Files
quiz/app/routes_admin.py
ameer e7a2f0387b 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.
2026-05-02 21:13:54 +08:00

113 lines
4.2 KiB
Python

"""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
from io import BytesIO
from pathlib import Path
import qrcode
import qrcode.image.svg
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.models import AdminLoginRequest
from app.room import RoomManager
def router(settings: Settings, rooms: RoomManager) -> APIRouter:
api = APIRouter()
def require_admin(request: Request) -> None:
auth.require_admin_request(settings, request)
@api.post("/admin/login")
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():
# 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/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=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,
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{sid}-results.csv"'},
)
@api.websocket("/ws/instructor/{sid}")
async def instructor_socket(websocket: WebSocket, sid: str):
if not auth.is_admin_ws(settings, websocket) or not await rooms.session_exists(sid):
await websocket.close(code=4001)
return
await rooms.instructor_ws(websocket, sid)
return api
def _qr_data_url(value: str) -> str:
image = qrcode.make(value, image_factory=qrcode.image.svg.SvgPathImage)
buf = BytesIO()
image.save(buf)
encoded = base64.b64encode(buf.getvalue()).decode("ascii")
return f"data:image/svg+xml;base64,{encoded}"