Three coupled fixes from the first manual test pass:
1. Stale signed cookie no longer 500s. `rooms.me()` now raises KeyError
when the participant row is gone (the previous code deref'd None and
threw TypeError, caught by quiz.js's catch-all as 'link expired').
`/api/session/{sid}/me` translates KeyError into 401 + delete_cookie,
so the client falls back to the join form cleanly.
Returning a JSONResponse directly because `raise HTTPException`
discards Response.delete_cookie mutations (FastAPI middleware
composes a fresh response on exception).
2. Reset is now a soft restart from the student's perspective. Before
closing each student WS in `RoomManager.reset`, the server now sends
a `{"type": "session_reset"}` message. The student SPA tears down
local state and re-runs boot(); /me returns 401 (now that the
participant is gone) and the join form renders without the user
having to manually reload. The WS close handler suppresses its
"Disconnected" screen during a reset to avoid a flash.
3. "You" highlight on the student leaderboard is now matched by id, not
by name. `RoomManager.leaderboard()` accepts an optional
`you_student_id` and stamps `is_you: true` on the matching entry only.
No other students' ids leak over the wire (we still don't include
`student_id` in the public top5 payload). quiz.js's renderBoard
prefers `r.is_you` when any row is marked, falling back to name match
for backward compatibility.
41/41 tests pass. Two new tests cover (a) the 401 + cookie-clear path
after reset and (b) `is_you` marking only the requesting student.
101 lines
4.3 KiB
Python
101 lines
4.3 KiB
Python
"""Student routes (single-session deployment)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
from fastapi import APIRouter, HTTPException, Request, Response, WebSocket
|
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
|
|
|
from app import auth
|
|
from app.config import Settings
|
|
from app.models import JoinRequest
|
|
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):
|
|
target_sid = resolve_sid(sid)
|
|
if not await rooms.session_exists(target_sid):
|
|
return HTMLResponse(
|
|
"<!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}")
|
|
async def session_metadata(sid: str):
|
|
if not await rooms.session_exists(sid):
|
|
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"],
|
|
"time_limit_default": (await rooms.get_pool_for_session(sid))["time_limit_default"],
|
|
}
|
|
|
|
@api.post("/api/session/{sid}/join")
|
|
async def join_session(sid: str, body: JoinRequest, response: Response):
|
|
if not await rooms.session_exists(sid):
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
student_id = body.student_id.strip()
|
|
name = body.name.strip()
|
|
cookie_id = str(uuid4())
|
|
cookie_value = auth.sign_student(settings, sid, student_id, name, cookie_id)
|
|
await rooms.add_participant(sid, student_id, name, cookie_id)
|
|
auth.set_student_cookie(settings, response, cookie_value)
|
|
return {"ok": True, "cookie_id": cookie_id}
|
|
|
|
@api.get("/api/session/{sid}/me")
|
|
async def me(sid: str, request: Request):
|
|
identity = auth.get_student_identity(settings, request, sid)
|
|
if not identity:
|
|
raise HTTPException(status_code=401, detail="Student cookie required")
|
|
try:
|
|
return await rooms.me(sid, identity["student_id"])
|
|
except KeyError:
|
|
# Cookie's student_id is no longer in the DB (e.g. session reset
|
|
# or DB rebuilt while the cookie persisted). Send 401 with the
|
|
# cookie cleared so the client renders the join form. We build
|
|
# the JSONResponse directly because raising HTTPException would
|
|
# bypass the cookie mutation.
|
|
resp = JSONResponse({"detail": "Re-join required"}, status_code=401)
|
|
resp.delete_cookie(auth.STUDENT_COOKIE, path="/")
|
|
return resp
|
|
|
|
@api.get("/api/session/{sid}/stats")
|
|
async def stats(sid: str, request: Request, question_idx: int | None = None):
|
|
if not await rooms.session_exists(sid):
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
identity = auth.get_student_identity(settings, request, sid)
|
|
return await rooms.stats(sid, question_idx, identity["student_id"] if identity else None)
|
|
|
|
@api.websocket("/ws/student/{sid}")
|
|
async def student_socket(websocket: WebSocket, sid: str):
|
|
identity = auth.get_student_identity_ws(settings, websocket, sid)
|
|
if not identity or not await rooms.session_exists(sid):
|
|
await websocket.close(code=4001)
|
|
return
|
|
await rooms.student_ws(websocket, sid, identity)
|
|
|
|
return api
|