From f689b8f297e28278bc34d258ca3710395df71dbc Mon Sep 17 00:00:00 2001 From: ameer Date: Sat, 2 May 2026 02:59:34 +0800 Subject: [PATCH] Add configuration and database core --- app/config.py | 46 +++++++++++++++++++++++++ app/csv_export.py | 39 +++++++++++++++++++++ app/db.py | 86 +++++++++++++++++++++++++++++++++++++++++++++++ app/main.py | 33 +++++++++++++++++- app/models.py | 31 +++++++++++++++++ 5 files changed, 234 insertions(+), 1 deletion(-) diff --git a/app/config.py b/app/config.py index b3ef134..c7ee23e 100644 --- a/app/config.py +++ b/app/config.py @@ -1 +1,47 @@ """Application configuration.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +def load_dotenv(path: str | Path = ".env") -> None: + env_path = Path(path) + if not env_path.exists(): + return + for raw_line in env_path.read_text().splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +@dataclass(slots=True) +class Settings: + db_path: str = "./quiz.db" + secret_key: str | None = None + admin_password: str | None = None + host: str = "127.0.0.1" + port: int = 8001 + public_url: str = "https://quiz.ahkhan.me" + log_level: str = "INFO" + + @classmethod + def from_env(cls) -> "Settings": + load_dotenv() + return cls( + db_path=os.getenv("QUIZ_DB_PATH", "./quiz.db"), + secret_key=os.getenv("QUIZ_SECRET_KEY"), + admin_password=os.getenv("QUIZ_ADMIN_PASSWORD"), + host=os.getenv("QUIZ_HOST", "127.0.0.1"), + port=int(os.getenv("QUIZ_PORT", "8001")), + public_url=os.getenv("QUIZ_PUBLIC_URL", "https://quiz.ahkhan.me").rstrip("/"), + log_level=os.getenv("QUIZ_LOG_LEVEL", "INFO"), + ) + + @property + def secure_cookies(self) -> bool: + return self.public_url.startswith("https://") diff --git a/app/csv_export.py b/app/csv_export.py index 7e2565b..cad21fe 100644 --- a/app/csv_export.py +++ b/app/csv_export.py @@ -1 +1,40 @@ """CSV export helpers.""" + +from __future__ import annotations + +import csv +from io import StringIO + +from app.db import connect + + +async def export_session_csv(db_path: str, sid: str) -> str: + out = StringIO() + writer = csv.writer(out) + writer.writerow(["sid", "student_id", "name", "question_idx", "answer", "elapsed_ms", "score", "status"]) + async with connect(db_path) as db: + cursor = await db.execute( + """ + SELECT p.sid, p.student_id, p.name, s.question_idx, s.answer, s.elapsed_ms, s.score, s.status + FROM participants p + LEFT JOIN submissions s ON s.sid = p.sid AND s.student_id = p.student_id + WHERE p.sid = ? + ORDER BY p.student_id, s.question_idx + """, + (sid,), + ) + rows = await cursor.fetchall() + for row in rows: + writer.writerow( + [ + row["sid"], + row["student_id"], + row["name"], + "" if row["question_idx"] is None else row["question_idx"], + row["answer"] or "", + "" if row["elapsed_ms"] is None else row["elapsed_ms"], + "" if row["score"] is None else row["score"], + row["status"] or "", + ] + ) + return out.getvalue() diff --git a/app/db.py b/app/db.py index d910356..f575634 100644 --- a/app/db.py +++ b/app/db.py @@ -1 +1,87 @@ """SQLite helpers.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path + +import aiosqlite + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS quizzes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + pool_json TEXT NOT NULL, + time_limit_default INTEGER NOT NULL DEFAULT 60, + score_fn_name TEXT NOT NULL DEFAULT 'linear_decay', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS quiz_sessions ( + sid TEXT PRIMARY KEY, + quiz_id INTEGER NOT NULL REFERENCES quizzes(id), + title TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'lobby', + current_question_idx INTEGER, + started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS participants ( + sid TEXT NOT NULL REFERENCES quiz_sessions(sid), + student_id TEXT NOT NULL, + name TEXT NOT NULL, + cookie_id TEXT NOT NULL, + joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (sid, student_id) +); + +CREATE TABLE IF NOT EXISTS question_events ( + sid TEXT NOT NULL REFERENCES quiz_sessions(sid), + question_idx INTEGER NOT NULL, + opened_at TIMESTAMP NOT NULL, + closed_at TIMESTAMP, + time_limit INTEGER NOT NULL, + PRIMARY KEY (sid, question_idx) +); + +CREATE TABLE IF NOT EXISTS submissions ( + sid TEXT NOT NULL REFERENCES quiz_sessions(sid), + student_id TEXT NOT NULL, + question_idx INTEGER NOT NULL, + answer TEXT, + submitted_at TIMESTAMP, + elapsed_ms INTEGER, + score INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'submitted', + PRIMARY KEY (sid, student_id, question_idx) +); + +CREATE INDEX IF NOT EXISTS idx_submissions_sid_qidx ON submissions(sid, question_idx); +CREATE INDEX IF NOT EXISTS idx_participants_sid ON participants(sid); +""" + + +async def init_db(db_path: str) -> None: + if db_path != ":memory:": + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + async with connect(db_path) as db: + await db.executescript(SCHEMA) + await db.commit() + + +@asynccontextmanager +async def connect(db_path: str) -> AsyncIterator[aiosqlite.Connection]: + db = await aiosqlite.connect(db_path) + db.row_factory = aiosqlite.Row + await db.execute("PRAGMA foreign_keys=ON") + await db.execute("PRAGMA journal_mode=WAL") + try: + yield db + finally: + await db.close() + + +def row_to_dict(row: aiosqlite.Row | None) -> dict | None: + return dict(row) if row is not None else None diff --git a/app/main.py b/app/main.py index cc506bf..7b2c48c 100644 --- a/app/main.py +++ b/app/main.py @@ -1,8 +1,39 @@ +from __future__ import annotations + from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from app import __version__ +from app.config import Settings +from app.db import init_db +from app.room import RoomManager +from app.routes_admin import router as admin_router +from app.routes_student import router as student_router -def create_app() -> FastAPI: +def create_app(settings: Settings | None = None) -> FastAPI: + settings = settings or Settings.from_env() + rooms = RoomManager(settings) app = FastAPI(title="Live In-Lecture Quiz Portal") + app.state.settings = settings + app.state.rooms = rooms + + @app.on_event("startup") + async def startup() -> None: + await init_db(settings.db_path) + + @app.get("/healthz") + async def healthz(): + return { + "ok": True, + "version": __version__, + "sessions_active": await rooms.sessions_active(), + "ws_clients": rooms.ws_client_count(), + } + + app.mount("/static", StaticFiles(directory="static"), name="static") + app.include_router(admin_router(settings, rooms)) + app.include_router(student_router(settings, rooms)) return app diff --git a/app/models.py b/app/models.py index 4294a13..6b06452 100644 --- a/app/models.py +++ b/app/models.py @@ -1 +1,32 @@ """Pydantic request and response models.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class JoinRequest(BaseModel): + student_id: str = Field(min_length=1, max_length=80) + name: str = Field(min_length=1, max_length=120) + + +class AdminLoginRequest(BaseModel): + password: str + + +class QuizCreateRequest(BaseModel): + title: str | None = None + pool_json: dict[str, Any] | str + time_limit_default: int | None = None + + +class SessionCreateRequest(BaseModel): + quiz_id: int + + +class SubmitMessage(BaseModel): + type: str + question_idx: int + answer: str