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

@@ -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()