- 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.
225 lines
7.8 KiB
Python
225 lines
7.8 KiB
Python
"""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),
|
|
)
|