"""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), )