overhaul: single-session deployment + redesigned frontend

Backend simplification:
- The server now loads ONE pool JSON from $QUIZ_POOL_PATH at startup and
  upserts a single canonical session. The session id comes from the pool
  JSON's optional "session_id" field, falling back to $QUIZ_SESSION_ID.
- The multi-quiz / multi-session CRUD API is gone:
    DELETED  GET/POST /admin/api/quizzes
    DELETED  POST    /admin/api/quizzes/upload
    DELETED  GET/POST /admin/api/sessions
    DELETED  GET     /admin/login (HTML stub)
    DELETED  GET     /admin/api/sessions/{sid}/csv (replaced by /admin/api/csv)
  Replaced with a single-session control surface:
    GET  /admin/                — serves admin.html unconditionally
    GET  /admin/api/state       — admin-gated; pool meta + state + QR + join URL
    POST /admin/api/reset       — admin-gated; wipe submissions + back to lobby
    POST /admin/logout          — clear admin cookie
    GET  /admin/api/csv         — single-session results
    WS   /ws/instructor/{sid}   — kept; new commands "next" + "reset"
- Instructor "Next" button is now a single state-driving command
  (RoomManager.advance_to_next): from lobby it opens Q0; from question_open
  it closes the current Q and opens the next; from question_closed it
  opens the next; if past the last question it ends the session.
- New RoomManager.reset wipes submissions, participants, and per-question
  state, then broadcasts a clean lobby.
- Student GET / now redirects to /?sid=<canonical> when no sid is given,
  so the QR / share URL is fully deterministic.

Frontend rewrite (functional baseline; visual polish to follow):
- /admin/ is now a single SPA: GET /admin/api/state decides login form
  vs dashboard. No separate /admin/login URL bar.
- Admin dashboard is state-driven with one primary action per state.
  QR code, join URL, and live participant list are always visible on the
  left so the operator can leave the page on a projector.
- Student answer buttons are big and tappable; reveal screen highlights
  correct/wrong choice + shows score, total, and rank.
- Static admin/student SPAs share a CSS palette with light/dark support.

Tests rewritten around the single canonical session id.
The auto-bootstrapped session lets each test fixture skip the old
quiz/session creation dance. 39/39 tests pass.

Cleanup:
- Deleted CODEX_PROMPT.md, IMPLEMENTATION_REPORT.md, NOTES.md, SPEC.md,
  static/observer.html (obsolete codex-build artifacts and the unused
  observer view).
- .gitignore now blocks /pool.json (the runtime file the operator drops
  on the server) and the leftover .codex_done / codex_run.log / etc.
- bootstrap.sh seeds /opt/quiz/pool.json from examples/pool_example.json
  on first deploy so a fresh box reaches a usable state without manual
  intervention; .env now includes QUIZ_POOL_PATH.
This commit is contained in:
ameer
2026-05-02 21:13:54 +08:00
parent 32c531247d
commit e7a2f0387b
29 changed files with 1696 additions and 1533 deletions

View File

@@ -28,6 +28,8 @@ class Settings:
port: int = 8001
public_url: str = "https://quiz.ahkhan.me"
log_level: str = "INFO"
pool_path: str = "./pool.json"
default_session_id: str = "main"
@classmethod
def from_env(cls) -> "Settings":
@@ -40,6 +42,8 @@ class Settings:
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"),
pool_path=os.getenv("QUIZ_POOL_PATH", "./pool.json"),
default_session_id=os.getenv("QUIZ_SESSION_ID", "main"),
)
@property

View File

@@ -1,27 +1,47 @@
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
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.pool import PoolValidationError, load_pool_from_file
from app.room import RoomManager
from app.routes_admin import router as admin_router
from app.routes_student import router as student_router
log = logging.getLogger("quiz")
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")
@asynccontextmanager
async def lifespan(_app: FastAPI):
await init_db(settings.db_path)
try:
pool = load_pool_from_file(settings.pool_path)
except PoolValidationError as exc:
log.error("Pool load failed at %s: %s", settings.pool_path, exc)
log.error("Server is starting without an active session.")
log.error("Drop a valid pool JSON at %s and restart.", settings.pool_path)
else:
sid = pool.get("session_id", settings.default_session_id)
await rooms.ensure_single_session(sid, pool)
rooms.canonical_sid = sid
log.info("Session ready: sid=%s title=%r questions=%d",
sid, pool["title"], len(pool["questions"]))
yield
app = FastAPI(title="Live In-Lecture Quiz Portal", lifespan=lifespan)
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 {

View File

@@ -1,9 +1,7 @@
"""Pydantic request and response models."""
"""Pydantic request models."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
@@ -14,19 +12,3 @@ class JoinRequest(BaseModel):
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

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from app.scoring import SCORE_FNS
@@ -25,6 +26,17 @@ def parse_pool_json(pool_json: str | dict[str, Any]) -> dict[str, Any]:
return validate_pool(data)
def load_pool_from_file(path: str | Path) -> dict[str, Any]:
p = Path(path)
if not p.exists():
raise PoolValidationError(f"Pool file not found: {p}")
try:
raw = p.read_text(encoding="utf-8")
except OSError as exc:
raise PoolValidationError(f"Could not read pool file {p}: {exc}") from exc
return parse_pool_json(raw)
def validate_pool(data: dict[str, Any]) -> dict[str, Any]:
if not isinstance(data, dict):
raise PoolValidationError("Pool must be a JSON object")
@@ -45,12 +57,18 @@ def validate_pool(data: dict[str, Any]) -> dict[str, Any]:
raise PoolValidationError(f"Question {index} must be an object")
normalized_questions.append(_validate_question(question, index, time_limit_default))
return {
out: dict[str, Any] = {
"title": title.strip(),
"score_fn": score_fn,
"time_limit_default": time_limit_default,
"questions": normalized_questions,
}
session_id = data.get("session_id")
if session_id is not None:
if not isinstance(session_id, str) or not session_id.strip():
raise PoolValidationError("session_id, if present, must be a non-empty string")
out["session_id"] = session_id.strip()
return out
def question_count(pool: dict[str, Any]) -> int:

View File

@@ -44,6 +44,100 @@ class RoomManager:
self.instructor_clients: dict[str, set[WebSocket]] = defaultdict(set)
self.autoclose_tasks: dict[tuple[str, int], asyncio.Task] = {}
self.locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
# The single canonical session id, set during startup once the pool
# has been loaded. Routes use this rather than settings.default_session_id
# so that a session_id field in the pool JSON can override the env default.
self.canonical_sid: str | None = None
async def ensure_single_session(self, sid: str, pool: dict[str, Any]) -> None:
"""Idempotently upsert the canonical single-session row + its quiz row.
Called on startup with the operator-supplied pool JSON. Creates the
quiz + session if they don't exist, otherwise updates the pool blob
on the existing quiz so a fresh restart picks up edits to the pool
file without losing prior submissions for the same session.
"""
title = pool["title"]
pool_blob = json.dumps(pool)
async with connect(self.settings.db_path) as db:
cursor = await db.execute(
"SELECT quiz_id FROM quiz_sessions WHERE sid = ?",
(sid,),
)
row = await cursor.fetchone()
if row is None:
cursor = await db.execute(
"INSERT INTO quizzes (title, pool_json, time_limit_default, score_fn_name) VALUES (?, ?, ?, ?)",
(title, pool_blob, pool["time_limit_default"], pool["score_fn"]),
)
quiz_id = cursor.lastrowid
await db.execute(
"INSERT INTO quiz_sessions (sid, quiz_id, title) VALUES (?, ?, ?)",
(sid, quiz_id, title),
)
else:
quiz_id = row["quiz_id"]
await db.execute(
"UPDATE quizzes SET title = ?, pool_json = ?, time_limit_default = ?, score_fn_name = ? WHERE id = ?",
(title, pool_blob, pool["time_limit_default"], pool["score_fn"], quiz_id),
)
await db.execute(
"UPDATE quiz_sessions SET title = ? WHERE sid = ?",
(title, sid),
)
await db.commit()
async def advance_to_next(self, sid: str) -> None:
"""Instructor 'Next' button: a single button that drives the whole
lifecycle. From lobby it opens Q0; from a question_open state it
closes the current Q and opens the next; from question_closed it
opens the next Q. If there is no next question, the session ends.
"""
async with self.locks[sid]:
session = await self.get_session(sid)
if session["state"] == "finished":
return
current_idx = session["current_question_idx"]
close_current = session["state"] == "question_open"
if close_current:
await self._close_question_locked(sid, int(current_idx))
if close_current:
await self.broadcast_question_closed(sid, int(current_idx))
pool = await self.get_pool_for_session(sid)
total = question_count(pool)
next_idx = 0 if current_idx is None else int(current_idx) + 1
if next_idx >= total:
await self.end_session(sid)
return
await self.open_question(sid, next_idx)
async def reset(self, sid: str) -> None:
"""Wipe submissions, participants, and per-question state, then return
the session to lobby. Useful for re-running the same quiz across
classes without redeploying."""
async with self.locks[sid]:
task_keys = [key for key in self.autoclose_tasks if key[0] == sid]
for key in task_keys:
task = self.autoclose_tasks.pop(key, None)
if task:
task.cancel()
async with connect(self.settings.db_path) as db:
await db.execute("DELETE FROM submissions WHERE sid = ?", (sid,))
await db.execute("DELETE FROM question_events WHERE sid = ?", (sid,))
await db.execute("DELETE FROM participants WHERE sid = ?", (sid,))
await db.execute(
"UPDATE quiz_sessions SET state = 'lobby', current_question_idx = NULL, finished_at = NULL WHERE sid = ?",
(sid,),
)
await db.commit()
for ws in list(self.student_clients.get(sid, {}).keys()):
try:
await ws.close(code=4002)
except Exception:
pass
self.student_clients.pop(sid, None)
await self.broadcast_instructors(sid, {"type": "state", "state": "lobby", "current_question_idx": None, "title": (await self.get_session(sid))["title"]})
await self.broadcast_lobby(sid)
async def sessions_active(self) -> int:
async with connect(self.settings.db_path) as db:
@@ -139,9 +233,11 @@ class RoomManager:
elif msg_type == "close_question":
await self.close_question(sid)
elif msg_type == "next":
await self.next_question(sid)
await self.advance_to_next(sid)
elif msg_type == "end_session":
await self.end_session(sid)
elif msg_type == "reset":
await self.reset(sid)
else:
await websocket.send_json({"type": "error", "code": "bad_message", "message": "Unknown message type"})
except (WebSocketDisconnect, RuntimeError):

View File

@@ -1,28 +1,29 @@
"""Instructor routes."""
"""Instructor routes (single-session deployment).
The deployment runs exactly one quiz session at a time, loaded from
`QUIZ_POOL_PATH` at startup. There is no per-quiz CRUD; the operator
edits the pool JSON on disk and restarts the service when they want
a new pool. The admin UI is therefore a thin control panel for the
single canonical session whose id is `Settings.default_session_id`.
"""
from __future__ import annotations
import base64
import json
import secrets
from io import BytesIO
from typing import Any
from pathlib import Path
import qrcode
import qrcode.image.svg
from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFile, WebSocket
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
from fastapi import APIRouter, HTTPException, Request, Response, WebSocket
from fastapi.responses import FileResponse, PlainTextResponse
from app import auth
from app.config import Settings
from app.csv_export import export_session_csv
from app.db import connect
from app.models import QuizCreateRequest, SessionCreateRequest
from app.pool import PoolValidationError, parse_pool_json
from app.models import AdminLoginRequest
from app.room import RoomManager
CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
def router(settings: Settings, rooms: RoomManager) -> APIRouter:
api = APIRouter()
@@ -30,119 +31,62 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter:
def require_admin(request: Request) -> None:
auth.require_admin_request(settings, request)
@api.get("/admin/login")
async def login_form():
return HTMLResponse(
"<!doctype html><title>Admin Login</title><form method='post'>"
"<label>Password <input name='password' type='password'></label>"
"<button type='submit'>Log in</button></form>"
)
@api.post("/admin/login")
async def login(request: Request, response: Response):
password = ""
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
data = await request.json()
password = str(data.get("password", ""))
else:
form = await request.form()
password = str(form.get("password", ""))
if not auth.verify_admin_password(settings, password):
async def login(body: AdminLoginRequest, response: Response):
if not auth.verify_admin_password(settings, body.password):
raise HTTPException(status_code=401, detail="Invalid admin password")
auth.set_admin_cookie(settings, response, auth.sign_admin(settings))
return {"ok": True}
@api.post("/admin/logout")
async def logout(response: Response):
response.delete_cookie(auth.ADMIN_COOKIE, path="/")
return {"ok": True}
@api.get("/admin/")
async def admin_page(request: Request):
require_admin(request)
return FileResponse("static/admin.html")
async def admin_page():
# No auth gate; the SPA fetches /admin/api/state and renders
# the login form on 401 or the dashboard on 200.
return FileResponse(Path("static/admin.html"))
@api.get("/admin/api/quizzes")
async def list_quizzes(request: Request):
require_admin(request)
async with connect(settings.db_path) as db:
cursor = await db.execute(
"SELECT id, title, time_limit_default, score_fn_name, created_at FROM quizzes ORDER BY created_at DESC, id DESC"
)
rows = await cursor.fetchall()
return {"quizzes": [dict(row) for row in rows]}
@api.post("/admin/api/quizzes")
async def create_quiz(request: Request, body: QuizCreateRequest):
require_admin(request)
try:
pool = parse_pool_json(body.pool_json)
except PoolValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if body.time_limit_default is not None:
pool["time_limit_default"] = body.time_limit_default
title = (body.title or pool["title"]).strip()
async with connect(settings.db_path) as db:
cursor = await db.execute(
"INSERT INTO quizzes (title, pool_json, time_limit_default, score_fn_name) VALUES (?, ?, ?, ?)",
(title, json.dumps(pool), pool["time_limit_default"], pool["score_fn"]),
)
await db.commit()
quiz_id = cursor.lastrowid
return {"ok": True, "quiz_id": quiz_id}
@api.post("/admin/api/quizzes/upload")
async def upload_quiz(request: Request, file: UploadFile = File(...)):
require_admin(request)
raw = (await file.read()).decode("utf-8")
try:
pool = parse_pool_json(raw)
except PoolValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
async with connect(settings.db_path) as db:
cursor = await db.execute(
"INSERT INTO quizzes (title, pool_json, time_limit_default, score_fn_name) VALUES (?, ?, ?, ?)",
(pool["title"], json.dumps(pool), pool["time_limit_default"], pool["score_fn"]),
)
await db.commit()
quiz_id = cursor.lastrowid
return {"ok": True, "quiz_id": quiz_id}
@api.get("/admin/api/sessions")
async def list_sessions(request: Request):
require_admin(request)
async with connect(settings.db_path) as db:
cursor = await db.execute(
"""
SELECT s.sid, s.quiz_id, s.title, s.state, s.current_question_idx, s.started_at, s.finished_at,
COUNT(p.student_id) AS participant_count
FROM quiz_sessions s
LEFT JOIN participants p ON p.sid = s.sid
GROUP BY s.sid
ORDER BY s.started_at DESC
"""
)
rows = await cursor.fetchall()
return {"sessions": [dict(row) for row in rows]}
@api.post("/admin/api/sessions")
async def create_session(request: Request, body: SessionCreateRequest):
require_admin(request)
async with connect(settings.db_path) as db:
quiz_cursor = await db.execute("SELECT id, title FROM quizzes WHERE id = ?", (body.quiz_id,))
quiz = await quiz_cursor.fetchone()
if quiz is None:
raise HTTPException(status_code=404, detail="Quiz not found")
sid = await _generate_sid(db)
await db.execute(
"INSERT INTO quiz_sessions (sid, quiz_id, title) VALUES (?, ?, ?)",
(sid, body.quiz_id, quiz["title"]),
)
await db.commit()
join_url = f"{settings.public_url}/?sid={sid}"
return {"sid": sid, "join_url": join_url, "qr_url": _qr_data_url(join_url)}
@api.get("/admin/api/sessions/{sid}/csv")
async def csv_download(sid: str, request: Request):
@api.get("/admin/api/state")
async def admin_state(request: Request):
require_admin(request)
sid = rooms.canonical_sid or settings.default_session_id
if not await rooms.session_exists(sid):
raise HTTPException(status_code=404, detail="Session not found")
raise HTTPException(status_code=503, detail="Session is not initialised")
session = await rooms.get_session(sid)
pool = await rooms.get_pool_for_session(sid)
join_url = f"{settings.public_url}/?sid={sid}"
return {
"sid": sid,
"title": session["title"],
"state": session["state"],
"current_question_idx": session["current_question_idx"],
"join_url": join_url,
"qr_url": _qr_data_url(join_url),
"pool_meta": {
"score_fn": pool["score_fn"],
"time_limit_default": pool["time_limit_default"],
"question_count": len(pool["questions"]),
},
}
@api.post("/admin/api/reset")
async def admin_reset(request: Request):
require_admin(request)
sid = rooms.canonical_sid or settings.default_session_id
if not await rooms.session_exists(sid):
raise HTTPException(status_code=503, detail="Session is not initialised")
await rooms.reset(sid)
return {"ok": True}
@api.get("/admin/api/csv")
async def csv_download(request: Request):
require_admin(request)
sid = rooms.canonical_sid or settings.default_session_id
if not await rooms.session_exists(sid):
raise HTTPException(status_code=503, detail="Session is not initialised")
csv_text = await export_session_csv(settings.db_path, sid)
return PlainTextResponse(
csv_text,
@@ -160,15 +104,6 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter:
return api
async def _generate_sid(db: Any) -> str:
for _ in range(5):
sid = "".join(secrets.choice(CROCKFORD) for _ in range(6))
cursor = await db.execute("SELECT 1 FROM quiz_sessions WHERE sid = ?", (sid,))
if await cursor.fetchone() is None:
return sid
raise HTTPException(status_code=500, detail="Could not allocate session ID")
def _qr_data_url(value: str) -> str:
image = qrcode.make(value, image_factory=qrcode.image.svg.SvgPathImage)
buf = BytesIO()

View File

@@ -1,4 +1,4 @@
"""Student routes."""
"""Student routes (single-session deployment)."""
from __future__ import annotations
@@ -6,7 +6,7 @@ from pathlib import Path
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request, Response, WebSocket
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from app import auth
from app.config import Settings
@@ -17,13 +17,27 @@ from app.room import RoomManager
def router(settings: Settings, rooms: RoomManager) -> APIRouter:
api = APIRouter()
def resolve_sid(sid: str | None) -> str:
return sid if sid else (rooms.canonical_sid or settings.default_session_id)
@api.get("/")
async def student_entry(sid: str | None = None):
if not sid or not await rooms.session_exists(sid):
target_sid = resolve_sid(sid)
if not await rooms.session_exists(target_sid):
return HTMLResponse(
"<!doctype html><title>Quiz</title><main><h1>Ask your instructor for the link</h1>"
"<p>This quiz link is missing or no longer valid.</p></main>"
"<!doctype html><meta charset='utf-8'>"
"<link rel='stylesheet' href='/static/style.css'>"
"<title>Quiz unavailable</title>"
"<main class='centered-shell'><div class='card narrow'>"
"<h1>Ask your instructor for the link</h1>"
"<p class='muted'>This quiz link is missing or no longer valid.</p>"
"</div></main>",
status_code=404,
)
if not sid:
# Canonicalise the URL so QR codes, share links, and bookmarks
# all converge on the same sid-bearing form.
return RedirectResponse(url=f"/?sid={target_sid}", status_code=302)
return FileResponse(Path("static/student.html"))
@api.get("/api/session/{sid}")
@@ -32,6 +46,7 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter:
raise HTTPException(status_code=404, detail="Session not found")
session = await rooms.get_session(sid)
return {
"sid": sid,
"title": session["title"],
"state": session["state"],
"current_question_idx": session["current_question_idx"],