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:
@@ -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
File diff suppressed because it is too large
Load Diff
+178
@@ -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
@@ -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
@@ -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),
|
||||
)
|
||||
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user