- 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.
1069 lines
48 KiB
Python
1069 lines
48 KiB
Python
"""개념 우주 웹서버.
|
||
|
||
.venv/bin/python -m server # http://127.0.0.1:8000
|
||
"""
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import time
|
||
import traceback
|
||
from contextlib import asynccontextmanager
|
||
from collections import defaultdict, deque
|
||
from pathlib import Path
|
||
|
||
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
|
||
from fastapi.middleware.gzip import GZipMiddleware
|
||
from fastapi.responses import FileResponse, JSONResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from pydantic import BaseModel, Field
|
||
|
||
from . import auth, db, members
|
||
from .seed import ATLAS_JSON, seed, seed_if_empty
|
||
|
||
WEB = db.ROOT / "web"
|
||
|
||
TERM_STATUSES = ("draft", "review", "confirmed")
|
||
REL_STATUSES = ("draft", "review", "confirmed", "rejected")
|
||
REL_TYPES = ("part_of", "precedes", "informs", "contrasts_with", "related_to")
|
||
FEEDBACK_STATUSES = ("new", "in_progress", "resolved", "rejected", "duplicate")
|
||
FEEDBACK_KINDS = ("content", "bug", "search", "suggestion", "client_error", "server_error")
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(_app):
|
||
con = db.get()
|
||
seed_if_empty(con)
|
||
made = auth.ensure_owner_account(con)
|
||
if made:
|
||
print(f"\n 교수자 계정을 만들었습니다 → 아이디: {made['username']} 비밀번호: {made['password']}"
|
||
f"\n (/login 에서 로그인, 관리 → 설정에서 아이디·비밀번호 변경)\n")
|
||
yield
|
||
|
||
|
||
app = FastAPI(title="지역사회간호학 개념 우주", docs_url="/api/docs", openapi_url="/api/openapi.json", lifespan=lifespan)
|
||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||
|
||
|
||
class GracefulCloseMiddleware:
|
||
"""연결을 닫는 요청(HTTP/1.0, Connection: close)에서 응답이 잘리는 문제 방지.
|
||
|
||
Apple container의 포트 전달은 서버가 먼저 연결을 닫으면 아직 전달하지 못한 데이터를 버린다.
|
||
시놀로지 리버스 프록시(nginx)는 백엔드에 HTTP/1.0으로 요청하므로 큰 응답의 끝부분이 잘렸다.
|
||
→ 본문을 다 보낸 뒤 바로 끝내지 않고, 상대가 다 받고 연결을 닫을 때까지(최대 60초) 기다린다.
|
||
"""
|
||
|
||
def __init__(self, app):
|
||
self.app = app
|
||
|
||
async def __call__(self, scope, receive, send):
|
||
if scope["type"] != "http":
|
||
return await self.app(scope, receive, send)
|
||
headers = dict(scope.get("headers") or [])
|
||
conn = headers.get(b"connection", b"").lower()
|
||
closing = scope.get("http_version") == "1.0" and b"keep-alive" not in conn or b"close" in conn
|
||
if not closing:
|
||
return await self.app(scope, receive, send)
|
||
held = {"final": False, "start": None, "chunks": None}
|
||
|
||
async def send_wrapper(message):
|
||
if message["type"] == "http.response.start":
|
||
hdrs = [(k.lower(), v) for k, v in message.get("headers", [])]
|
||
if any(k == b"content-length" for k, _ in hdrs) or message["status"] in (204, 304):
|
||
await send(message)
|
||
else: # 길이를 모르는 응답(스트리밍 gzip 등)은 모아서 길이를 붙인다
|
||
held["start"], held["chunks"] = message, []
|
||
return
|
||
if message["type"] == "http.response.body":
|
||
last = not message.get("more_body", False)
|
||
if held["chunks"] is not None:
|
||
held["chunks"].append(message.get("body", b""))
|
||
if not last:
|
||
return
|
||
body = b"".join(held["chunks"])
|
||
st = held["start"]
|
||
await send({**st, "headers": [*st.get("headers", []), (b"content-length", str(len(body)).encode())]})
|
||
message = {"type": "http.response.body", "body": body}
|
||
if last:
|
||
held["final"] = True
|
||
await send({**message, "more_body": True})
|
||
return
|
||
await send(message)
|
||
|
||
await self.app(scope, receive, send_wrapper)
|
||
if held["final"]:
|
||
import asyncio
|
||
try:
|
||
while True:
|
||
msg = await asyncio.wait_for(receive(), timeout=60)
|
||
if msg["type"] == "http.disconnect":
|
||
return
|
||
except asyncio.TimeoutError:
|
||
pass
|
||
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
||
|
||
|
||
app.add_middleware(GracefulCloseMiddleware)
|
||
app.include_router(members.router)
|
||
app.mount("/static", StaticFiles(directory=WEB / "static"), name="static")
|
||
|
||
|
||
# ------------------------------------------------------------------ 오류 기록
|
||
|
||
def _record_error(kind: str, message: str, stack: str = "", url: str = "", ua: str = "", context=None):
|
||
con = db.get()
|
||
first = next((l.strip() for l in (stack or "").splitlines() if l.strip()), "")
|
||
fp = hashlib.sha1(f"{kind}|{message[:300]}|{first[:300]}".encode()).hexdigest()[:16]
|
||
with con:
|
||
r = con.execute("SELECT id FROM feedback WHERE fingerprint=? AND status IN ('new','in_progress')", (fp,)).fetchone()
|
||
if r:
|
||
con.execute("UPDATE feedback SET occurrences=occurrences+1, updated_at=? WHERE id=?", (db.now(), r["id"]))
|
||
return r["id"]
|
||
cur = con.execute(
|
||
"INSERT INTO feedback(created_at,updated_at,kind,category,message,context,page_url,user_agent,fingerprint)"
|
||
" VALUES(?,?,?,?,?,?,?,?,?)",
|
||
(db.now(), db.now(), kind, "자동 수집 오류", message[:2000], json.dumps({"stack": stack[:8000], **(context or {})}, ensure_ascii=False),
|
||
url[:500], ua[:300], fp),
|
||
)
|
||
return cur.lastrowid
|
||
|
||
|
||
@app.exception_handler(Exception)
|
||
async def _unhandled(request: Request, exc: Exception):
|
||
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
||
try:
|
||
_record_error("server_error", f"{type(exc).__name__}: {exc}", tb, str(request.url), request.headers.get("user-agent", ""),
|
||
{"method": request.method})
|
||
except Exception:
|
||
pass
|
||
return JSONResponse({"detail": "서버 오류가 발생했습니다. 자동으로 기록되었습니다."}, status_code=500)
|
||
|
||
|
||
# 간단한 IP별 요청 제한 (피드백 남용 방지)
|
||
_hits: dict[str, deque] = defaultdict(deque)
|
||
|
||
|
||
def _rate_limit(request: Request, limit=60, window=600):
|
||
ip = request.client.host if request.client else "?"
|
||
q = _hits[ip]
|
||
t = time.time()
|
||
while q and q[0] < t - window:
|
||
q.popleft()
|
||
if len(q) >= limit:
|
||
raise HTTPException(429, "잠시 후 다시 시도해 주세요.")
|
||
q.append(t)
|
||
|
||
|
||
# ------------------------------------------------------------------ 페이지
|
||
|
||
@app.get("/", include_in_schema=False)
|
||
def page_index():
|
||
return FileResponse(WEB / "index.html")
|
||
|
||
|
||
@app.get("/feedback", include_in_schema=False)
|
||
def page_feedback():
|
||
return FileResponse(WEB / "feedback.html")
|
||
|
||
|
||
@app.get("/login", include_in_schema=False)
|
||
def page_login():
|
||
return FileResponse(WEB / "login.html")
|
||
|
||
|
||
@app.get("/signup", include_in_schema=False)
|
||
def page_signup():
|
||
return FileResponse(WEB / "signup.html")
|
||
|
||
|
||
@app.get("/account", include_in_schema=False)
|
||
def page_account():
|
||
return FileResponse(WEB / "account.html")
|
||
|
||
|
||
@app.get("/members", include_in_schema=False)
|
||
def page_members():
|
||
return FileResponse(WEB / "admin.html")
|
||
|
||
|
||
@app.get("/admin", include_in_schema=False)
|
||
def page_admin():
|
||
return FileResponse(WEB / "admin.html")
|
||
|
||
|
||
@app.get("/favicon.ico", include_in_schema=False)
|
||
def favicon():
|
||
return FileResponse(WEB / "static" / "favicon.svg", media_type="image/svg+xml")
|
||
|
||
|
||
# ------------------------------------------------------------------ 공개 데이터
|
||
|
||
def _public_filter(con):
|
||
st = db.get_setting(con, "public_statuses") or list(TERM_STATUSES)
|
||
return [s for s in st if s in TERM_STATUSES] or ["confirmed"]
|
||
|
||
|
||
@app.get("/api/atlas")
|
||
def api_atlas(request: Request, preview: bool = False):
|
||
"""지도·검색에 필요한 전체 데이터 (간략형). preview=1 은 관리자가 모든 상태를 볼 때."""
|
||
con = db.get()
|
||
is_admin = auth.current_admin(request) is not None
|
||
statuses = list(TERM_STATUSES) if preview and is_admin else _public_filter(con)
|
||
ph = ",".join("?" * len(statuses))
|
||
books = [dict(r) for r in con.execute("SELECT * FROM books ORDER BY is_primary DESC, book_id")]
|
||
terms = con.execute(
|
||
f"SELECT term_id,name,kind,lang,aliases,search_terms,galaxy_id,section_id,item_id,review_status,"
|
||
f" easy,definition_text,example IS NOT NULL AS has_example FROM terms WHERE hidden=0 AND review_status IN ({ph})",
|
||
statuses).fetchall()
|
||
visible = {t["term_id"] for t in terms}
|
||
counts = defaultdict(int)
|
||
for t in terms:
|
||
counts[t["galaxy_id"]] += 1
|
||
galaxies = [dict(r) | {"count": counts[r["galaxy_id"]]} for r in con.execute("SELECT * FROM galaxies ORDER BY ord")]
|
||
primary = [b["book_id"] for b in books if b["is_primary"]]
|
||
toc = con.execute(
|
||
f"SELECT toc_id,book_id,kind,level,number,title,page,parent_id,galaxy_id,easy_sub FROM toc"
|
||
f" WHERE book_id IN ({','.join('?' * len(primary))}) AND kind IN ('section','item') ORDER BY book_id, page, level",
|
||
primary).fetchall()
|
||
sections = []
|
||
by_sec = {}
|
||
for n in toc:
|
||
if n["kind"] == "section":
|
||
s = {"id": n["toc_id"], "galaxy_id": n["galaxy_id"], "number": n["number"], "title": n["title"],
|
||
"easy_sub": n["easy_sub"], "items": []}
|
||
sections.append(s)
|
||
by_sec[n["toc_id"]] = s
|
||
for n in toc:
|
||
if n["kind"] == "item" and n["parent_id"] in by_sec:
|
||
by_sec[n["parent_id"]]["items"].append({"id": n["toc_id"], "title": n["title"], "number": n["number"]})
|
||
rel_st = [s for s in statuses if s != "rejected"]
|
||
rels = con.execute(
|
||
f"SELECT relation_id,source,target,type,label,directed,review_status,method FROM relations"
|
||
f" WHERE hidden=0 AND review_status IN ({','.join('?' * len(rel_st))})", rel_st).fetchall()
|
||
out_terms = []
|
||
for t in terms:
|
||
# 공개 검색은 쉬운 설명만 (교과서 문장은 저작권 때문에 내보내지 않음)
|
||
text = t["easy"] or ""
|
||
out_terms.append({
|
||
"i": t["term_id"], "n": t["name"], "g": t["galaxy_id"], "s": t["section_id"], "it": t["item_id"],
|
||
"st": t["review_status"], "k": t["kind"], "l": t["lang"],
|
||
"a": json.loads(t["aliases"] or "[]"), "q": json.loads(t["search_terms"] or "[]"),
|
||
"x": text[:260], "e": 1 if t["easy"] else 0,
|
||
})
|
||
out_rels = [{"i": r["relation_id"], "s": r["source"], "t": r["target"], "ty": r["type"], "l": r["label"],
|
||
"d": r["directed"], "st": r["review_status"], "m": r["method"]}
|
||
for r in rels if r["source"] in visible and r["target"] in visible]
|
||
meta = {r["key"]: r["value"] for r in con.execute("SELECT key, value FROM meta")}
|
||
return {
|
||
"meta": meta | {"show_status_badges": db.get_setting(con, "show_status_badges"),
|
||
"statuses": statuses},
|
||
"books": books if is_admin else [], "galaxies": galaxies, "sections": sections, "terms": out_terms, "relations": out_rels,
|
||
}
|
||
|
||
|
||
def _term_detail(con, term_id: str, request: Request, admin=False):
|
||
t = db.row(con.execute("SELECT * FROM terms WHERE term_id=?", (term_id,)).fetchone())
|
||
if not t or (not admin and (t["hidden"] or t["review_status"] not in _public_filter(con))):
|
||
raise HTTPException(404, "용어를 찾을 수 없습니다.")
|
||
books = {r["book_id"]: dict(r) for r in con.execute("SELECT * FROM books")}
|
||
srcs = db.rows(con.execute("SELECT * FROM term_sources WHERE term_id=? ORDER BY id", (term_id,)))
|
||
for s in srcs:
|
||
b = books.get(s["book_id"], {})
|
||
s["book_short"] = b.get("short")
|
||
s["book_title"] = b.get("title")
|
||
s["is_primary"] = b.get("is_primary")
|
||
srcs.sort(key=lambda s: (-(s["is_primary"] or 0), s["book_id"] or "", s["page"] or 0))
|
||
statuses = list(REL_STATUSES) if admin else [s for s in _public_filter(con)]
|
||
ph = ",".join("?" * len(statuses))
|
||
rels = db.rows(con.execute(
|
||
f"SELECT r.*, CASE WHEN r.source=? THEN r.target ELSE r.source END AS other_id FROM relations r"
|
||
f" WHERE (r.source=? OR r.target=?) AND r.review_status IN ({ph})" + ("" if admin else " AND r.hidden=0"),
|
||
(term_id, term_id, term_id, *statuses)))
|
||
others = {}
|
||
if rels:
|
||
ids = list({r["other_id"] for r in rels})
|
||
for o in con.execute(f"SELECT term_id,name,galaxy_id,section_id,hidden,review_status FROM terms WHERE term_id IN ({','.join('?'*len(ids))})", ids):
|
||
others[o["term_id"]] = dict(o)
|
||
pub = set(_public_filter(con))
|
||
out_rels = []
|
||
for r in rels:
|
||
o = others.get(r["other_id"])
|
||
if not o or (not admin and (o["hidden"] or o["review_status"] not in pub)):
|
||
continue
|
||
r["other"] = o
|
||
r["outgoing"] = r["source"] == term_id
|
||
out_rels.append(r)
|
||
t["sources"] = srcs
|
||
t["relations"] = out_rels
|
||
sec = con.execute("SELECT toc_id,title,number,easy_sub FROM toc WHERE toc_id=?", (t["section_id"],)).fetchone()
|
||
item = con.execute("SELECT toc_id,title,number FROM toc WHERE toc_id=?", (t["item_id"],)).fetchone()
|
||
t["section"] = dict(sec) if sec else None
|
||
t["item"] = dict(item) if item else None
|
||
if t.get("definition_book"):
|
||
b = books.get(t["definition_book"], {})
|
||
t["definition_book_short"] = b.get("short")
|
||
if not admin and not members.can_view_members_content(con, request):
|
||
# 사례는 승인된 회원 전용
|
||
t["example_locked"] = bool(t.get("example"))
|
||
t["example"] = None
|
||
if not admin:
|
||
# 저작권: 학생에게는 교과서 문장·교재명·쪽수를 보내지 않는다
|
||
t["sources"] = []
|
||
for k in ("definition_text", "definition_book", "definition_page", "definition_method", "definition_book_short", "note", "reviewer"):
|
||
t.pop(k, None)
|
||
for r in t["relations"]:
|
||
r.pop("evidence", None)
|
||
t["open_feedback"] = con.execute(
|
||
"SELECT COUNT(*) FROM feedback WHERE target_type='term' AND target_id=? AND status IN ('new','in_progress')",
|
||
(term_id,)).fetchone()[0]
|
||
return t
|
||
|
||
|
||
@app.get("/api/terms/{term_id}")
|
||
def api_term(term_id: str, request: Request):
|
||
return _term_detail(db.get(), term_id, request)
|
||
|
||
|
||
# ------------------------------------------------------------------ 피드백 (공개)
|
||
|
||
class FeedbackIn(BaseModel):
|
||
kind: str = "content"
|
||
category: str = Field("", max_length=60)
|
||
target_type: str | None = None
|
||
target_id: str | None = Field(None, max_length=80)
|
||
target_label: str | None = Field(None, max_length=200)
|
||
message: str = Field(..., min_length=2, max_length=4000)
|
||
suggestion: str | None = Field(None, max_length=4000)
|
||
page_url: str | None = Field(None, max_length=500)
|
||
reporter: str | None = Field(None, max_length=60)
|
||
contact: str | None = Field(None, max_length=120)
|
||
website: str | None = None # 스팸 방지용 숨은 칸
|
||
|
||
|
||
@app.post("/api/feedback")
|
||
def api_feedback_create(body: FeedbackIn, request: Request):
|
||
_rate_limit(request)
|
||
if body.website:
|
||
return {"ok": True, "id": 0}
|
||
if body.kind not in FEEDBACK_KINDS or body.kind in ("client_error", "server_error"):
|
||
raise HTTPException(400, "잘못된 유형입니다.")
|
||
con = db.get()
|
||
label = body.target_label
|
||
if body.target_type == "term" and body.target_id:
|
||
r = con.execute("SELECT name FROM terms WHERE term_id=?", (body.target_id,)).fetchone()
|
||
if not r:
|
||
raise HTTPException(400, "존재하지 않는 용어입니다.")
|
||
label = r["name"]
|
||
if body.target_type == "relation" and body.target_id:
|
||
if not con.execute("SELECT 1 FROM relations WHERE relation_id=?", (body.target_id,)).fetchone():
|
||
raise HTTPException(400, "존재하지 않는 관계입니다.")
|
||
with con:
|
||
cur = con.execute(
|
||
"INSERT INTO feedback(created_at,updated_at,kind,category,target_type,target_id,target_label,message,suggestion,"
|
||
"page_url,user_agent,reporter,contact) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(db.now(), db.now(), body.kind, body.category, body.target_type, body.target_id, label, body.message.strip(),
|
||
(body.suggestion or "").strip() or None, body.page_url, request.headers.get("user-agent", "")[:300],
|
||
(body.reporter or "").strip() or None, (body.contact or "").strip() or None),
|
||
)
|
||
return {"ok": True, "id": cur.lastrowid}
|
||
|
||
|
||
@app.get("/api/feedback")
|
||
def api_feedback_list(status: str | None = None, target_id: str | None = None, limit: int = Query(50, le=200), offset: int = 0):
|
||
"""공개 목록: 연락처·내부 메모는 숨긴다. 자동 수집 오류는 보이지 않음."""
|
||
con = db.get()
|
||
where = ["kind NOT IN ('client_error','server_error')"]
|
||
args = []
|
||
if status:
|
||
where.append("status=?")
|
||
args.append(status)
|
||
if target_id:
|
||
where.append("target_id=?")
|
||
args.append(target_id)
|
||
w = " AND ".join(where)
|
||
total = con.execute(f"SELECT COUNT(*) FROM feedback WHERE {w}", args).fetchone()[0]
|
||
items = [dict(r) for r in con.execute(
|
||
f"SELECT id,created_at,updated_at,kind,category,target_type,target_id,target_label,message,suggestion,status,resolution"
|
||
f" FROM feedback WHERE {w} ORDER BY id DESC LIMIT ? OFFSET ?", (*args, limit, offset))]
|
||
return {"total": total, "items": items}
|
||
|
||
|
||
class ClientError(BaseModel):
|
||
message: str = Field(..., max_length=2000)
|
||
stack: str | None = Field(None, max_length=8000)
|
||
url: str | None = Field(None, max_length=500)
|
||
source: str | None = Field(None, max_length=300)
|
||
|
||
|
||
@app.post("/api/client-errors")
|
||
def api_client_error(body: ClientError, request: Request):
|
||
_rate_limit(request, limit=30, window=600)
|
||
fid = _record_error("client_error", body.message, body.stack or body.source or "", body.url or "",
|
||
request.headers.get("user-agent", ""))
|
||
return {"ok": True, "id": fid}
|
||
|
||
|
||
# ------------------------------------------------------------------ 관리자
|
||
|
||
class LoginIn(BaseModel):
|
||
username: str = Field(..., min_length=1, max_length=40)
|
||
password: str
|
||
|
||
|
||
def _admin_session(response, request, a):
|
||
con = db.get()
|
||
with con:
|
||
con.execute("UPDATE admins SET last_login=? WHERE admin_id=?", (db.now(), a["admin_id"]))
|
||
response.set_cookie(auth.COOKIE, auth.make_token(a["name"] or a["username"], "owner"), max_age=auth.TTL, httponly=True,
|
||
samesite="lax", secure=request.url.scheme == "https")
|
||
|
||
|
||
@app.post("/api/admin/login")
|
||
def admin_login(body: LoginIn, request: Request, response: Response):
|
||
_rate_limit(request, limit=10, window=300)
|
||
a = auth.check_login(db.get(), body.username, body.password)
|
||
if not a:
|
||
raise HTTPException(401, "아이디 또는 비밀번호가 맞지 않습니다.")
|
||
_admin_session(response, request, a)
|
||
return {"ok": True, "name": a["name"] or a["username"], "role": "owner"}
|
||
|
||
|
||
@app.post("/api/login")
|
||
def unified_login(body: LoginIn, request: Request, response: Response):
|
||
"""하나의 로그인 페이지: 교수자 아이디면 관리자 세션, 아니면 학생 회원 세션."""
|
||
_rate_limit(request, limit=15, window=300)
|
||
con = db.get()
|
||
a = auth.check_login(con, body.username, body.password)
|
||
if a:
|
||
_admin_session(response, request, a)
|
||
return {"ok": True, "kind": "admin", "name": a["name"] or a["username"]}
|
||
from .members import LoginIn as MemberLogin, login as member_login
|
||
r = member_login(MemberLogin(username=body.username, password=body.password), request, response)
|
||
return {"ok": True, "kind": "member", "name": r["name"], "status": r["status"]}
|
||
|
||
|
||
@app.get("/api/admin/account")
|
||
def admin_account(request: Request, _: str = Depends(auth.require_owner)):
|
||
con = db.get()
|
||
rows = [dict(r) for r in con.execute("SELECT admin_id, username, name, created_at, last_login FROM admins ORDER BY admin_id")]
|
||
return {"items": rows}
|
||
|
||
|
||
class AccountIn(BaseModel):
|
||
current_password: str
|
||
username: str | None = Field(None, min_length=3, max_length=30)
|
||
name: str | None = Field(None, max_length=40)
|
||
new_password: str | None = Field(None, min_length=8, max_length=100)
|
||
|
||
|
||
@app.put("/api/admin/account")
|
||
def admin_account_update(body: AccountIn, request: Request, response: Response, who: str = Depends(auth.require_owner)):
|
||
"""교수자 아이디·이름·비밀번호 변경 (현재 비밀번호 확인)."""
|
||
con = db.get()
|
||
a = con.execute("SELECT * FROM admins ORDER BY admin_id LIMIT 1").fetchone()
|
||
if not a or not auth.verify_password(body.current_password, a["password_hash"]):
|
||
raise HTTPException(400, "현재 비밀번호가 맞지 않습니다.")
|
||
sets, args = [], []
|
||
if body.username and body.username != a["username"]:
|
||
import re
|
||
if not re.fullmatch(r"[A-Za-z0-9_.\-]{3,30}", body.username):
|
||
raise HTTPException(400, "아이디는 영문·숫자·_ . - 로 3~30자입니다.")
|
||
if con.execute("SELECT 1 FROM users WHERE username=?", (body.username,)).fetchone() or \
|
||
con.execute("SELECT 1 FROM admins WHERE username=? AND admin_id<>?", (body.username, a["admin_id"])).fetchone():
|
||
raise HTTPException(409, "이미 쓰는 아이디입니다.")
|
||
sets.append("username=?")
|
||
args.append(body.username)
|
||
if body.name is not None:
|
||
sets.append("name=?")
|
||
args.append(body.name.strip() or None)
|
||
if body.new_password:
|
||
sets.append("password_hash=?")
|
||
args.append(auth.hash_password(body.new_password))
|
||
if sets:
|
||
with con:
|
||
con.execute(f"UPDATE admins SET {','.join(sets)} WHERE admin_id=?", (*args, a["admin_id"]))
|
||
db.audit(con, who, "update", "account", str(a["admin_id"]), None,
|
||
{k: v for k, v in body.model_dump().items() if k in ("username", "name") and v})
|
||
a = con.execute("SELECT * FROM admins WHERE admin_id=?", (a["admin_id"],)).fetchone()
|
||
_admin_session(response, request, dict(a))
|
||
return {"ok": True, "username": a["username"], "name": a["name"]}
|
||
|
||
|
||
@app.post("/api/admin/logout")
|
||
def admin_logout(response: Response):
|
||
response.delete_cookie(auth.COOKIE)
|
||
return {"ok": True}
|
||
|
||
|
||
@app.get("/api/admin/me")
|
||
def admin_me(request: Request):
|
||
return {"name": auth.current_admin(request), "role": auth.current_role(request)}
|
||
|
||
|
||
@app.get("/api/admin/stats")
|
||
def admin_stats(_: str = Depends(auth.require_admin)):
|
||
con = db.get()
|
||
q = lambda sql: {r[0]: r[1] for r in con.execute(sql)}
|
||
return {
|
||
"terms": q("SELECT review_status, COUNT(*) FROM terms WHERE hidden=0 GROUP BY review_status"),
|
||
"terms_hidden": con.execute("SELECT COUNT(*) FROM terms WHERE hidden=1").fetchone()[0],
|
||
"terms_easy": con.execute("SELECT COUNT(*) FROM terms WHERE hidden=0 AND easy IS NOT NULL AND easy<>''").fetchone()[0],
|
||
"terms_definition": con.execute("SELECT COUNT(*) FROM terms WHERE hidden=0 AND definition_text IS NOT NULL").fetchone()[0],
|
||
"terms_section_guess": con.execute("SELECT COUNT(*) FROM terms WHERE hidden=0 AND section_guess=1").fetchone()[0],
|
||
"relations": q("SELECT review_status, COUNT(*) FROM relations WHERE hidden=0 GROUP BY review_status"),
|
||
"relations_by_method": q("SELECT method, COUNT(*) FROM relations WHERE hidden=0 GROUP BY method"),
|
||
"feedback": q("SELECT status, COUNT(*) FROM feedback GROUP BY status"),
|
||
"feedback_open_by_kind": q("SELECT kind, COUNT(*) FROM feedback WHERE status IN ('new','in_progress') GROUP BY kind"),
|
||
"by_galaxy": [dict(r) for r in con.execute(
|
||
"SELECT g.galaxy_id, g.name, COUNT(t.term_id) AS terms,"
|
||
" SUM(CASE WHEN t.review_status='confirmed' THEN 1 ELSE 0 END) AS confirmed"
|
||
" FROM galaxies g LEFT JOIN terms t ON t.galaxy_id=g.galaxy_id AND t.hidden=0 GROUP BY g.galaxy_id ORDER BY g.ord")],
|
||
"meta": {r["key"]: r["value"] for r in con.execute("SELECT key,value FROM meta")},
|
||
}
|
||
|
||
|
||
# ---- 피드백 처리
|
||
|
||
@app.get("/api/admin/feedback")
|
||
def admin_feedback_list(status: str | None = None, kind: str | None = None, q: str | None = None,
|
||
limit: int = Query(100, le=500), offset: int = 0, _: str = Depends(auth.require_admin)):
|
||
con = db.get()
|
||
where, args = ["1=1"], []
|
||
if status == "open":
|
||
where.append("status IN ('new','in_progress')")
|
||
elif status:
|
||
where.append("status=?")
|
||
args.append(status)
|
||
if kind:
|
||
where.append("kind=?")
|
||
args.append(kind)
|
||
if q:
|
||
where.append("(message LIKE ? OR target_label LIKE ? OR suggestion LIKE ? OR admin_note LIKE ?)")
|
||
args += [f"%{q}%"] * 4
|
||
w = " AND ".join(where)
|
||
total = con.execute(f"SELECT COUNT(*) FROM feedback WHERE {w}", args).fetchone()[0]
|
||
items = [dict(r) for r in con.execute(f"SELECT * FROM feedback WHERE {w} ORDER BY id DESC LIMIT ? OFFSET ?", (*args, limit, offset))]
|
||
return {"total": total, "items": items}
|
||
|
||
|
||
@app.get("/api/admin/feedback/{fid}")
|
||
def admin_feedback_get(fid: int, _: str = Depends(auth.require_admin)):
|
||
r = db.get().execute("SELECT * FROM feedback WHERE id=?", (fid,)).fetchone()
|
||
if not r:
|
||
raise HTTPException(404)
|
||
return dict(r)
|
||
|
||
|
||
class FeedbackPatch(BaseModel):
|
||
status: str | None = None
|
||
admin_note: str | None = None
|
||
resolution: str | None = None
|
||
|
||
|
||
@app.patch("/api/admin/feedback/{fid}")
|
||
def admin_feedback_patch(fid: int, body: FeedbackPatch, who: str = Depends(auth.require_admin)):
|
||
con = db.get()
|
||
before = con.execute("SELECT * FROM feedback WHERE id=?", (fid,)).fetchone()
|
||
if not before:
|
||
raise HTTPException(404)
|
||
if body.status and body.status not in FEEDBACK_STATUSES:
|
||
raise HTTPException(400, "잘못된 상태")
|
||
changes = {k: v for k, v in body.model_dump().items() if v is not None}
|
||
if not changes:
|
||
return dict(before)
|
||
with con:
|
||
sets = ",".join(f"{k}=?" for k in changes)
|
||
con.execute(f"UPDATE feedback SET {sets}, handled_by=?, updated_at=? WHERE id=?", (*changes.values(), who, db.now(), fid))
|
||
db.audit(con, who, "update", "feedback", str(fid), {k: before[k] for k in changes}, changes)
|
||
return dict(con.execute("SELECT * FROM feedback WHERE id=?", (fid,)).fetchone())
|
||
|
||
|
||
@app.delete("/api/admin/feedback/{fid}")
|
||
def admin_feedback_delete(fid: int, who: str = Depends(auth.require_admin)):
|
||
con = db.get()
|
||
with con:
|
||
before = con.execute("SELECT * FROM feedback WHERE id=?", (fid,)).fetchone()
|
||
con.execute("DELETE FROM feedback WHERE id=?", (fid,))
|
||
db.audit(con, who, "delete", "feedback", str(fid), dict(before) if before else None, None)
|
||
return {"ok": True}
|
||
|
||
|
||
# ---- 용어
|
||
|
||
@app.get("/api/admin/terms")
|
||
def admin_terms(q: str | None = None, galaxy: str | None = None, section: str | None = None, status: str | None = None,
|
||
missing: str | None = None, hidden: int | None = None, sort: str = "name",
|
||
limit: int = Query(100, le=1000), offset: int = 0, _: str = Depends(auth.require_admin)):
|
||
con = db.get()
|
||
where, args = ["1=1"], []
|
||
if q:
|
||
where.append("(name LIKE ? OR aliases LIKE ? OR definition_text LIKE ? OR easy LIKE ? OR term_id=?)")
|
||
args += [f"%{q}%"] * 4 + [q]
|
||
if galaxy:
|
||
where.append("galaxy_id=?")
|
||
args.append(galaxy)
|
||
if section:
|
||
where.append("section_id=?")
|
||
args.append(section)
|
||
if status:
|
||
where.append("review_status=?")
|
||
args.append(status)
|
||
if hidden is not None:
|
||
where.append("hidden=?")
|
||
args.append(hidden)
|
||
if missing == "easy":
|
||
where.append("(easy IS NULL OR easy='')")
|
||
elif missing == "definition":
|
||
where.append("definition_text IS NULL")
|
||
elif missing == "section":
|
||
where.append("section_guess=1")
|
||
order = {"name": "name", "updated": "updated_at DESC", "status": "review_status, name"}.get(sort, "name")
|
||
w = " AND ".join(where)
|
||
total = con.execute(f"SELECT COUNT(*) FROM terms WHERE {w}", args).fetchone()[0]
|
||
items = db.rows(con.execute(
|
||
f"SELECT term_id,name,kind,lang,aliases,galaxy_id,section_id,section_guess,review_status,hidden,edited,origin,"
|
||
f" easy IS NOT NULL AND easy<>'' AS has_easy, definition_text IS NOT NULL AS has_def, updated_at, reviewer"
|
||
f" FROM terms WHERE {w} ORDER BY {order} LIMIT ? OFFSET ?", (*args, limit, offset)))
|
||
return {"total": total, "items": items}
|
||
|
||
|
||
@app.get("/api/admin/terms/{term_id}")
|
||
def admin_term(term_id: str, request: Request, _: str = Depends(auth.require_admin)):
|
||
con = db.get()
|
||
t = _term_detail(con, term_id, request, admin=True)
|
||
t["feedback"] = [dict(r) for r in con.execute(
|
||
"SELECT id,created_at,category,message,suggestion,status FROM feedback WHERE target_type='term' AND target_id=? ORDER BY id DESC",
|
||
(term_id,))]
|
||
t["history"] = [dict(r) for r in con.execute(
|
||
"SELECT at,actor,action,before,after FROM audit WHERE entity='term' AND entity_id=? ORDER BY id DESC LIMIT 30", (term_id,))]
|
||
return t
|
||
|
||
|
||
class SourceIn(BaseModel):
|
||
id: int | None = None
|
||
book_id: str
|
||
page: int | None = None
|
||
pdf_page: int | None = None
|
||
path: str | None = None
|
||
snippet: str | None = None
|
||
section_id: str | None = None
|
||
|
||
|
||
class TermIn(BaseModel):
|
||
name: str | None = Field(None, min_length=1, max_length=120)
|
||
kind: str | None = None
|
||
aliases: list[str] | None = None
|
||
search_terms: list[str] | None = None
|
||
galaxy_id: str | None = None
|
||
section_id: str | None = None
|
||
item_id: str | None = None
|
||
definition_text: str | None = None
|
||
definition_book: str | None = None
|
||
definition_page: int | None = None
|
||
easy: str | None = None
|
||
example: str | None = None
|
||
review_status: str | None = None
|
||
hidden: bool | None = None
|
||
note: str | None = None
|
||
sources: list[SourceIn] | None = None
|
||
|
||
|
||
TERM_EDITABLE = ("name", "kind", "aliases", "search_terms", "galaxy_id", "section_id", "item_id", "definition_text",
|
||
"definition_book", "definition_page", "easy", "example", "review_status", "hidden", "note")
|
||
|
||
|
||
def _apply_term(con, term_id, body: TermIn, who, creating=False):
|
||
data = body.model_dump(exclude_unset=True)
|
||
if "review_status" in data and data["review_status"] not in TERM_STATUSES:
|
||
raise HTTPException(400, "잘못된 검수 상태")
|
||
before = db.row(con.execute("SELECT * FROM terms WHERE term_id=?", (term_id,)).fetchone())
|
||
vals = {}
|
||
for k in TERM_EDITABLE:
|
||
if k in data:
|
||
v = data[k]
|
||
if k in ("aliases", "search_terms"):
|
||
v = db.dumps([x.strip() for x in v or [] if x and x.strip()])
|
||
elif k == "hidden":
|
||
v = int(bool(v))
|
||
elif isinstance(v, str):
|
||
v = v.strip() or None
|
||
vals[k] = v
|
||
if "section_id" in vals:
|
||
vals["section_guess"] = 0
|
||
if vals["section_id"] and "galaxy_id" not in vals:
|
||
g = con.execute("SELECT galaxy_id FROM toc WHERE toc_id=?", (vals["section_id"],)).fetchone()
|
||
if g:
|
||
vals["galaxy_id"] = g["galaxy_id"]
|
||
if "definition_text" in vals:
|
||
vals["definition_method"] = "manual"
|
||
if "easy" in vals:
|
||
vals["easy_method"] = "manual"
|
||
vals["edited"] = 1
|
||
vals["reviewer"] = who
|
||
vals["updated_at"] = db.now()
|
||
if creating:
|
||
vals.setdefault("review_status", "draft")
|
||
cols = ["term_id", *vals, "origin", "created_at"]
|
||
con.execute(f"INSERT INTO terms({','.join(cols)}) VALUES({','.join('?'*len(cols))})",
|
||
(term_id, *vals.values(), "manual", db.now()))
|
||
else:
|
||
sets = ",".join(f"{k}=?" for k in vals)
|
||
con.execute(f"UPDATE terms SET {sets} WHERE term_id=?", (*vals.values(), term_id))
|
||
if body.sources is not None:
|
||
keep = {s.id for s in body.sources if s.id}
|
||
for r in con.execute("SELECT id FROM term_sources WHERE term_id=?", (term_id,)).fetchall():
|
||
if r["id"] not in keep:
|
||
con.execute("DELETE FROM term_sources WHERE id=?", (r["id"],))
|
||
for s in body.sources:
|
||
if s.id:
|
||
cur = con.execute("SELECT book_id,page,pdf_page,path,snippet,section_id FROM term_sources WHERE id=? AND term_id=?",
|
||
(s.id, term_id)).fetchone()
|
||
new = (s.book_id, s.page, s.pdf_page, s.path, s.snippet, s.section_id)
|
||
if cur and tuple(cur) != new:
|
||
con.execute("UPDATE term_sources SET book_id=?, page=?, pdf_page=?, path=?, snippet=?, section_id=?, origin='manual' WHERE id=? AND term_id=?",
|
||
(*new, s.id, term_id))
|
||
else:
|
||
con.execute("INSERT INTO term_sources(term_id,book_id,page,pdf_page,path,snippet,section_id,origin) VALUES(?,?,?,?,?,?,?,'manual')",
|
||
(term_id, s.book_id, s.page, s.pdf_page, s.path, s.snippet, s.section_id))
|
||
db.audit(con, who, "create" if creating else "update", "term", term_id,
|
||
{k: before.get(k) for k in data if k != "sources"} if before else None,
|
||
{k: v for k, v in data.items() if k != "sources"} | ({"sources": len(body.sources)} if body.sources is not None else {}))
|
||
|
||
|
||
@app.put("/api/admin/terms/{term_id}")
|
||
def admin_term_update(term_id: str, body: TermIn, request: Request, who: str = Depends(auth.require_admin)):
|
||
con = db.get()
|
||
if not con.execute("SELECT 1 FROM terms WHERE term_id=?", (term_id,)).fetchone():
|
||
raise HTTPException(404)
|
||
with con:
|
||
_apply_term(con, term_id, body, who)
|
||
return admin_term(term_id, request, who)
|
||
|
||
|
||
@app.post("/api/admin/terms")
|
||
def admin_term_create(body: TermIn, request: Request, who: str = Depends(auth.require_admin)):
|
||
if not body.name:
|
||
raise HTTPException(400, "이름이 필요합니다.")
|
||
con = db.get()
|
||
import re
|
||
key = re.sub(r"[\s·\-–_‧∙/'’\.]", "", body.name.lower())
|
||
term_id = "t_" + hashlib.sha1(key.encode()).hexdigest()[:10]
|
||
if con.execute("SELECT 1 FROM terms WHERE term_id=?", (term_id,)).fetchone():
|
||
raise HTTPException(409, f"같은 이름의 용어가 이미 있습니다: {term_id}")
|
||
with con:
|
||
_apply_term(con, term_id, body, who, creating=True)
|
||
return admin_term(term_id, request, who)
|
||
|
||
|
||
class MergeIn(BaseModel):
|
||
into: str
|
||
|
||
|
||
@app.post("/api/admin/terms/{term_id}/merge")
|
||
def admin_term_merge(term_id: str, body: MergeIn, request: Request, who: str = Depends(auth.require_admin)):
|
||
"""중복 용어를 다른 용어로 합침: 출처·관계·별칭을 옮기고 원래 용어는 숨김."""
|
||
con = db.get()
|
||
a = db.row(con.execute("SELECT * FROM terms WHERE term_id=?", (term_id,)).fetchone())
|
||
b = db.row(con.execute("SELECT * FROM terms WHERE term_id=?", (body.into,)).fetchone())
|
||
if not a or not b or term_id == body.into:
|
||
raise HTTPException(400, "합칠 용어를 확인하세요.")
|
||
with con:
|
||
aliases = list(dict.fromkeys(b["aliases"] + [a["name"]] + a["aliases"]))
|
||
con.execute("UPDATE terms SET aliases=?, edited=1, updated_at=? WHERE term_id=?", (db.dumps(aliases), db.now(), b["term_id"]))
|
||
con.execute("UPDATE term_sources SET term_id=?, origin='manual' WHERE term_id=?", (b["term_id"], term_id))
|
||
for r in con.execute("SELECT * FROM relations WHERE source=? OR target=?", (term_id, term_id)).fetchall():
|
||
s = b["term_id"] if r["source"] == term_id else r["source"]
|
||
t = b["term_id"] if r["target"] == term_id else r["target"]
|
||
if s == t:
|
||
con.execute("DELETE FROM relations WHERE relation_id=?", (r["relation_id"],))
|
||
else:
|
||
con.execute("UPDATE relations SET source=?, target=?, edited=1 WHERE relation_id=?", (s, t, r["relation_id"]))
|
||
con.execute("DELETE FROM relations WHERE rowid NOT IN (SELECT MIN(rowid) FROM relations GROUP BY source, target, type)"
|
||
" AND (source=? OR target=?)", (b["term_id"], b["term_id"]))
|
||
con.execute("UPDATE terms SET hidden=1, edited=1, note=?, updated_at=? WHERE term_id=?",
|
||
(f"‘{b['name']}’({b['term_id']})로 합쳐짐", db.now(), term_id))
|
||
con.execute("UPDATE feedback SET target_id=? WHERE target_type='term' AND target_id=?", (b["term_id"], term_id))
|
||
db.audit(con, who, "merge", "term", term_id, {"name": a["name"]}, {"into": b["term_id"]})
|
||
return admin_term(b["term_id"], request, who)
|
||
|
||
|
||
# ---- 관계
|
||
|
||
@app.get("/api/admin/relations")
|
||
def admin_relations(q: str | None = None, status: str | None = None, type: str | None = None, method: str | None = None,
|
||
term: str | None = None, galaxy: str | None = None,
|
||
limit: int = Query(100, le=1000), offset: int = 0, _: str = Depends(auth.require_admin)):
|
||
con = db.get()
|
||
where, args = ["1=1"], []
|
||
if status:
|
||
where.append("r.review_status=?")
|
||
args.append(status)
|
||
if type:
|
||
where.append("r.type=?")
|
||
args.append(type)
|
||
if method:
|
||
where.append("r.method=?")
|
||
args.append(method)
|
||
if term:
|
||
where.append("(r.source=? OR r.target=?)")
|
||
args += [term, term]
|
||
if galaxy:
|
||
where.append("(a.galaxy_id=? OR b.galaxy_id=?)")
|
||
args += [galaxy, galaxy]
|
||
if q:
|
||
where.append("(a.name LIKE ? OR b.name LIKE ? OR r.label LIKE ?)")
|
||
args += [f"%{q}%"] * 3
|
||
w = " AND ".join(where)
|
||
base = f"FROM relations r JOIN terms a ON a.term_id=r.source JOIN terms b ON b.term_id=r.target WHERE {w}"
|
||
total = con.execute(f"SELECT COUNT(*) {base}", args).fetchone()[0]
|
||
items = db.rows(con.execute(
|
||
f"SELECT r.*, a.name AS source_name, a.galaxy_id AS source_galaxy, b.name AS target_name, b.galaxy_id AS target_galaxy"
|
||
f" {base} ORDER BY r.review_status='draft' DESC, a.name LIMIT ? OFFSET ?", (*args, limit, offset)))
|
||
return {"total": total, "items": items}
|
||
|
||
|
||
class RelationIn(BaseModel):
|
||
source: str | None = None
|
||
target: str | None = None
|
||
type: str | None = None
|
||
label: str | None = Field(None, max_length=200)
|
||
reason: str | None = Field(None, max_length=2000)
|
||
directed: bool | None = None
|
||
review_status: str | None = None
|
||
hidden: bool | None = None
|
||
evidence: list[dict] | None = None
|
||
|
||
|
||
def _check_rel(con, data):
|
||
if "type" in data and data["type"] not in REL_TYPES:
|
||
raise HTTPException(400, "잘못된 관계 유형")
|
||
if "review_status" in data and data["review_status"] not in REL_STATUSES:
|
||
raise HTTPException(400, "잘못된 검수 상태")
|
||
for k in ("source", "target"):
|
||
if k in data and not con.execute("SELECT 1 FROM terms WHERE term_id=?", (data[k],)).fetchone():
|
||
raise HTTPException(400, f"{k} 용어가 없습니다.")
|
||
|
||
|
||
@app.post("/api/admin/relations")
|
||
def admin_relation_create(body: RelationIn, who: str = Depends(auth.require_admin)):
|
||
con = db.get()
|
||
data = body.model_dump(exclude_unset=True)
|
||
if not (body.source and body.target and body.type):
|
||
raise HTTPException(400, "시작·도착 용어와 유형이 필요합니다.")
|
||
if body.source == body.target:
|
||
raise HTTPException(400, "같은 용어끼리는 연결할 수 없습니다.")
|
||
_check_rel(con, data)
|
||
rid = "r_" + hashlib.sha1(f"{body.source}|{body.target}|{body.type}".encode()).hexdigest()[:10]
|
||
if con.execute("SELECT 1 FROM relations WHERE relation_id=?", (rid,)).fetchone():
|
||
raise HTTPException(409, "같은 관계가 이미 있습니다.")
|
||
directed = body.directed if body.directed is not None else body.type != "contrasts_with"
|
||
with con:
|
||
con.execute(
|
||
"INSERT INTO relations(relation_id,source,target,type,label,reason,directed,method,evidence,review_status,hidden,edited,reviewer,created_at,updated_at)"
|
||
" VALUES(?,?,?,?,?,?,?,?,?,?,0,1,?,?,?)",
|
||
(rid, body.source, body.target, body.type, body.label, body.reason, int(directed), "manual",
|
||
db.dumps(body.evidence or []), body.review_status or "draft", who, db.now(), db.now()))
|
||
db.audit(con, who, "create", "relation", rid, None, data)
|
||
return db.row(con.execute("SELECT * FROM relations WHERE relation_id=?", (rid,)).fetchone())
|
||
|
||
|
||
@app.put("/api/admin/relations/{rid}")
|
||
def admin_relation_update(rid: str, body: RelationIn, who: str = Depends(auth.require_admin)):
|
||
con = db.get()
|
||
before = db.row(con.execute("SELECT * FROM relations WHERE relation_id=?", (rid,)).fetchone())
|
||
if not before:
|
||
raise HTTPException(404)
|
||
data = body.model_dump(exclude_unset=True)
|
||
_check_rel(con, data)
|
||
vals = {}
|
||
for k, v in data.items():
|
||
if k == "evidence":
|
||
v = db.dumps(v or [])
|
||
elif k in ("directed", "hidden"):
|
||
v = int(bool(v))
|
||
vals[k] = v
|
||
vals.update(edited=1, reviewer=who, updated_at=db.now())
|
||
with con:
|
||
con.execute(f"UPDATE relations SET {','.join(f'{k}=?' for k in vals)} WHERE relation_id=?", (*vals.values(), rid))
|
||
db.audit(con, who, "update", "relation", rid, {k: before.get(k) for k in data}, data)
|
||
return db.row(con.execute("SELECT * FROM relations WHERE relation_id=?", (rid,)).fetchone())
|
||
|
||
|
||
@app.delete("/api/admin/relations/{rid}")
|
||
def admin_relation_delete(rid: str, who: str = Depends(auth.require_admin)):
|
||
con = db.get()
|
||
before = db.row(con.execute("SELECT * FROM relations WHERE relation_id=?", (rid,)).fetchone())
|
||
if not before:
|
||
raise HTTPException(404)
|
||
with con:
|
||
if before["method"] == "manual":
|
||
con.execute("DELETE FROM relations WHERE relation_id=?", (rid,))
|
||
else: # 자동 관계는 다시 생성되지 않도록 숨김 처리
|
||
con.execute("UPDATE relations SET hidden=1, review_status='rejected', edited=1, reviewer=?, updated_at=? WHERE relation_id=?",
|
||
(who, db.now(), rid))
|
||
db.audit(con, who, "delete", "relation", rid, before, None)
|
||
return {"ok": True}
|
||
|
||
|
||
class BulkIn(BaseModel):
|
||
ids: list[str]
|
||
review_status: str | None = None
|
||
hidden: bool | None = None
|
||
|
||
|
||
@app.post("/api/admin/relations/bulk")
|
||
def admin_relation_bulk(body: BulkIn, who: str = Depends(auth.require_admin)):
|
||
con = db.get()
|
||
if body.review_status and body.review_status not in REL_STATUSES:
|
||
raise HTTPException(400, "잘못된 검수 상태")
|
||
with con:
|
||
for rid in body.ids:
|
||
sets, args = ["edited=1", "reviewer=?", "updated_at=?"], [who, db.now()]
|
||
if body.review_status:
|
||
sets.append("review_status=?")
|
||
args.append(body.review_status)
|
||
if body.hidden is not None:
|
||
sets.append("hidden=?")
|
||
args.append(int(body.hidden))
|
||
con.execute(f"UPDATE relations SET {','.join(sets)} WHERE relation_id=?", (*args, rid))
|
||
db.audit(con, who, "bulk", "relation", ",".join(body.ids[:50]), None, body.model_dump(exclude={"ids"}) | {"count": len(body.ids)})
|
||
return {"ok": True, "count": len(body.ids)}
|
||
|
||
|
||
@app.post("/api/admin/terms/bulk")
|
||
def admin_term_bulk(body: BulkIn, who: str = Depends(auth.require_admin)):
|
||
con = db.get()
|
||
if body.review_status and body.review_status not in TERM_STATUSES:
|
||
raise HTTPException(400, "잘못된 검수 상태")
|
||
with con:
|
||
for tid in body.ids:
|
||
sets, args = ["edited=1", "reviewer=?", "updated_at=?"], [who, db.now()]
|
||
if body.review_status:
|
||
sets.append("review_status=?")
|
||
args.append(body.review_status)
|
||
if body.hidden is not None:
|
||
sets.append("hidden=?")
|
||
args.append(int(body.hidden))
|
||
con.execute(f"UPDATE terms SET {','.join(sets)} WHERE term_id=?", (*args, tid))
|
||
db.audit(con, who, "bulk", "term", ",".join(body.ids[:50]), None, body.model_dump(exclude={"ids"}) | {"count": len(body.ids)})
|
||
return {"ok": True, "count": len(body.ids)}
|
||
|
||
|
||
# ---- 은하·단원
|
||
|
||
class GalaxyIn(BaseModel):
|
||
name: str | None = None
|
||
sub: str | None = None
|
||
color: str | None = None
|
||
|
||
|
||
@app.get("/api/admin/structure")
|
||
def admin_structure(_: str = Depends(auth.require_admin)):
|
||
con = db.get()
|
||
return {
|
||
"books": [dict(r) for r in con.execute("SELECT * FROM books")],
|
||
"galaxies": [dict(r) for r in con.execute("SELECT * FROM galaxies ORDER BY ord")],
|
||
"toc": [dict(r) for r in con.execute("SELECT * FROM toc ORDER BY book_id, page, level")],
|
||
}
|
||
|
||
|
||
@app.put("/api/admin/galaxies/{gid}")
|
||
def admin_galaxy_update(gid: str, body: GalaxyIn, who: str = Depends(auth.require_admin)):
|
||
con = db.get()
|
||
before = con.execute("SELECT * FROM galaxies WHERE galaxy_id=?", (gid,)).fetchone()
|
||
if not before:
|
||
raise HTTPException(404)
|
||
data = body.model_dump(exclude_unset=True)
|
||
with con:
|
||
if data:
|
||
con.execute(f"UPDATE galaxies SET {','.join(f'{k}=?' for k in data)}, edited=1, updated_at=? WHERE galaxy_id=?",
|
||
(*data.values(), db.now(), gid))
|
||
db.audit(con, who, "update", "galaxy", gid, {k: before[k] for k in data}, data)
|
||
return dict(con.execute("SELECT * FROM galaxies WHERE galaxy_id=?", (gid,)).fetchone())
|
||
|
||
|
||
class TocIn(BaseModel):
|
||
title: str | None = None
|
||
easy_sub: str | None = None
|
||
galaxy_id: str | None = None
|
||
|
||
|
||
@app.put("/api/admin/toc/{toc_id}")
|
||
def admin_toc_update(toc_id: str, body: TocIn, who: str = Depends(auth.require_admin)):
|
||
con = db.get()
|
||
before = con.execute("SELECT * FROM toc WHERE toc_id=?", (toc_id,)).fetchone()
|
||
if not before:
|
||
raise HTTPException(404)
|
||
data = body.model_dump(exclude_unset=True)
|
||
with con:
|
||
if data:
|
||
con.execute(f"UPDATE toc SET {','.join(f'{k}=?' for k in data)}, edited=1 WHERE toc_id=?", (*data.values(), toc_id))
|
||
if data.get("galaxy_id"):
|
||
con.execute("UPDATE terms SET galaxy_id=?, updated_at=? WHERE section_id=?", (data["galaxy_id"], db.now(), toc_id))
|
||
db.audit(con, who, "update", "toc", toc_id, {k: before[k] for k in data}, data)
|
||
return dict(con.execute("SELECT * FROM toc WHERE toc_id=?", (toc_id,)).fetchone())
|
||
|
||
|
||
# ---- 설정·기록·내보내기
|
||
|
||
@app.get("/api/admin/settings")
|
||
def admin_settings(_: str = Depends(auth.require_admin)):
|
||
return db.all_settings(db.get())
|
||
|
||
|
||
class SettingsIn(BaseModel):
|
||
public_statuses: list[str] | None = None
|
||
show_status_badges: bool | None = None
|
||
|
||
|
||
@app.put("/api/admin/settings")
|
||
def admin_settings_update(body: SettingsIn, who: str = Depends(auth.require_owner)):
|
||
con = db.get()
|
||
data = body.model_dump(exclude_unset=True)
|
||
if "public_statuses" in data and not set(data["public_statuses"]) <= set(TERM_STATUSES):
|
||
raise HTTPException(400, "잘못된 상태")
|
||
with con:
|
||
for k, v in data.items():
|
||
db.set_setting(con, k, v)
|
||
db.audit(con, who, "update", "settings", None, None, data)
|
||
return db.all_settings(con)
|
||
|
||
|
||
@app.get("/api/admin/audit")
|
||
def admin_audit(entity: str | None = None, limit: int = Query(100, le=500), offset: int = 0, _: str = Depends(auth.require_admin)):
|
||
con = db.get()
|
||
where, args = "1=1", []
|
||
if entity:
|
||
where, args = "entity=?", [entity]
|
||
total = con.execute(f"SELECT COUNT(*) FROM audit WHERE {where}", args).fetchone()[0]
|
||
return {"total": total, "items": [dict(r) for r in con.execute(
|
||
f"SELECT * FROM audit WHERE {where} ORDER BY id DESC LIMIT ? OFFSET ?", (*args, limit, offset))]}
|
||
|
||
|
||
@app.get("/api/admin/export")
|
||
def admin_export(_: str = Depends(auth.require_admin)):
|
||
"""현재 DB 전체(용어·관계·출처·구조·피드백)를 JSON으로 내려받기."""
|
||
con = db.get()
|
||
data = {
|
||
"exported_at": db.now(),
|
||
"books": [dict(r) for r in con.execute("SELECT * FROM books")],
|
||
"galaxies": [dict(r) for r in con.execute("SELECT * FROM galaxies")],
|
||
"toc": [dict(r) for r in con.execute("SELECT * FROM toc")],
|
||
"terms": db.rows(con.execute("SELECT * FROM terms")),
|
||
"term_sources": [dict(r) for r in con.execute("SELECT * FROM term_sources")],
|
||
"relations": db.rows(con.execute("SELECT * FROM relations")),
|
||
"feedback": [dict(r) for r in con.execute("SELECT * FROM feedback")],
|
||
}
|
||
return Response(json.dumps(data, ensure_ascii=False, indent=1), media_type="application/json",
|
||
headers={"Content-Disposition": f'attachment; filename="atlas-export-{time.strftime("%Y%m%d-%H%M")}.json"'})
|
||
|
||
|
||
@app.post("/api/admin/reseed")
|
||
def admin_reseed(who: str = Depends(auth.require_admin)):
|
||
"""data/atlas.json(파이프라인 결과)을 다시 가져옴. 사람이 고친 항목은 유지."""
|
||
if not ATLAS_JSON.exists():
|
||
raise HTTPException(404, "data/atlas.json 이 없습니다.")
|
||
con = db.get()
|
||
stats = seed(con, json.loads(ATLAS_JSON.read_text()))
|
||
with con:
|
||
db.audit(con, who, "reseed", "atlas", None, None, stats)
|
||
return stats
|