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
+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}