- 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.
674 lines
31 KiB
Python
674 lines
31 KiB
Python
"""회원(학생)·반·문제.
|
||
|
||
- 누구나: 지도·검색·쉬운 뜻·관계
|
||
- 승인된 회원(또는 관리자): 사례, 문제 풀기
|
||
- 교수자만: 가입 코드·회원·반 관리, 직접 출제한 문제 관리
|
||
- 학생은 교수자가 수업에서 알려 준 가입 코드를 넣으면 바로 회원이 된다 (코드에 지정된 반으로 배정)
|
||
|
||
문제는 두 종류다.
|
||
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}
|