feat: add common utilities and feedback handling scripts

- Introduced common.js for shared functions including API calls, error reporting, and UI helpers.
- Implemented feedback.js for the feedback reporting page, including form handling and displaying feedback status.
- Added layout.js for managing galaxy and section layouts in the visualization.
- Created login.js for user authentication with session management.
- Developed search.js for term searching based on user input and synonyms.
- Implemented signup.js for user registration with validation and immediate login.
This commit is contained in:
jsahn
2026-10-06 23:14:45 +09:00
parent 04158ac442
commit 4e943700fc
62 changed files with 158771 additions and 0 deletions
View File
+20
View File
@@ -0,0 +1,20 @@
"""python -m server [--host 0.0.0.0] [--port 8000] [--reload]"""
import argparse
import os
import uvicorn
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=8000)
ap.add_argument("--reload", action="store_true")
a = ap.parse_args()
# 리버스 프록시(시놀로지 등) 뒤: X-Forwarded-For/Proto 로 실제 방문자 IP·https 여부를 인식
uvicorn.run("server.app:app", host=a.host, port=a.port, reload=a.reload, proxy_headers=True,
forwarded_allow_ips=os.environ.get("ATLAS_FORWARDED_ALLOW_IPS", "*"))
if __name__ == "__main__":
main()
+1068
View File
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
"""인증.
- 교수자(owner): admins 테이블의 아이디·비밀번호 계정. 용어·관계·문제 수정, 회원·반 관리, 설정.
처음 실행할 때 아이디 ATLAS_OWNER_USERNAME(기본 professor), 비밀번호 ATLAS_OWNER_PASSWORD 또는
data/.owner_password 로 계정을 만든다. 이후 비밀번호는 DB에 해시로만 저장된다.
- 회원(member): 학생 계정. 교수자가 승인하면 사례·문제를 볼 수 있다. 학생·방문자는 오류 신고만 할 수 있다.
모두 서명된 쿠키 세션을 쓴다.
"""
import base64
import hashlib
import hmac
import os
import secrets
import time
from fastapi import HTTPException, Request
from .db import DB_PATH
DATA = DB_PATH.parent
COOKIE = "atlas_admin"
MEMBER_COOKIE = "atlas_member"
TTL = 60 * 60 * 24 * 14
def _read_or_create(path, factory):
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
return path.read_text().strip()
v = factory()
path.write_text(v)
try:
os.chmod(path, 0o600)
except OSError:
pass
return v
def secret() -> bytes:
return _read_or_create(DATA / ".secret", lambda: secrets.token_hex(32)).encode()
def owner_password() -> str:
env = os.environ.get("ATLAS_OWNER_PASSWORD")
if env:
return env
return _read_or_create(DATA / ".owner_password", lambda: secrets.token_urlsafe(12))
# ---------------------------------------------------------------- 서명 토큰
def sign(payload: str) -> str:
raw = payload.encode()
sig = hmac.new(secret(), raw, hashlib.sha256).hexdigest()
return base64.urlsafe_b64encode(raw).decode() + "." + sig
def unsign(token: str | None) -> str | None:
if not token or "." not in token:
return None
b64, sig = token.rsplit(".", 1)
try:
raw = base64.urlsafe_b64decode(b64.encode())
except Exception:
return None
if not hmac.compare_digest(hmac.new(secret(), raw, hashlib.sha256).hexdigest(), sig):
return None
return raw.decode()
def make_token(name: str, role: str = "owner") -> str:
return sign(f"{name}|{role}|{int(time.time()) + TTL}")
def read_session(token: str | None) -> tuple[str, str] | None:
"""(이름, 역할) 또는 None. 예전 형식(이름|만료)은 검수자로 본다."""
p = unsign(token)
if not p:
return None
parts = p.rsplit("|", 2)
if len(parts) == 3:
name, role, exp = parts
else:
name, exp = p.rsplit("|", 1)
role = "reviewer"
if not exp.isdigit() or int(exp) < time.time():
return None
return name, role
def read_token(token: str | None) -> str | None:
s = read_session(token)
return s[0] if s else None
def ensure_owner_account(con) -> dict | None:
"""교수자 계정이 없으면 만든다. 새로 만들었으면 {username, password} 를 돌려준다."""
if con.execute("SELECT 1 FROM admins").fetchone():
return None
username = os.environ.get("ATLAS_OWNER_USERNAME", "professor")
pw = owner_password()
with con:
con.execute("INSERT INTO admins(username,password_hash,name,created_at) VALUES(?,?,?,?)",
(username, hash_password(pw), "교수자", time.strftime("%Y-%m-%dT%H:%M:%S")))
# 평문 비밀번호 파일은 계정을 만든 뒤 지운다 (이후엔 DB의 해시만 사용)
f = DATA / ".owner_password"
if not os.environ.get("ATLAS_OWNER_PASSWORD") and f.exists():
f.unlink()
return {"username": username, "password": pw}
def check_login(con, username: str, pw: str) -> dict | None:
"""교수자 아이디·비밀번호가 맞으면 계정 행"""
r = con.execute("SELECT * FROM admins WHERE username=?", (username.strip(),)).fetchone()
if r and verify_password(pw, r["password_hash"]):
return dict(r)
return None
def current_admin(request: Request) -> str | None:
"""교수자로 로그인했으면 이름 (예전 검수자 세션은 인정하지 않음)."""
s = read_session(request.cookies.get(COOKIE))
return s[0] if s and s[1] == "owner" else None
def current_role(request: Request) -> str | None:
s = read_session(request.cookies.get(COOKIE))
return s[1] if s else None
def require_admin(request: Request) -> str:
name = current_admin(request)
if not name:
raise HTTPException(401, "교수자 로그인이 필요합니다.")
return name
def require_owner(request: Request) -> str:
s = read_session(request.cookies.get(COOKIE))
if not s:
raise HTTPException(401, "관리자 로그인이 필요합니다.")
if s[1] != "owner":
raise HTTPException(401, "교수자 로그인이 필요합니다.")
return s[0]
# ---------------------------------------------------------------- 회원 비밀번호·세션
def hash_password(pw: str) -> str:
salt = os.urandom(16)
h = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, 200_000)
return f"pbkdf2${salt.hex()}${h.hex()}"
def verify_password(pw: str, stored: str | None) -> bool:
try:
_, salt, h = (stored or "").split("$")
except ValueError:
return False
calc = hashlib.pbkdf2_hmac("sha256", pw.encode(), bytes.fromhex(salt), 200_000).hex()
return hmac.compare_digest(calc, h)
def make_member_token(user_id: int) -> str:
return sign(f"m|{user_id}|{int(time.time()) + TTL}")
def member_id(request: Request) -> int | None:
p = unsign(request.cookies.get(MEMBER_COOKIE))
if not p:
return None
try:
tag, uid, exp = p.split("|")
except ValueError:
return None
if tag != "m" or int(exp) < time.time():
return None
return int(uid)
+100
View File
@@ -0,0 +1,100 @@
"""피드백·오류를 터미널에서 확인하고 처리하는 도구.
.venv/bin/python -m server.cli feedback # 처리 대기(new, in_progress) 목록
.venv/bin/python -m server.cli feedback --all # 전체
.venv/bin/python -m server.cli show 12 # 한 건 자세히 (대상 용어 현재 값 포함)
.venv/bin/python -m server.cli resolve 12 "정의 문장을 교재 p.188 기준으로 수정"
.venv/bin/python -m server.cli status 12 in_progress
.venv/bin/python -m server.cli term 지역사회 간호사정 # 용어 현재 값 확인
"""
import argparse
import json
from . import db
def _print_fb(r, full=False):
tgt = f" [{r['target_type']}:{r['target_label'] or r['target_id']}]" if r["target_type"] else ""
occ = f" ×{r['occurrences']}" if (r["occurrences"] or 1) > 1 else ""
print(f"#{r['id']:<4} {r['status']:<11} {r['kind']:<12} {r['category'] or ''}{tgt}{occ} {r['created_at']}")
msg = r["message"] or ""
print(" " + (msg if full else msg[:160].replace("\n", " ")))
if r["suggestion"]:
print(" → 제안: " + (r["suggestion"] if full else r["suggestion"][:160]))
if full:
for k in ("page_url", "reporter", "contact", "admin_note", "resolution", "handled_by", "user_agent"):
if r[k]:
print(f" {k}: {r[k]}")
if r["context"]:
print(" context:", r["context"][:3000])
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
f = sub.add_parser("feedback")
f.add_argument("--all", action="store_true")
f.add_argument("--kind")
s = sub.add_parser("show")
s.add_argument("id", type=int)
r = sub.add_parser("resolve")
r.add_argument("id", type=int)
r.add_argument("resolution")
st = sub.add_parser("status")
st.add_argument("id", type=int)
st.add_argument("status", choices=["new", "in_progress", "resolved", "rejected", "duplicate"])
t = sub.add_parser("term")
t.add_argument("name", nargs="+")
a = ap.parse_args()
con = db.connect()
if a.cmd == "feedback":
where = "1=1" if a.all else "status IN ('new','in_progress')"
args = []
if a.kind:
where += " AND kind=?"
args.append(a.kind)
rows = con.execute(f"SELECT * FROM feedback WHERE {where} ORDER BY id", args).fetchall()
print(f"{len(rows)}건")
for r in rows:
_print_fb(r)
elif a.cmd == "show":
r = con.execute("SELECT * FROM feedback WHERE id=?", (a.id,)).fetchone()
if not r:
return print("없음")
_print_fb(r, full=True)
if r["target_type"] == "term" and r["target_id"]:
_show_term(con, r["target_id"])
elif a.cmd in ("resolve", "status"):
status = "resolved" if a.cmd == "resolve" else a.status
with con:
if a.cmd == "resolve":
con.execute("UPDATE feedback SET status=?, resolution=?, handled_by='developer', updated_at=? WHERE id=?",
(status, a.resolution, db.now(), a.id))
else:
con.execute("UPDATE feedback SET status=?, handled_by='developer', updated_at=? WHERE id=?", (status, db.now(), a.id))
db.audit(con, "developer(cli)", "update", "feedback", str(a.id), None, {"status": status})
print("완료")
elif a.cmd == "term":
name = " ".join(a.name)
r = con.execute("SELECT term_id FROM terms WHERE name=? OR term_id=?", (name, name)).fetchone()
if not r:
rows = con.execute("SELECT term_id,name FROM terms WHERE name LIKE ? LIMIT 20", (f"%{name}%",)).fetchall()
for x in rows:
print(x["term_id"], x["name"])
return
_show_term(con, r["term_id"])
def _show_term(con, term_id):
t = db.row(con.execute("SELECT * FROM terms WHERE term_id=?", (term_id,)).fetchone())
print("\n--- 대상 용어 현재 값")
print(json.dumps({k: t[k] for k in ("term_id", "name", "aliases", "galaxy_id", "section_id", "definition_text",
"definition_book", "definition_page", "easy", "example", "review_status")},
ensure_ascii=False, indent=1))
for s in con.execute("SELECT book_id,page,path,snippet FROM term_sources WHERE term_id=?", (term_id,)):
print(f" 출처 {s['book_id']} p.{s['page']} {s['path']}\n {(s['snippet'] or '')[:150]}")
if __name__ == "__main__":
main()
+224
View File
@@ -0,0 +1,224 @@
"""SQLite 저장소. 스키마, 연결, JSON 컬럼 도우미."""
import json
import os
import sqlite3
import threading
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DB_PATH = Path(os.environ.get("ATLAS_DB", ROOT / "data" / "atlas.db"))
SCHEMA = """
CREATE TABLE IF NOT EXISTS books (
book_id TEXT PRIMARY KEY, title TEXT, short TEXT, edition TEXT, year INTEGER,
is_primary INTEGER, filename TEXT
);
CREATE TABLE IF NOT EXISTS galaxies (
galaxy_id TEXT PRIMARY KEY, name TEXT, sub TEXT, color TEXT, ord INTEGER, chapter_no INTEGER,
edited INTEGER DEFAULT 0, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS toc (
toc_id TEXT PRIMARY KEY, book_id TEXT, kind TEXT, level INTEGER, number TEXT, title TEXT,
page INTEGER, parent_id TEXT, galaxy_id TEXT, easy_sub TEXT, edited INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS terms (
term_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT DEFAULT 'concept',
lang TEXT DEFAULT 'ko',
aliases TEXT DEFAULT '[]',
search_terms TEXT DEFAULT '[]',
galaxy_id TEXT,
section_id TEXT,
section_guess INTEGER DEFAULT 0,
item_id TEXT,
section_ids TEXT DEFAULT '[]',
definition_text TEXT, definition_book TEXT, definition_page INTEGER, definition_method TEXT,
easy TEXT, example TEXT, easy_method TEXT,
review_status TEXT DEFAULT 'draft',
hidden INTEGER DEFAULT 0,
origin TEXT DEFAULT 'auto',
edited INTEGER DEFAULT 0,
reviewer TEXT, note TEXT,
created_at TEXT, updated_at TEXT
);
CREATE INDEX IF NOT EXISTS terms_galaxy ON terms(galaxy_id);
CREATE INDEX IF NOT EXISTS terms_section ON terms(section_id);
CREATE TABLE IF NOT EXISTS term_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
term_id TEXT NOT NULL, book_id TEXT, page INTEGER, pdf_page INTEGER,
toc_id TEXT, section_id TEXT, galaxy_id TEXT, path TEXT, snippet TEXT,
origin TEXT DEFAULT 'auto'
);
CREATE INDEX IF NOT EXISTS sources_term ON term_sources(term_id);
CREATE TABLE IF NOT EXISTS relations (
relation_id TEXT PRIMARY KEY,
source TEXT NOT NULL, target TEXT NOT NULL,
type TEXT NOT NULL, label TEXT, reason TEXT,
directed INTEGER DEFAULT 1,
method TEXT, evidence TEXT DEFAULT '[]',
review_status TEXT DEFAULT 'draft',
hidden INTEGER DEFAULT 0,
edited INTEGER DEFAULT 0,
reviewer TEXT, created_at TEXT, updated_at TEXT
);
CREATE INDEX IF NOT EXISTS rel_source ON relations(source);
CREATE INDEX IF NOT EXISTS rel_target ON relations(target);
CREATE TABLE IF NOT EXISTS feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT, updated_at TEXT,
kind TEXT, -- content | bug | search | suggestion | client_error
category TEXT,
target_type TEXT, target_id TEXT, target_label TEXT,
message TEXT, suggestion TEXT,
page_url TEXT, user_agent TEXT, context TEXT,
reporter TEXT, contact TEXT,
status TEXT DEFAULT 'new', -- new | in_progress | resolved | rejected | duplicate
admin_note TEXT, resolution TEXT, handled_by TEXT,
occurrences INTEGER DEFAULT 1, fingerprint TEXT
);
CREATE INDEX IF NOT EXISTS feedback_status ON feedback(status);
CREATE INDEX IF NOT EXISTS feedback_fp ON feedback(fingerprint);
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT, actor TEXT, action TEXT, entity TEXT, entity_id TEXT, before TEXT, after TEXT
);
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS admins (
admin_id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL, name TEXT,
created_at TEXT, last_login TEXT
);
CREATE TABLE IF NOT EXISTS classes (
class_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, note TEXT, created_at TEXT
);
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL, student_no TEXT, note TEXT,
status TEXT DEFAULT 'pending', -- pending | approved | rejected | disabled
class_id INTEGER,
created_at TEXT, approved_at TEXT, approved_by TEXT, last_login TEXT
);
CREATE INDEX IF NOT EXISTS users_status ON users(status);
CREATE TABLE IF NOT EXISTS signup_codes (
code_id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE COLLATE NOCASE,
class_id INTEGER, note TEXT,
active INTEGER DEFAULT 1, auto_approve INTEGER DEFAULT 0,
max_uses INTEGER, uses INTEGER DEFAULT 0, expires_at TEXT,
created_by TEXT, created_at TEXT
);
CREATE TABLE IF NOT EXISTS questions (
question_id INTEGER PRIMARY KEY AUTOINCREMENT,
term_id TEXT, galaxy_id TEXT,
qtype TEXT DEFAULT 'mcq', -- mcq | ox
prompt TEXT NOT NULL, choices TEXT DEFAULT '[]', answer INTEGER NOT NULL,
explanation TEXT,
review_status TEXT DEFAULT 'draft', hidden INTEGER DEFAULT 0,
created_by TEXT, created_at TEXT, updated_at TEXT
);
CREATE INDEX IF NOT EXISTS questions_term ON questions(term_id);
CREATE TABLE IF NOT EXISTS attempts (
attempt_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER, class_id INTEGER,
qkey TEXT, term_id TEXT, galaxy_id TEXT, qtype TEXT,
choice INTEGER, correct INTEGER, created_at TEXT
);
CREATE INDEX IF NOT EXISTS attempts_user ON attempts(user_id);
CREATE INDEX IF NOT EXISTS attempts_class ON attempts(class_id);
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
"""
DEFAULT_SETTINGS = {
# 학생 화면에 보여줄 검수 상태
"public_statuses": ["draft", "review", "confirmed"],
# 학생 화면에 '검수 전' 표시
"show_status_badges": True,
}
JSON_COLS = {"aliases", "search_terms", "section_ids", "evidence", "choices"}
_local = threading.local()
def now() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%S")
def connect(path: Path | None = None) -> sqlite3.Connection:
p = Path(path or DB_PATH)
p.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(p, check_same_thread=False, timeout=15)
con.row_factory = sqlite3.Row
con.execute("PRAGMA journal_mode=WAL")
con.execute("PRAGMA foreign_keys=ON")
con.executescript(SCHEMA)
_migrate(con)
return con
def _migrate(con):
"""기존 DB에 새 컬럼 추가."""
cols = {r[1] for r in con.execute("PRAGMA table_info(users)")}
if "signup_code_id" not in cols:
con.execute("ALTER TABLE users ADD COLUMN signup_code_id INTEGER")
con.commit()
def get() -> sqlite3.Connection:
con = getattr(_local, "con", None)
if con is None or getattr(_local, "path", None) != str(DB_PATH):
con = _local.con = connect()
_local.path = str(DB_PATH)
return con
def row(r: sqlite3.Row | None) -> dict | None:
if r is None:
return None
d = dict(r)
for k in JSON_COLS & d.keys():
try:
d[k] = json.loads(d[k]) if d[k] else []
except (TypeError, json.JSONDecodeError):
d[k] = []
return d
def rows(rs) -> list[dict]:
return [row(r) for r in rs]
def dumps(v) -> str:
return json.dumps(v, ensure_ascii=False)
def get_setting(con, key):
r = con.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
if r is None:
return DEFAULT_SETTINGS.get(key)
return json.loads(r["value"])
def all_settings(con) -> dict:
out = dict(DEFAULT_SETTINGS)
for r in con.execute("SELECT key, value FROM settings"):
out[r["key"]] = json.loads(r["value"])
return out
def set_setting(con, key, value):
con.execute("INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, dumps(value)))
def audit(con, actor, action, entity, entity_id, before=None, after=None):
con.execute(
"INSERT INTO audit(at, actor, action, entity, entity_id, before, after) VALUES(?,?,?,?,?,?,?)",
(now(), actor, action, entity, entity_id,
dumps(before) if before is not None else None, dumps(after) if after is not None else None),
)
+673
View File
@@ -0,0 +1,673 @@
"""회원(학생)·반·문제.
- 누구나: 지도·검색·쉬운 뜻·관계
- 승인된 회원(또는 관리자): 사례, 문제 풀기
- 교수자만: 가입 코드·회원·반 관리, 직접 출제한 문제 관리
- 학생은 교수자가 수업에서 알려 준 가입 코드를 넣으면 바로 회원이 된다 (코드에 지정된 반으로 배정)
문제는 두 종류다.
1) 자동 객관식: 쉬운 뜻 → 용어 고르기 / 용어 → 뜻 고르기 (쉬운 설명이 있는 용어로 즉석 생성)
2) 직접 출제: questions 테이블 (객관식·OX)
정답은 서버만 안다. 문제마다 서명된 토큰을 주고, 답을 내면 서버가 채점·기록한다.
"""
import json
import random
import re
import time
from collections import defaultdict
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field
from . import auth, db
router = APIRouter()
PUBLIC_TERM_STATUSES = ("draft", "review", "confirmed")
MIN_EASY = 12
def _rate_limit(request: Request, limit=20, window=600):
from .app import _rate_limit as rl
rl(request, limit=limit, window=window)
# ---------------------------------------------------------------- 회원 상태
def current_member(con, request: Request) -> dict | None:
uid = auth.member_id(request)
if not uid:
return None
r = con.execute(
"SELECT u.*, c.name AS class_name FROM users u LEFT JOIN classes c ON c.class_id=u.class_id WHERE u.user_id=?",
(uid,)).fetchone()
return dict(r) if r else None
def can_view_members_content(con, request: Request) -> bool:
"""사례·문제를 볼 수 있는가: 승인된 회원 또는 관리자(소유자·검수자)."""
if auth.current_admin(request):
return True
m = current_member(con, request)
return bool(m and m["status"] == "approved")
def require_member_content(request: Request):
con = db.get()
if not can_view_members_content(con, request):
m = current_member(con, request)
if m and m["status"] == "pending":
raise HTTPException(403, "가입 승인을 기다리는 중입니다.")
raise HTTPException(401, "회원 전용입니다. 로그인해 주세요.")
return con
# ---------------------------------------------------------------- 가입·로그인
USERNAME = re.compile(r"^[A-Za-z0-9_.\-]{3,30}$")
class SignupIn(BaseModel):
code: str = Field(..., min_length=1, max_length=40)
username: str = Field(..., min_length=3, max_length=30)
password: str = Field(..., min_length=6, max_length=100)
name: str = Field(..., min_length=1, max_length=40)
student_no: str | None = Field(None, max_length=30)
note: str | None = Field(None, max_length=300)
website: str | None = None # 스팸 방지
@router.post("/api/members/signup")
def signup(body: SignupIn, request: Request):
_rate_limit(request, limit=10, window=600)
if body.website:
return {"ok": True, "status": "pending"}
if not USERNAME.match(body.username):
raise HTTPException(400, "아이디는 영문·숫자·_ . - 로 3~30자입니다.")
con = db.get()
code = _valid_code(con, body.code)
if con.execute("SELECT 1 FROM users WHERE username=?", (body.username,)).fetchone() or \
con.execute("SELECT 1 FROM admins WHERE username=?", (body.username,)).fetchone():
raise HTTPException(409, "이미 사용 중인 아이디입니다.")
status = "approved" # 오프라인에서 받은 코드가 곧 인증
with con:
con.execute(
"INSERT INTO users(username,password_hash,name,student_no,note,status,class_id,signup_code_id,created_at,approved_at,approved_by)"
" VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(body.username, auth.hash_password(body.password), body.name.strip(), (body.student_no or "").strip() or None,
(body.note or "").strip() or None, status, code["class_id"], code["code_id"], db.now(),
db.now(), f"가입 코드 {code['code']}"))
con.execute("UPDATE signup_codes SET uses=uses+1 WHERE code_id=?", (code["code_id"],))
return {"ok": True, "status": status}
def _valid_code(con, raw: str):
c = con.execute("SELECT * FROM signup_codes WHERE code=?", (raw.strip().upper(),)).fetchone()
if not c or not c["active"]:
raise HTTPException(400, "가입 코드가 맞지 않습니다. 교수자에게 받은 코드를 확인해 주세요.")
if c["expires_at"] and c["expires_at"] < db.now()[:10]:
raise HTTPException(400, "기한이 지난 가입 코드입니다. 교수자에게 새 코드를 받아 주세요.")
if c["max_uses"] and c["uses"] >= c["max_uses"]:
raise HTTPException(400, "사용 횟수가 다 된 가입 코드입니다. 교수자에게 문의해 주세요.")
return c
class LoginIn(BaseModel):
username: str
password: str
@router.post("/api/members/login")
def login(body: LoginIn, request: Request, response: Response):
_rate_limit(request, limit=15, window=300)
con = db.get()
r = con.execute("SELECT * FROM users WHERE username=?", (body.username.strip(),)).fetchone()
if not r or not auth.verify_password(body.password, r["password_hash"]):
raise HTTPException(401, "아이디 또는 비밀번호가 맞지 않습니다.")
if r["status"] in ("rejected", "disabled"):
raise HTTPException(403, "사용할 수 없는 계정입니다. 담당 교수자에게 문의해 주세요.")
with con:
con.execute("UPDATE users SET last_login=? WHERE user_id=?", (db.now(), r["user_id"]))
response.set_cookie(auth.MEMBER_COOKIE, auth.make_member_token(r["user_id"]), max_age=auth.TTL, httponly=True,
samesite="lax", secure=request.url.scheme == "https")
return {"ok": True, "status": r["status"], "name": r["name"]}
@router.post("/api/members/logout")
def logout(response: Response):
response.delete_cookie(auth.MEMBER_COOKIE)
return {"ok": True}
@router.get("/api/members/me")
def me(request: Request):
con = db.get()
m = current_member(con, request)
admin = auth.current_admin(request)
out = {"logged_in": bool(m), "admin": admin, "role": auth.current_role(request),
"can_view": can_view_members_content(con, request)}
if m:
st = con.execute("SELECT COUNT(*), COALESCE(SUM(correct),0) FROM attempts WHERE user_id=?", (m["user_id"],)).fetchone()
out.update(username=m["username"], name=m["name"], status=m["status"], class_name=m["class_name"],
attempts=st[0], correct=st[1])
return out
@router.get("/api/members/my-stats")
def my_stats(request: Request):
con = db.get()
m = current_member(con, request)
if not m:
raise HTTPException(401, "로그인이 필요합니다.")
by_g = [dict(r) for r in con.execute(
"SELECT g.galaxy_id, g.name, COUNT(a.attempt_id) AS attempts, COALESCE(SUM(a.correct),0) AS correct"
" FROM attempts a JOIN galaxies g ON g.galaxy_id=a.galaxy_id WHERE a.user_id=? GROUP BY g.galaxy_id ORDER BY g.ord",
(m["user_id"],))]
wrong = [dict(r) for r in con.execute(
"SELECT t.term_id, t.name, COUNT(*) AS wrong FROM attempts a JOIN terms t ON t.term_id=a.term_id"
" WHERE a.user_id=? AND a.correct=0 GROUP BY t.term_id ORDER BY wrong DESC, MAX(a.attempt_id) DESC LIMIT 15",
(m["user_id"],))]
return {"by_galaxy": by_g, "wrong_terms": wrong}
# ---------------------------------------------------------------- 문제 만들기
def _pool(con):
"""쉬운 설명이 있는 공개 개념 용어."""
return [dict(r) for r in con.execute(
"SELECT term_id, name, easy, galaxy_id, section_id FROM terms WHERE hidden=0 AND kind='concept'"
" AND easy IS NOT NULL AND length(easy)>=? AND review_status IN ('draft','review','confirmed')", (MIN_EASY,))]
def _mask(text: str, name: str, repl="○○") -> str:
pat = re.escape(name).replace(r"\ ", r"\s?")
return re.sub(pat, repl, text)
def _distractors(pool, t, k=3, rng=random):
same_sec = [p for p in pool if p["section_id"] == t["section_id"] and p["term_id"] != t["term_id"]]
same_gal = [p for p in pool if p["galaxy_id"] == t["galaxy_id"] and p["term_id"] != t["term_id"] and p not in same_sec]
others = [p for p in pool if p["galaxy_id"] != t["galaxy_id"]]
out = []
for group in (same_sec, same_gal, others):
rng.shuffle(group)
for p in group:
if len(out) >= k:
return out
# 이름이 서로 포함되면(예: 건강증진/건강증진사업) 헷갈리는 오답이 아니라 함정이 되므로 제외
if p["name"] in t["name"] or t["name"] in p["name"]:
continue
if p["easy"] == t["easy"]:
continue
out.append(p)
return out
def _token(data: dict) -> str:
return auth.sign(json.dumps(data, ensure_ascii=False, separators=(",", ":")))
def auto_question(pool, t, qtype, rng=random):
ds = _distractors(pool, t, 3, rng)
if len(ds) < 3:
return None
opts = [t] + ds
rng.shuffle(opts)
ans = next(i for i, o in enumerate(opts) if o["term_id"] == t["term_id"])
if qtype == "m2t":
prompt = "다음 설명에 해당하는 용어는 무엇일까요?"
quote = _mask(t["easy"], t["name"])
choices = [o["name"] for o in opts]
else:
prompt = f"‘{t['name']}’의 뜻으로 알맞은 것은 무엇일까요?"
quote = None
choices = [_mask(o["easy"], o["name"], "이것") for o in opts]
token = _token({"k": "auto", "t": t["term_id"], "g": t["galaxy_id"], "ty": qtype, "a": ans,
"ids": [o["term_id"] for o in opts], "iat": int(time.time())})
return {"token": token, "qtype": qtype, "prompt": prompt, "quote": quote, "choices": choices,
"term_id": t["term_id"], "origin": "auto"}
def manual_question(q: dict):
token = _token({"k": "q", "id": q["question_id"], "iat": int(time.time())})
choices = q["choices"] if q["qtype"] == "mcq" else ["O", "X"]
return {"token": token, "qtype": q["qtype"], "prompt": q["prompt"], "quote": None, "choices": choices,
"term_id": q["term_id"], "origin": "manual"}
def _manual_for(con, where, args):
return db.rows(con.execute(
f"SELECT * FROM questions WHERE hidden=0 AND review_status IN ('review','confirmed') AND {where}", args))
@router.get("/api/members/questions")
def term_questions(term: str, request: Request):
"""용어 상세의 확인 문제: 직접 출제 문제 + 자동 2문제."""
con = require_member_content(request)
out = [manual_question(q) for q in _manual_for(con, "term_id=?", (term,))]
pool = _pool(con)
t = next((p for p in pool if p["term_id"] == term), None)
if t:
for qt in ("m2t", "t2m"):
q = auto_question(pool, t, qt)
if q:
out.append(q)
return {"questions": out}
@router.get("/api/members/quiz")
def quiz(request: Request, galaxy: str | None = None, n: int = Query(10, ge=1, le=30), wrong_only: bool = False):
"""문제 풀기: 은하(단원)를 골라 n문제. wrong_only=1 이면 내가 틀린 용어 위주."""
con = require_member_content(request)
pool = _pool(con)
targets = [p for p in pool if not galaxy or p["galaxy_id"] == galaxy]
m = current_member(con, request)
if wrong_only and m:
wrong = {r[0] for r in con.execute("SELECT term_id FROM attempts WHERE user_id=? AND correct=0", (m["user_id"],))}
right = {r[0] for r in con.execute(
"SELECT term_id FROM attempts a WHERE user_id=? AND correct=1 AND attempt_id > "
"(SELECT COALESCE(MAX(attempt_id),0) FROM attempts b WHERE b.user_id=a.user_id AND b.term_id=a.term_id AND b.correct=0)",
(m["user_id"],))}
targets = [p for p in targets if p["term_id"] in wrong - right]
manual = _manual_for(con, "(galaxy_id=? OR ?)", (galaxy, 1 if not galaxy else 0))
random.shuffle(targets)
out = [manual_question(q) for q in random.sample(manual, min(len(manual), max(0, n // 3)))] if not wrong_only else []
for t in targets:
if len(out) >= n:
break
q = auto_question(pool, t, random.choice(("m2t", "t2m")))
if q:
out.append(q)
random.shuffle(out)
return {"questions": out[:n], "available": len(targets)}
class AnswerIn(BaseModel):
token: str
choice: int
@router.post("/api/members/answer")
def answer(body: AnswerIn, request: Request):
con = require_member_content(request)
raw = auth.unsign(body.token)
if not raw:
raise HTTPException(400, "문제 정보가 올바르지 않습니다. 새로고침해 주세요.")
d = json.loads(raw)
if d["k"] == "auto":
correct_idx = d["a"]
term_id, galaxy_id, qtype, qkey = d["t"], d["g"], d["ty"], f"auto:{d['ty']}:{d['t']}"
t = con.execute("SELECT name, easy FROM terms WHERE term_id=?", (term_id,)).fetchone()
explanation = f"정답은 ‘{t['name']}’입니다. {t['easy'] or ''}".strip() if t else None
else:
q = db.row(con.execute("SELECT * FROM questions WHERE question_id=?", (d["id"],)).fetchone())
if not q:
raise HTTPException(404, "문제가 삭제되었습니다.")
correct_idx, term_id, galaxy_id, qtype, qkey = q["answer"], q["term_id"], q["galaxy_id"], q["qtype"], f"q:{q['question_id']}"
explanation = q["explanation"]
if not galaxy_id and term_id:
g = con.execute("SELECT galaxy_id FROM terms WHERE term_id=?", (term_id,)).fetchone()
galaxy_id = g["galaxy_id"] if g else None
ok = int(body.choice == correct_idx)
m = current_member(con, request)
if m and m["status"] == "approved":
with con:
con.execute(
"INSERT INTO attempts(user_id,class_id,qkey,term_id,galaxy_id,qtype,choice,correct,created_at) VALUES(?,?,?,?,?,?,?,?,?)",
(m["user_id"], m["class_id"], qkey, term_id, galaxy_id, qtype, body.choice, ok, db.now()))
return {"correct": bool(ok), "answer": correct_idx, "explanation": explanation, "term_id": term_id}
# ---------------------------------------------------------------- 소유자: 회원·반
@router.get("/api/admin/members")
def admin_members(status: str | None = None, class_id: int | None = None, q: str | None = None,
_: str = Depends(auth.require_owner)):
con = db.get()
where, args = ["1=1"], []
if status:
where.append("u.status=?")
args.append(status)
if class_id is not None:
where.append("u.class_id=?")
args.append(class_id)
if q:
where.append("(u.username LIKE ? OR u.name LIKE ? OR u.student_no LIKE ?)")
args += [f"%{q}%"] * 3
rows = [dict(r) for r in con.execute(
"SELECT u.user_id,u.username,u.name,u.student_no,u.note,u.status,u.class_id,c.name AS class_name,u.created_at,"
" u.approved_at,u.approved_by,u.last_login,(SELECT code FROM signup_codes s WHERE s.code_id=u.signup_code_id) AS signup_code,"
" (SELECT COUNT(*) FROM attempts a WHERE a.user_id=u.user_id) AS attempts,"
" (SELECT COALESCE(SUM(correct),0) FROM attempts a WHERE a.user_id=u.user_id) AS correct"
f" FROM users u LEFT JOIN classes c ON c.class_id=u.class_id WHERE {' AND '.join(where)}"
" ORDER BY u.status='pending' DESC, u.created_at DESC", args)]
counts = {r[0]: r[1] for r in con.execute("SELECT status, COUNT(*) FROM users GROUP BY status")}
return {"items": rows, "counts": counts}
@router.get("/api/admin/members/{user_id}")
def admin_member_detail(user_id: int, _: str = Depends(auth.require_owner)):
con = db.get()
u = con.execute(
"SELECT u.*, c.name AS class_name, (SELECT code FROM signup_codes s WHERE s.code_id=u.signup_code_id) AS signup_code"
" FROM users u LEFT JOIN classes c ON c.class_id=u.class_id WHERE u.user_id=?", (user_id,)).fetchone()
if not u:
raise HTTPException(404)
u = dict(u)
u.pop("password_hash", None)
u["by_galaxy"] = [dict(r) for r in con.execute(
"SELECT g.galaxy_id, g.name, COUNT(a.attempt_id) AS attempts, COALESCE(SUM(a.correct),0) AS correct"
" FROM attempts a JOIN galaxies g ON g.galaxy_id=a.galaxy_id WHERE a.user_id=? GROUP BY g.galaxy_id ORDER BY g.ord", (user_id,))]
u["wrong_terms"] = [dict(r) for r in con.execute(
"SELECT t.term_id, t.name, COUNT(*) AS wrong FROM attempts a JOIN terms t ON t.term_id=a.term_id"
" WHERE a.user_id=? AND a.correct=0 GROUP BY t.term_id ORDER BY wrong DESC LIMIT 15", (user_id,))]
u["recent"] = [dict(r) for r in con.execute(
"SELECT a.created_at, a.correct, a.qtype, t.name AS term_name, a.term_id FROM attempts a LEFT JOIN terms t ON t.term_id=a.term_id"
" WHERE a.user_id=? ORDER BY a.attempt_id DESC LIMIT 20", (user_id,))]
u["days"] = [dict(r) for r in con.execute(
"SELECT substr(created_at,1,10) AS day, COUNT(*) AS attempts, SUM(correct) AS correct FROM attempts"
" WHERE user_id=? GROUP BY day ORDER BY day DESC LIMIT 14", (user_id,))]
return u
class MemberPatch(BaseModel):
status: str | None = None
class_id: int | None = None
name: str | None = Field(None, max_length=40)
student_no: str | None = Field(None, max_length=30)
new_password: str | None = Field(None, min_length=6, max_length=100)
@router.patch("/api/admin/members/{user_id}")
def admin_member_update(user_id: int, body: MemberPatch, who: str = Depends(auth.require_owner)):
con = db.get()
u = con.execute("SELECT * FROM users WHERE user_id=?", (user_id,)).fetchone()
if not u:
raise HTTPException(404)
data = body.model_dump(exclude_unset=True)
if "status" in data and data["status"] not in ("pending", "approved", "rejected", "disabled"):
raise HTTPException(400, "잘못된 상태")
if data.get("status") == "approved" and not (data.get("class_id") or u["class_id"]):
raise HTTPException(400, "승인할 때 반을 지정해 주세요.")
if data.get("class_id") and not con.execute("SELECT 1 FROM classes WHERE class_id=?", (data["class_id"],)).fetchone():
raise HTTPException(400, "없는 반입니다.")
sets, args = [], []
for k in ("status", "class_id", "name", "student_no"):
if k in data:
sets.append(f"{k}=?")
args.append(data[k])
if data.get("new_password"):
sets.append("password_hash=?")
args.append(auth.hash_password(data["new_password"]))
if data.get("status") == "approved" and u["status"] != "approved":
sets += ["approved_at=?", "approved_by=?"]
args += [db.now(), who]
if not sets:
return dict(u)
with con:
con.execute(f"UPDATE users SET {','.join(sets)} WHERE user_id=?", (*args, user_id))
db.audit(con, who, "update", "member", str(user_id), {k: u[k] for k in data if k in u.keys()},
{k: v for k, v in data.items() if k != "new_password"})
return dict(con.execute("SELECT * FROM users WHERE user_id=?", (user_id,)).fetchone()) | {"password_hash": None}
@router.delete("/api/admin/members/{user_id}")
def admin_member_delete(user_id: int, who: str = Depends(auth.require_owner)):
con = db.get()
with con:
con.execute("DELETE FROM users WHERE user_id=?", (user_id,))
db.audit(con, who, "delete", "member", str(user_id), None, None)
return {"ok": True}
@router.get("/api/admin/classes")
def admin_classes(_: str = Depends(auth.require_admin)):
con = db.get()
return {"items": [dict(r) for r in con.execute(
"SELECT c.*, (SELECT COUNT(*) FROM users u WHERE u.class_id=c.class_id AND u.status='approved') AS members,"
" (SELECT COUNT(*) FROM attempts a WHERE a.class_id=c.class_id) AS attempts,"
" (SELECT COALESCE(SUM(correct),0) FROM attempts a WHERE a.class_id=c.class_id) AS correct"
" FROM classes c ORDER BY c.name")]}
class ClassIn(BaseModel):
name: str = Field(..., min_length=1, max_length=40)
note: str | None = Field(None, max_length=200)
@router.post("/api/admin/classes")
def admin_class_create(body: ClassIn, who: str = Depends(auth.require_owner)):
con = db.get()
if con.execute("SELECT 1 FROM classes WHERE name=?", (body.name.strip(),)).fetchone():
raise HTTPException(409, "같은 이름의 반이 있습니다.")
with con:
cur = con.execute("INSERT INTO classes(name,note,created_at) VALUES(?,?,?)", (body.name.strip(), body.note, db.now()))
db.audit(con, who, "create", "class", str(cur.lastrowid), None, body.model_dump())
return {"class_id": cur.lastrowid}
@router.put("/api/admin/classes/{class_id}")
def admin_class_update(class_id: int, body: ClassIn, who: str = Depends(auth.require_owner)):
con = db.get()
with con:
con.execute("UPDATE classes SET name=?, note=? WHERE class_id=?", (body.name.strip(), body.note, class_id))
db.audit(con, who, "update", "class", str(class_id), None, body.model_dump())
return {"ok": True}
@router.delete("/api/admin/classes/{class_id}")
def admin_class_delete(class_id: int, who: str = Depends(auth.require_owner)):
con = db.get()
if con.execute("SELECT 1 FROM users WHERE class_id=?", (class_id,)).fetchone():
raise HTTPException(409, "이 반에 회원이 있어 지울 수 없습니다. 회원의 반을 먼저 옮겨 주세요.")
with con:
con.execute("DELETE FROM classes WHERE class_id=?", (class_id,))
db.audit(con, who, "delete", "class", str(class_id), None, None)
return {"ok": True}
@router.get("/api/admin/class-stats")
def admin_class_stats(class_id: int | None = None, _: str = Depends(auth.require_admin)):
"""반별 학습 현황: 은하별 정답률, 많이 틀린 용어."""
con = db.get()
w, args = ("WHERE a.class_id=?", [class_id]) if class_id else ("", [])
by_g = [dict(r) for r in con.execute(
f"SELECT g.galaxy_id, g.name, COUNT(a.attempt_id) AS attempts, COALESCE(SUM(a.correct),0) AS correct"
f" FROM attempts a JOIN galaxies g ON g.galaxy_id=a.galaxy_id {w} GROUP BY g.galaxy_id ORDER BY g.ord", args)]
wrong = [dict(r) for r in con.execute(
f"SELECT t.term_id, t.name, COUNT(*) AS attempts, SUM(1-a.correct) AS wrong FROM attempts a"
f" JOIN terms t ON t.term_id=a.term_id {w} GROUP BY t.term_id HAVING wrong>0 ORDER BY wrong DESC LIMIT 20", args)]
return {"by_galaxy": by_g, "wrong_terms": wrong}
# ---------------------------------------------------------------- 검수자·소유자: 직접 출제 문제
class QuestionIn(BaseModel):
term_id: str | None = None
galaxy_id: str | None = None
qtype: str = "mcq"
prompt: str = Field(..., min_length=2, max_length=2000)
choices: list[str] = []
answer: int
explanation: str | None = Field(None, max_length=2000)
review_status: str = "review"
hidden: bool = False
def _check_q(con, body: QuestionIn):
if body.qtype not in ("mcq", "ox"):
raise HTTPException(400, "문제 유형은 객관식(mcq) 또는 OX입니다.")
if body.qtype == "mcq":
ch = [c.strip() for c in body.choices if c.strip()]
if len(ch) < 2 or len(ch) > 6:
raise HTTPException(400, "보기는 2~6개입니다.")
if not 0 <= body.answer < len(ch):
raise HTTPException(400, "정답 번호를 확인해 주세요.")
body.choices = ch
else:
body.choices = []
if body.answer not in (0, 1):
raise HTTPException(400, "OX 정답은 0(O) 또는 1(X)입니다.")
if body.term_id:
t = con.execute("SELECT galaxy_id FROM terms WHERE term_id=?", (body.term_id,)).fetchone()
if not t:
raise HTTPException(400, "없는 용어입니다.")
body.galaxy_id = body.galaxy_id or t["galaxy_id"]
if body.review_status not in ("draft", "review", "confirmed"):
raise HTTPException(400, "잘못된 검수 상태")
@router.get("/api/admin/questions")
def admin_questions(term: str | None = None, galaxy: str | None = None, _: str = Depends(auth.require_admin)):
con = db.get()
where, args = ["1=1"], []
if term:
where.append("q.term_id=?")
args.append(term)
if galaxy:
where.append("q.galaxy_id=?")
args.append(galaxy)
items = db.rows(con.execute(
f"SELECT q.*, t.name AS term_name,"
f" (SELECT COUNT(*) FROM attempts a WHERE a.qkey='q:'||q.question_id) AS attempts,"
f" (SELECT COALESCE(SUM(correct),0) FROM attempts a WHERE a.qkey='q:'||q.question_id) AS correct"
f" FROM questions q LEFT JOIN terms t ON t.term_id=q.term_id WHERE {' AND '.join(where)} ORDER BY q.question_id DESC", args))
return {"items": items}
@router.post("/api/admin/questions")
def admin_question_create(body: QuestionIn, who: str = Depends(auth.require_admin)):
con = db.get()
_check_q(con, body)
with con:
cur = con.execute(
"INSERT INTO questions(term_id,galaxy_id,qtype,prompt,choices,answer,explanation,review_status,hidden,created_by,created_at,updated_at)"
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
(body.term_id, body.galaxy_id, body.qtype, body.prompt.strip(), db.dumps(body.choices), body.answer,
body.explanation, body.review_status, int(body.hidden), who, db.now(), db.now()))
db.audit(con, who, "create", "question", str(cur.lastrowid), None, body.model_dump())
return {"question_id": cur.lastrowid}
@router.put("/api/admin/questions/{qid}")
def admin_question_update(qid: int, body: QuestionIn, who: str = Depends(auth.require_admin)):
con = db.get()
_check_q(con, body)
with con:
con.execute(
"UPDATE questions SET term_id=?,galaxy_id=?,qtype=?,prompt=?,choices=?,answer=?,explanation=?,review_status=?,hidden=?,updated_at=? WHERE question_id=?",
(body.term_id, body.galaxy_id, body.qtype, body.prompt.strip(), db.dumps(body.choices), body.answer,
body.explanation, body.review_status, int(body.hidden), db.now(), qid))
db.audit(con, who, "update", "question", str(qid), None, body.model_dump())
return {"ok": True}
@router.delete("/api/admin/questions/{qid}")
def admin_question_delete(qid: int, who: str = Depends(auth.require_admin)):
con = db.get()
with con:
con.execute("DELETE FROM questions WHERE question_id=?", (qid,))
db.audit(con, who, "delete", "question", str(qid), None, None)
return {"ok": True}
@router.get("/api/admin/questions/preview")
def admin_question_preview(term: str, _: str = Depends(auth.require_admin)):
"""이 용어로 자동 생성되는 문제 미리보기 (정답 포함)."""
con = db.get()
pool = _pool(con)
t = next((p for p in pool if p["term_id"] == term), None)
if not t:
return {"items": [], "reason": f"쉬운 설명({MIN_EASY}자 이상)이 있어야 자동 문제가 만들어집니다."}
out = []
for qt in ("m2t", "t2m"):
q = auto_question(pool, t, qt)
if q:
q["answer"] = json.loads(auth.unsign(q.pop("token")))["a"]
out.append(q)
return {"items": out}
# ---------------------------------------------------------------- 교수자: 가입 코드
CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # 헷갈리는 0/O, 1/I 제외
def _new_code(con):
for _ in range(50):
c = "".join(random.SystemRandom().choice(CODE_CHARS) for _ in range(6))
c = f"{c[:3]}-{c[3:]}"
if not con.execute("SELECT 1 FROM signup_codes WHERE code=?", (c,)).fetchone():
return c
raise HTTPException(500, "코드를 만들지 못했습니다.")
class CodeIn(BaseModel):
code: str | None = Field(None, max_length=40)
class_id: int | None = None
note: str | None = Field(None, max_length=200)
active: bool = True
auto_approve: bool = False
max_uses: int | None = Field(None, ge=1, le=100000)
expires_at: str | None = None # YYYY-MM-DD
def _check_code_body(con, body: CodeIn):
if not body.class_id:
raise HTTPException(400, "가입 코드에는 반을 지정해 주세요. 이 코드로 가입한 학생이 그 반에 들어갑니다.")
if not con.execute("SELECT 1 FROM classes WHERE class_id=?", (body.class_id,)).fetchone():
raise HTTPException(400, "없는 반입니다.")
if body.expires_at and not re.fullmatch(r"\d{4}-\d{2}-\d{2}", body.expires_at):
raise HTTPException(400, "만료일은 YYYY-MM-DD 형식입니다.")
if body.code is not None:
body.code = body.code.strip().upper()
if not re.fullmatch(r"[A-Z0-9\-]{4,40}", body.code):
raise HTTPException(400, "코드는 영문 대문자·숫자·- 로 4자 이상입니다.")
@router.get("/api/admin/signup-codes")
def admin_codes(_: str = Depends(auth.require_owner)):
con = db.get()
return {"items": [dict(r) for r in con.execute(
"SELECT s.*, c.name AS class_name,"
" (SELECT COUNT(*) FROM users u WHERE u.signup_code_id=s.code_id) AS members"
" FROM signup_codes s LEFT JOIN classes c ON c.class_id=s.class_id ORDER BY s.active DESC, s.code_id DESC")]}
@router.post("/api/admin/signup-codes")
def admin_code_create(body: CodeIn, who: str = Depends(auth.require_owner)):
con = db.get()
_check_code_body(con, body)
code = body.code or _new_code(con)
if con.execute("SELECT 1 FROM signup_codes WHERE code=?", (code,)).fetchone():
raise HTTPException(409, "이미 있는 코드입니다.")
with con:
cur = con.execute(
"INSERT INTO signup_codes(code,class_id,note,active,auto_approve,max_uses,expires_at,created_by,created_at) VALUES(?,?,?,?,?,?,?,?,?)",
(code, body.class_id, body.note, int(body.active), int(body.auto_approve), body.max_uses, body.expires_at, who, db.now()))
db.audit(con, who, "create", "signup_code", str(cur.lastrowid), None, {"code": code, "class_id": body.class_id})
return {"code_id": cur.lastrowid, "code": code}
@router.put("/api/admin/signup-codes/{code_id}")
def admin_code_update(code_id: int, body: CodeIn, who: str = Depends(auth.require_owner)):
con = db.get()
old = con.execute("SELECT * FROM signup_codes WHERE code_id=?", (code_id,)).fetchone()
if not old:
raise HTTPException(404)
_check_code_body(con, body)
with con:
con.execute(
"UPDATE signup_codes SET class_id=?, note=?, active=?, auto_approve=?, max_uses=?, expires_at=? WHERE code_id=?",
(body.class_id, body.note, int(body.active), int(body.auto_approve), body.max_uses, body.expires_at, code_id))
db.audit(con, who, "update", "signup_code", str(code_id), None, body.model_dump(exclude={"code"}))
return {"ok": True}
@router.delete("/api/admin/signup-codes/{code_id}")
def admin_code_delete(code_id: int, who: str = Depends(auth.require_owner)):
con = db.get()
with con:
con.execute("DELETE FROM signup_codes WHERE code_id=?", (code_id,))
db.audit(con, who, "delete", "signup_code", str(code_id), None, None)
return {"ok": True}
+152
View File
@@ -0,0 +1,152 @@
"""data/atlas.json(파이프라인 결과)을 DB로 가져오기.
.venv/bin/python -m server.seed # 사람이 고친 항목(edited=1)은 보존하며 갱신
.venv/bin/python -m server.seed --reset # DB 내용을 지우고 새로 가져옴 (피드백·기록은 유지)
자동 생성 행만 덮어쓰므로 파이프라인을 다시 돌려도 검수 결과가 사라지지 않는다.
"""
import argparse
import json
import os
from pathlib import Path
from . import db
ATLAS_JSON = Path(os.environ.get("ATLAS_JSON", db.ROOT / "data" / "atlas.json"))
def seed(con, data: dict, reset: bool = False) -> dict:
ts = db.now()
stats = {"terms_new": 0, "terms_updated": 0, "terms_kept": 0, "terms_removed": 0,
"relations_new": 0, "relations_updated": 0, "relations_kept": 0, "relations_removed": 0}
with con:
if reset:
for t in ("books", "galaxies", "toc", "terms", "term_sources", "relations"):
con.execute(f"DELETE FROM {t}")
for b in data["books"]:
con.execute(
"INSERT INTO books(book_id,title,short,edition,year,is_primary,filename) VALUES(?,?,?,?,?,?,?) "
"ON CONFLICT(book_id) DO UPDATE SET title=excluded.title, short=excluded.short, edition=excluded.edition,"
" year=excluded.year, is_primary=excluded.is_primary, filename=excluded.filename",
(b["book_id"], b["title"], b["short"], b["edition"], b["year"], int(b["primary"]), b["filename"]),
)
for i, g in enumerate(data["galaxies"]):
con.execute(
"INSERT INTO galaxies(galaxy_id,name,sub,color,ord,chapter_no,updated_at) VALUES(?,?,?,?,?,?,?) "
"ON CONFLICT(galaxy_id) DO UPDATE SET name=excluded.name, sub=excluded.sub, color=excluded.color,"
" ord=excluded.ord, chapter_no=excluded.chapter_no WHERE galaxies.edited=0",
(g["id"], g["name"], g["sub"], g["color"], i, g["chapter_no"], ts),
)
for n in data["toc"]:
con.execute(
"INSERT INTO toc(toc_id,book_id,kind,level,number,title,page,parent_id,galaxy_id) VALUES(?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(toc_id) DO UPDATE SET book_id=excluded.book_id, kind=excluded.kind, level=excluded.level,"
" number=excluded.number, title=excluded.title, page=excluded.page, parent_id=excluded.parent_id,"
" galaxy_id=excluded.galaxy_id WHERE toc.edited=0",
(n["id"], n["book_id"], n["kind"], n["level"], n["number"], n["title"], n["page"], n["parent_id"], n["galaxy_id"]),
)
toc_ids = {n["id"] for n in data["toc"]}
for r in con.execute("SELECT toc_id FROM toc WHERE edited=0").fetchall():
if r["toc_id"] not in toc_ids:
con.execute("DELETE FROM toc WHERE toc_id=?", (r["toc_id"],))
existing = {r["term_id"]: r for r in con.execute("SELECT term_id, edited, origin FROM terms")}
seen = set()
for t in data["terms"]:
tid = t["term_id"]
seen.add(tid)
d = t.get("definition") or {}
vals = dict(
name=t["name"], kind=t["kind"], lang=t["lang"], aliases=db.dumps(t["aliases"]),
search_terms=db.dumps(t.get("search_terms", [])), galaxy_id=t["galaxy_id"], section_id=t["section_id"],
section_guess=int(t.get("section_guess", False)), item_id=t.get("item_id"),
section_ids=db.dumps(t.get("section_ids", [])),
definition_text=d.get("text"), definition_book=d.get("book_id"), definition_page=d.get("page"),
definition_method=d.get("method"), easy=t.get("easy"), example=t.get("example"),
easy_method=t.get("easy_method"),
)
if tid not in existing:
cols = ["term_id", *vals, "review_status", "origin", "created_at", "updated_at"]
con.execute(f"INSERT INTO terms({','.join(cols)}) VALUES({','.join('?' * len(cols))})",
(tid, *vals.values(), "draft", "auto", ts, ts))
stats["terms_new"] += 1
elif not existing[tid]["edited"]:
sets = ",".join(f"{k}=?" for k in vals)
con.execute(f"UPDATE terms SET {sets}, updated_at=? WHERE term_id=?", (*vals.values(), ts, tid))
stats["terms_updated"] += 1
else:
stats["terms_kept"] += 1
con.execute("DELETE FROM term_sources WHERE term_id=? AND origin='auto'", (tid,))
manual = {(r["book_id"], r["page"]) for r in con.execute(
"SELECT book_id, page FROM term_sources WHERE term_id=? AND origin='manual'", (tid,))}
for s in t["sources"]:
if (s["book_id"], s["page"]) in manual: # 사람이 고친 출처가 우선
continue
con.execute(
"INSERT INTO term_sources(term_id,book_id,page,pdf_page,toc_id,section_id,galaxy_id,path,snippet,origin)"
" VALUES(?,?,?,?,?,?,?,?,?,'auto')",
(tid, s["book_id"], s["page"], s.get("pdf_page"), s.get("toc_id"), s.get("section_id"),
s.get("galaxy_id"), s.get("path"), s.get("snippet")),
)
for tid, r in existing.items():
if tid not in seen and r["origin"] == "auto" and not r["edited"]:
con.execute("DELETE FROM terms WHERE term_id=?", (tid,))
con.execute("DELETE FROM term_sources WHERE term_id=?", (tid,))
stats["terms_removed"] += 1
existing = {r["relation_id"]: r for r in con.execute("SELECT relation_id, edited, method FROM relations")}
seen = set()
for r in data["relations"]:
rid = r["relation_id"]
seen.add(rid)
vals = dict(source=r["source"], target=r["target"], type=r["type"], label=r["label"],
directed=int(r["directed"]), method=r["method"], evidence=db.dumps(r["evidence"]))
if rid not in existing:
cols = ["relation_id", *vals, "review_status", "created_at", "updated_at"]
con.execute(f"INSERT INTO relations({','.join(cols)}) VALUES({','.join('?' * len(cols))})",
(rid, *vals.values(), "draft", ts, ts))
stats["relations_new"] += 1
elif not existing[rid]["edited"]:
sets = ",".join(f"{k}=?" for k in vals)
con.execute(f"UPDATE relations SET {sets}, updated_at=? WHERE relation_id=?", (*vals.values(), ts, rid))
stats["relations_updated"] += 1
else:
stats["relations_kept"] += 1
for rid, r in existing.items():
if rid not in seen and r["method"] != "manual" and not r["edited"]:
con.execute("DELETE FROM relations WHERE relation_id=?", (rid,))
stats["relations_removed"] += 1
con.execute("INSERT INTO meta(key,value) VALUES('seeded_at',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", (ts,))
con.execute("INSERT INTO meta(key,value) VALUES('generated_at',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(data.get("generated_at", ""),))
db.audit(con, "system", "seed", "atlas", None, None, stats)
return stats
def seed_if_empty(con) -> None:
"""DB가 비었거나 atlas.json이 새로 만들어졌으면 가져온다 (사람이 고친 항목은 보존)."""
if not ATLAS_JSON.exists():
return
n = con.execute("SELECT COUNT(*) FROM terms").fetchone()[0]
data = json.loads(ATLAS_JSON.read_text())
cur = con.execute("SELECT value FROM meta WHERE key='generated_at'").fetchone()
if n == 0:
print(f"DB가 비어 있어 {ATLAS_JSON}을 가져옵니다…")
elif not cur or cur[0] != data.get("generated_at"):
print(f"새 데이터({data.get('generated_at')})를 가져옵니다…")
else:
return
print(seed(con, data))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--reset", action="store_true")
ap.add_argument("--file", default=str(ATLAS_JSON))
a = ap.parse_args()
con = db.connect()
print(seed(con, json.loads(Path(a.file).read_text()), reset=a.reset))
if __name__ == "__main__":
main()