feat: anti-cheat + presence panel + projector view
Refinement pass on top of the validated v1.2 base.
Hardening / quick fixes
- Caddyfile.tpl: CSP / HSTS / X-Frame / Referrer-Policy / Permissions-Policy,
1 MiB request body cap, X-Forwarded-For pass-through; stock-Caddy compatible.
- auth.py: STUDENT_MAX_AGE 1y -> 30d.
- bootstrap.sh: stage 5 prepends `git config --add safe.directory` so the
re-bootstrap path no longer 'detected dubious ownership' faults.
- admin.js: drop the misleading `closedPayload?.state || session.state`
shim; state derives from session only.
Anti-cheat
- New `student_events` audit table; new POST /api/session/{sid}/event for
blur / visibility_hidden / focus / visibility_visible. quiz.js debounces
events at 1.5s and uses sendBeacon for visibility_hidden so the event
survives a navigation. Counts surface in admin presence + CSV export.
- First-claim-wins on join: add_participant raises DuplicateStudentId on
PK violation; route returns 409 + records a duplicate_join audit event
with attempted name + IP + UA. Admin dashboard surfaces a per-row red
badge for hits on real participants and a top-of-page alert for orphan
attempts.
- DELETE /admin/api/students/{id} as the recovery hatch: clears the
participant + submissions, kicks active WS sockets so a stale cookie
cannot continue submitting. quiz.js surfaces the FastAPI detail message
in the join form so users see the 'already in use' guidance.
Presence panel
- New presence_update WS message; in-process presence map keyed on
student_id tracks ws_count + last_seen_ms. Admin dashboard renders
per-student rows: connected/idle/dropped dot, blur+hidden+duplicate
badges, 'answered current Q' tick, and a clear-student button.
Projector view (public, read-only)
- /projector/?sid=..., GET /api/session/{sid}/projector, WS
/ws/projector/{sid}. Single self-contained projector_state snapshot
pushed on every state change. Public leaderboard strips student_id;
QR rendered server-side as data: URL (CSP-compliant).
- Includes per-Q answer histogram, 8-bucket response-time distribution,
10-bucket score distribution.
- static/projector.{html,css,js}: editorial-broadside design — masthead,
registration crosses, conic-gradient countdown ring, SVG stepped-area
score distribution with median tick, leaderboard row-stagger. Inherits
light/dark tokens from style.css; honours prefers-reduced-motion. No
scroll at 1366x768 / 1920x1080 / 3440x1440.
Tests
- tests/test_anti_cheat.py: blur logging + CSV count, unknown-kind 422,
unauthenticated event 401, duplicate-join 409 + audit, admin
clear-student happy + 404, server-side submit lockout regression.
- tests/test_projector.py: snapshot shape, leaderboard student_id
redaction, WS push on state change, 404 for unknown sid, page redirect
when no sid.
- Existing tests updated for the new presence_update snapshot frame +
CSV header columns + first-claim-wins refusal of re-key.
57/57 pytest green; smoke-tested locally end-to-end.
This commit is contained in:
@@ -14,7 +14,10 @@ from app.config import Settings
|
||||
|
||||
STUDENT_COOKIE = "qz_student"
|
||||
ADMIN_COOKIE = "qz_admin"
|
||||
STUDENT_MAX_AGE = 31_536_000
|
||||
# 30 days. Long enough to cover a multi-week course where the same QR
|
||||
# may be re-used (lecture cadence is once a week), short enough that a
|
||||
# stolen-device cookie doesn't follow a graduate around for a year.
|
||||
STUDENT_MAX_AGE = 30 * 86_400
|
||||
ADMIN_MAX_AGE = 86_400
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,21 @@ 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"])
|
||||
writer.writerow(
|
||||
[
|
||||
"sid",
|
||||
"student_id",
|
||||
"name",
|
||||
"question_idx",
|
||||
"answer",
|
||||
"elapsed_ms",
|
||||
"score",
|
||||
"status",
|
||||
"blur_count",
|
||||
"hidden_count",
|
||||
"duplicate_join_attempts",
|
||||
]
|
||||
)
|
||||
async with connect(db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
@@ -24,7 +38,21 @@ async def export_session_csv(db_path: str, sid: str) -> str:
|
||||
(sid,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
events_cur = await db.execute(
|
||||
"""
|
||||
SELECT student_id, kind, COUNT(*) AS c
|
||||
FROM student_events
|
||||
WHERE sid = ? AND student_id IS NOT NULL
|
||||
GROUP BY student_id, kind
|
||||
""",
|
||||
(sid,),
|
||||
)
|
||||
events = await events_cur.fetchall()
|
||||
counts: dict[str, dict[str, int]] = {}
|
||||
for row in events:
|
||||
counts.setdefault(row["student_id"], {})[row["kind"]] = int(row["c"])
|
||||
for row in rows:
|
||||
per = counts.get(row["student_id"], {})
|
||||
writer.writerow(
|
||||
[
|
||||
row["sid"],
|
||||
@@ -35,6 +63,9 @@ async def export_session_csv(db_path: str, sid: str) -> str:
|
||||
"" if row["elapsed_ms"] is None else row["elapsed_ms"],
|
||||
"" if row["score"] is None else row["score"],
|
||||
row["status"] or "",
|
||||
per.get("blur", 0),
|
||||
per.get("visibility_hidden", 0),
|
||||
per.get("duplicate_join", 0),
|
||||
]
|
||||
)
|
||||
return out.getvalue()
|
||||
|
||||
19
app/db.py
19
app/db.py
@@ -60,6 +60,25 @@ CREATE TABLE IF NOT EXISTS submissions (
|
||||
|
||||
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);
|
||||
|
||||
-- Soft-anti-cheat audit + tab-blur trail. Append-only; the admin panel
|
||||
-- and CSV export aggregate per-student counts. Kinds in use:
|
||||
-- 'blur' — window blur during a question_open state
|
||||
-- 'visibility_hidden' — page tab/window backgrounded
|
||||
-- 'duplicate_join' — second-claim attempt on an already-claimed
|
||||
-- student_id; student_id field holds the
|
||||
-- ATTEMPTED id; detail JSON carries IP/UA/name
|
||||
CREATE TABLE IF NOT EXISTS student_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sid TEXT NOT NULL,
|
||||
student_id TEXT,
|
||||
question_idx INTEGER,
|
||||
kind TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_student_events_sid_student ON student_events(sid, student_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_student_events_sid_kind ON student_events(sid, kind);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -12,3 +12,10 @@ class JoinRequest(BaseModel):
|
||||
|
||||
class AdminLoginRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class StudentEventRequest(BaseModel):
|
||||
# Bounded set of event kinds — anything else returns 422 instead of
|
||||
# silently filling the audit log with junk.
|
||||
kind: str = Field(pattern=r"^(blur|focus|visibility_hidden|visibility_visible)$")
|
||||
question_idx: int | None = Field(default=None, ge=0, le=10_000)
|
||||
|
||||
408
app/room.py
408
app/room.py
@@ -3,11 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import aiosqlite
|
||||
import qrcode
|
||||
import qrcode.image.svg
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.config import Settings
|
||||
@@ -37,17 +42,36 @@ def parse_ts(value: str) -> datetime:
|
||||
return parsed
|
||||
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
class DuplicateStudentId(Exception):
|
||||
"""Raised when a join request targets a student_id that is already
|
||||
claimed by another active participant (first-claim-wins anti-hijack)."""
|
||||
|
||||
|
||||
class RoomManager:
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
self.student_clients: dict[str, dict[WebSocket, dict[str, Any]]] = defaultdict(dict)
|
||||
self.instructor_clients: dict[str, set[WebSocket]] = defaultdict(set)
|
||||
# Projector clients are public read-only; no per-client identity.
|
||||
self.projector_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
|
||||
# Volatile presence: presence[sid][student_id] = {"connected": bool,
|
||||
# "last_seen_ms": int, "ws_count": int}. Rebuilt on each WS connect
|
||||
# / disconnect; not persisted (presence dies with the process).
|
||||
self.presence: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict)
|
||||
|
||||
async def ensure_single_session(self, sid: str, pool: dict[str, Any]) -> None:
|
||||
"""Idempotently upsert the canonical single-session row + its quiz row.
|
||||
@@ -125,6 +149,7 @@ class RoomManager:
|
||||
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("DELETE FROM student_events WHERE sid = ?", (sid,))
|
||||
await db.execute(
|
||||
"UPDATE quiz_sessions SET state = 'lobby', current_question_idx = NULL, finished_at = NULL WHERE sid = ?",
|
||||
(sid,),
|
||||
@@ -143,8 +168,13 @@ class RoomManager:
|
||||
except Exception:
|
||||
pass
|
||||
self.student_clients.pop(sid, None)
|
||||
# Presence is volatile — wipe alongside the participant table so
|
||||
# the next instructor snapshot doesn't show stale ghost rows.
|
||||
self.presence.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)
|
||||
await self.broadcast_presence(sid)
|
||||
await self.broadcast_projectors(sid)
|
||||
|
||||
async def sessions_active(self) -> int:
|
||||
async with connect(self.settings.db_path) as db:
|
||||
@@ -153,20 +183,28 @@ class RoomManager:
|
||||
return int(row["count"])
|
||||
|
||||
def ws_client_count(self) -> int:
|
||||
return sum(len(clients) for clients in self.student_clients.values()) + sum(
|
||||
len(clients) for clients in self.instructor_clients.values()
|
||||
return (
|
||||
sum(len(clients) for clients in self.student_clients.values())
|
||||
+ sum(len(clients) for clients in self.instructor_clients.values())
|
||||
+ sum(len(clients) for clients in self.projector_clients.values())
|
||||
)
|
||||
|
||||
async def add_participant(self, sid: str, student_id: str, name: str, cookie_id: str) -> None:
|
||||
"""First-claim-wins. Raises DuplicateStudentId if this student_id
|
||||
is already in the participants table for this sid (an attempt to
|
||||
hijack another student's id, or a legit student returning after
|
||||
clearing cookies). The route handler turns the exception into a
|
||||
409 + records a `duplicate_join` audit event so the instructor
|
||||
can see the attempt on the live presence panel."""
|
||||
async with connect(self.settings.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO participants (sid, student_id, name, cookie_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(sid, student_id) DO UPDATE SET name = excluded.name, cookie_id = excluded.cookie_id
|
||||
""",
|
||||
(sid, student_id, name, cookie_id),
|
||||
)
|
||||
try:
|
||||
await db.execute(
|
||||
"INSERT INTO participants (sid, student_id, name, cookie_id) VALUES (?, ?, ?, ?)",
|
||||
(sid, student_id, name, cookie_id),
|
||||
)
|
||||
except aiosqlite.IntegrityError as exc:
|
||||
# PK violation = student_id already claimed in this sid.
|
||||
raise DuplicateStudentId(student_id) from exc
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO submissions (sid, student_id, question_idx, status, score)
|
||||
@@ -178,10 +216,75 @@ class RoomManager:
|
||||
)
|
||||
await db.commit()
|
||||
await self.broadcast_lobby(sid)
|
||||
await self.broadcast_presence(sid)
|
||||
await self.broadcast_projectors(sid)
|
||||
|
||||
async def log_event(
|
||||
self,
|
||||
sid: str,
|
||||
student_id: str | None,
|
||||
kind: str,
|
||||
question_idx: int | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
async with connect(self.settings.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO student_events (sid, student_id, question_idx, kind, detail)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(sid, student_id, question_idx, kind, json.dumps(detail) if detail else None),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def clear_student(self, sid: str, student_id: str) -> bool:
|
||||
"""Admin recovery hatch for first-claim-wins: remove a participant
|
||||
+ all their submissions so the legitimate student can re-claim
|
||||
their id. Returns True if a row was removed."""
|
||||
async with connect(self.settings.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM submissions WHERE sid = ? AND student_id = ?",
|
||||
(sid, student_id),
|
||||
)
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM participants WHERE sid = ? AND student_id = ?",
|
||||
(sid, student_id),
|
||||
)
|
||||
removed = cursor.rowcount > 0
|
||||
await db.commit()
|
||||
if removed:
|
||||
self.presence.get(sid, {}).pop(student_id, None)
|
||||
# Kick any active WS for this student_id so a stale cookie can
|
||||
# no longer drive submissions. /me will 401 (cookie cleared)
|
||||
# and the page will land on the join form.
|
||||
for ws, ident in list(self.student_clients.get(sid, {}).items()):
|
||||
if ident.get("student_id") == student_id:
|
||||
try:
|
||||
await ws.send_json({"type": "session_reset"})
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await ws.close(code=4002)
|
||||
except Exception:
|
||||
pass
|
||||
await self.broadcast_lobby(sid)
|
||||
await self.broadcast_presence(sid)
|
||||
await self.broadcast_projectors(sid)
|
||||
return removed
|
||||
|
||||
async def student_ws(self, websocket: WebSocket, sid: str, identity: dict[str, Any]) -> None:
|
||||
await websocket.accept()
|
||||
self.student_clients[sid][websocket] = identity
|
||||
student_id = identity["student_id"]
|
||||
slot = self.presence[sid].setdefault(
|
||||
student_id,
|
||||
{"connected": False, "last_seen_ms": now_ms(), "ws_count": 0, "name": identity.get("name", "")},
|
||||
)
|
||||
slot["ws_count"] += 1
|
||||
slot["connected"] = True
|
||||
slot["last_seen_ms"] = now_ms()
|
||||
slot["name"] = identity.get("name", slot.get("name", ""))
|
||||
await self.broadcast_presence(sid)
|
||||
try:
|
||||
await self.send_student_snapshot(websocket, sid, identity)
|
||||
while True:
|
||||
@@ -211,6 +314,12 @@ class RoomManager:
|
||||
pass
|
||||
finally:
|
||||
self.student_clients[sid].pop(websocket, None)
|
||||
slot = self.presence.get(sid, {}).get(student_id)
|
||||
if slot:
|
||||
slot["ws_count"] = max(0, slot.get("ws_count", 1) - 1)
|
||||
slot["connected"] = slot["ws_count"] > 0
|
||||
slot["last_seen_ms"] = now_ms()
|
||||
await self.broadcast_presence(sid)
|
||||
|
||||
async def instructor_ws(self, websocket: WebSocket, sid: str) -> None:
|
||||
await websocket.accept()
|
||||
@@ -290,6 +399,7 @@ class RoomManager:
|
||||
}
|
||||
)
|
||||
await websocket.send_json(await self.lobby_message(sid))
|
||||
await websocket.send_json(await self.presence_message(sid))
|
||||
# When an instructor reconnects mid-session, replay enough payloads
|
||||
# for the SPA to render the current state without waiting for the
|
||||
# next event. Otherwise the dashboard sits on a "Reveal pending..."
|
||||
@@ -337,6 +447,7 @@ class RoomManager:
|
||||
await self.broadcast_students(sid, msg)
|
||||
await self.broadcast_instructors(sid, msg)
|
||||
await self.broadcast_instructors(sid, await self.live_histogram_message(sid, question_idx))
|
||||
await self.broadcast_projectors(sid)
|
||||
|
||||
async def close_question(self, sid: str) -> None:
|
||||
async with self.locks[sid]:
|
||||
@@ -434,6 +545,8 @@ class RoomManager:
|
||||
)
|
||||
await db.commit()
|
||||
await self.broadcast_instructors(sid, await self.live_histogram_message(sid, qidx))
|
||||
await self.broadcast_presence(sid)
|
||||
await self.broadcast_projectors(sid)
|
||||
return {"type": "submit_ack", "question_idx": qidx, "answer": answer, "score": score, "elapsed_ms": elapsed_ms}
|
||||
|
||||
def _schedule_autoclose(self, sid: str, question_idx: int, time_limit: int) -> None:
|
||||
@@ -682,6 +795,278 @@ class RoomManager:
|
||||
participants = [dict(row) for row in rows]
|
||||
return {"type": "lobby_update", "participants": participants, "count": len(participants)}
|
||||
|
||||
async def presence_message(self, sid: str) -> dict[str, Any]:
|
||||
"""Per-student live presence: connected/idle, last_seen, blur+
|
||||
visibility-hidden counts, current-question-answered flag, and
|
||||
any duplicate-join attempts on that id. Broadcast to the
|
||||
instructor on every connect / disconnect / join / answer."""
|
||||
async with connect(self.settings.db_path) as db:
|
||||
participants_cur = await db.execute(
|
||||
"SELECT student_id, name, joined_at FROM participants WHERE sid = ? ORDER BY joined_at, name",
|
||||
(sid,),
|
||||
)
|
||||
participants = await participants_cur.fetchall()
|
||||
session_cur = await db.execute(
|
||||
"SELECT state, current_question_idx FROM quiz_sessions WHERE sid = ?",
|
||||
(sid,),
|
||||
)
|
||||
session_row = await session_cur.fetchone()
|
||||
events_cur = await db.execute(
|
||||
"""
|
||||
SELECT student_id, kind, COUNT(*) AS count
|
||||
FROM student_events
|
||||
WHERE sid = ? AND student_id IS NOT NULL
|
||||
GROUP BY student_id, kind
|
||||
""",
|
||||
(sid,),
|
||||
)
|
||||
event_rows = await events_cur.fetchall()
|
||||
current_idx = session_row["current_question_idx"] if session_row else None
|
||||
answered_now: set[str] = set()
|
||||
if current_idx is not None:
|
||||
ans_cur = await db.execute(
|
||||
"""
|
||||
SELECT student_id FROM submissions
|
||||
WHERE sid = ? AND question_idx = ? AND status = 'submitted'
|
||||
""",
|
||||
(sid, current_idx),
|
||||
)
|
||||
answered_now = {row["student_id"] for row in await ans_cur.fetchall()}
|
||||
# Duplicate-join attempts (any student_id touched by an
|
||||
# event whose kind=duplicate_join). For attempts on an
|
||||
# existing student_id we want to surface to the legit owner.
|
||||
dup_cur = await db.execute(
|
||||
"""
|
||||
SELECT student_id, COUNT(*) AS count, MAX(ts) AS latest_ts, MAX(detail) AS latest_detail
|
||||
FROM student_events
|
||||
WHERE sid = ? AND kind = 'duplicate_join' AND student_id IS NOT NULL
|
||||
GROUP BY student_id
|
||||
""",
|
||||
(sid,),
|
||||
)
|
||||
dup_rows = await dup_cur.fetchall()
|
||||
events_by_student: dict[str, dict[str, int]] = defaultdict(dict)
|
||||
for row in event_rows:
|
||||
events_by_student[row["student_id"]][row["kind"]] = int(row["count"])
|
||||
dup_by_student = {
|
||||
row["student_id"]: {
|
||||
"count": int(row["count"]),
|
||||
"latest_ts": row["latest_ts"],
|
||||
"latest_detail": row["latest_detail"],
|
||||
}
|
||||
for row in dup_rows
|
||||
}
|
||||
rows: list[dict[str, Any]] = []
|
||||
for participant in participants:
|
||||
sid_id = participant["student_id"]
|
||||
slot = self.presence.get(sid, {}).get(sid_id, {})
|
||||
counts = events_by_student.get(sid_id, {})
|
||||
rows.append(
|
||||
{
|
||||
"student_id": sid_id,
|
||||
"name": participant["name"],
|
||||
"joined_at": participant["joined_at"],
|
||||
"connected": bool(slot.get("connected")),
|
||||
"ws_count": int(slot.get("ws_count", 0)),
|
||||
"last_seen_ms": int(slot.get("last_seen_ms", 0)) or None,
|
||||
"blur_count": int(counts.get("blur", 0)),
|
||||
"hidden_count": int(counts.get("visibility_hidden", 0)),
|
||||
"duplicate_join_attempts": dup_by_student.get(sid_id, {"count": 0}),
|
||||
"answered_current": sid_id in answered_now,
|
||||
}
|
||||
)
|
||||
# Orphan duplicate-join attempts: an attempt on a student_id that
|
||||
# has not yet been claimed by a real participant. Surface as a
|
||||
# separate list so the instructor can see "someone tried to join
|
||||
# as 12345 but nobody named 12345 has joined yet".
|
||||
orphan_attempts = [
|
||||
{"student_id": sid_id, **info}
|
||||
for sid_id, info in dup_by_student.items()
|
||||
if not any(p["student_id"] == sid_id for p in participants)
|
||||
]
|
||||
return {
|
||||
"type": "presence_update",
|
||||
"current_question_idx": current_idx,
|
||||
"rows": rows,
|
||||
"orphan_duplicate_joins": orphan_attempts,
|
||||
}
|
||||
|
||||
async def broadcast_presence(self, sid: str) -> None:
|
||||
await self.broadcast_instructors(sid, await self.presence_message(sid))
|
||||
|
||||
# ---- Projector (public big-screen view) -------------------------------
|
||||
|
||||
async def projector_snapshot(self, sid: str) -> dict[str, Any]:
|
||||
"""Self-contained read-only payload for the projector page. No
|
||||
student_ids; only aggregate distributions and the public top-N
|
||||
leaderboard. Sent on initial GET + every WS state change."""
|
||||
session = await self.get_session(sid)
|
||||
pool = await self.get_pool_for_session(sid)
|
||||
state = session["state"]
|
||||
current_idx = session["current_question_idx"]
|
||||
title = session["title"]
|
||||
join_url = f"{self.settings.public_url}/?sid={sid}"
|
||||
qr_url = _qr_data_url(join_url)
|
||||
async with connect(self.settings.db_path) as db:
|
||||
part_cur = await db.execute(
|
||||
"SELECT COUNT(*) AS count FROM participants WHERE sid = ?", (sid,)
|
||||
)
|
||||
participant_count = int((await part_cur.fetchone())["count"])
|
||||
question_block: dict[str, Any] | None = None
|
||||
live_histogram: dict[str, Any] | None = None
|
||||
reveal: dict[str, Any] | None = None
|
||||
response_time_distribution: dict[str, Any] | None = None
|
||||
if current_idx is not None and state in {"question_open", "question_closed"}:
|
||||
question = get_question(pool, int(current_idx))
|
||||
event = await self.get_question_event(sid, int(current_idx))
|
||||
opened_ms = int(parse_ts(event["opened_at"]).timestamp() * 1000)
|
||||
time_limit_s = int(event["time_limit"])
|
||||
remaining_ms = max(0, opened_ms + time_limit_s * 1000 - now_ms()) if state == "question_open" else 0
|
||||
question_block = {
|
||||
"idx": int(current_idx),
|
||||
"text": question["text"],
|
||||
"options": question["options"],
|
||||
"opened_at_server_ts": opened_ms,
|
||||
"time_limit": time_limit_s,
|
||||
"remaining_ms": remaining_ms,
|
||||
"total_questions": question_count(pool),
|
||||
}
|
||||
histogram = await self.histogram(sid, int(current_idx), pending=True)
|
||||
submitted = histogram["A"] + histogram["B"] + histogram["C"] + histogram["D"]
|
||||
live_histogram = {
|
||||
"counts": histogram,
|
||||
"submitted_count": submitted,
|
||||
"total_count": submitted + histogram["missed"] + histogram.get("pending", 0),
|
||||
}
|
||||
response_time_distribution = await self._response_time_buckets(sid, int(current_idx), time_limit_s)
|
||||
if state == "question_closed":
|
||||
reveal = {
|
||||
"correct": question["correct"],
|
||||
"explanation": question.get("explanation", ""),
|
||||
}
|
||||
leaderboard = await self.leaderboard(sid, limit=10)
|
||||
# Strip student_ids from the public leaderboard. The instructor
|
||||
# /admin board still has them via include_ids=True.
|
||||
public_leaderboard = [
|
||||
{"rank": row["rank"], "name": row["name"], "score": row["score"]}
|
||||
for row in leaderboard
|
||||
]
|
||||
score_distribution = await self._score_distribution(sid, question_count(pool))
|
||||
return {
|
||||
"type": "projector_state",
|
||||
"sid": sid,
|
||||
"state": state,
|
||||
"title": title,
|
||||
"join_url": join_url,
|
||||
"qr_url": qr_url,
|
||||
"participant_count": participant_count,
|
||||
"pool_meta": {
|
||||
"question_count": question_count(pool),
|
||||
"time_limit_default": pool["time_limit_default"],
|
||||
"score_fn": pool["score_fn"],
|
||||
},
|
||||
"question": question_block,
|
||||
"live_histogram": live_histogram,
|
||||
"reveal": reveal,
|
||||
"response_time_distribution": response_time_distribution,
|
||||
"score_distribution": score_distribution,
|
||||
"leaderboard": public_leaderboard,
|
||||
"server_ts": now_ms(),
|
||||
}
|
||||
|
||||
async def _response_time_buckets(self, sid: str, question_idx: int, time_limit_s: int) -> dict[str, Any]:
|
||||
# Bucket elapsed-ms into 8 equal-width bins from 0..time_limit_s.
|
||||
# Bins are {"label": "0-7s", "count": N, "is_correct_avg": 0..1}.
|
||||
async with connect(self.settings.db_path) as db:
|
||||
cur = await db.execute(
|
||||
"""
|
||||
SELECT s.elapsed_ms, s.answer
|
||||
FROM submissions s
|
||||
WHERE s.sid = ? AND s.question_idx = ? AND s.status = 'submitted' AND s.elapsed_ms IS NOT NULL
|
||||
""",
|
||||
(sid, question_idx),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
bins = 8
|
||||
if time_limit_s <= 0:
|
||||
time_limit_s = 60
|
||||
edge_ms = (time_limit_s * 1000) / bins
|
||||
buckets = [{"label": "", "count": 0} for _ in range(bins)]
|
||||
for i in range(bins):
|
||||
lo = round(edge_ms * i / 1000)
|
||||
hi = round(edge_ms * (i + 1) / 1000)
|
||||
buckets[i]["label"] = f"{lo}-{hi}s"
|
||||
for row in rows:
|
||||
ms = int(row["elapsed_ms"])
|
||||
idx = min(bins - 1, max(0, int(ms // edge_ms)))
|
||||
buckets[idx]["count"] += 1
|
||||
total = sum(b["count"] for b in buckets)
|
||||
return {"buckets": buckets, "total": total}
|
||||
|
||||
async def _score_distribution(self, sid: str, question_count_total: int) -> dict[str, Any]:
|
||||
"""Histogram of per-student total scores. Bins are 10% of the
|
||||
max-possible total (so every quiz lands on a 10-bucket axis
|
||||
regardless of question count)."""
|
||||
async with connect(self.settings.db_path) as db:
|
||||
cur = await db.execute(
|
||||
"""
|
||||
SELECT p.student_id, COALESCE(SUM(s.score), 0) AS total
|
||||
FROM participants p
|
||||
LEFT JOIN submissions s ON s.sid = p.sid AND s.student_id = p.student_id
|
||||
WHERE p.sid = ?
|
||||
GROUP BY p.student_id
|
||||
""",
|
||||
(sid,),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
max_total = max(1, question_count_total)
|
||||
bins = 10
|
||||
edge = max_total / bins
|
||||
buckets = [{"label": "", "count": 0} for _ in range(bins)]
|
||||
for i in range(bins):
|
||||
lo = round(edge * i, 1)
|
||||
hi = round(edge * (i + 1), 1)
|
||||
buckets[i]["label"] = f"{lo}-{hi}"
|
||||
for row in rows:
|
||||
total = float(row["total"])
|
||||
idx = min(bins - 1, max(0, int(total // edge))) if edge > 0 else 0
|
||||
buckets[idx]["count"] += 1
|
||||
return {"buckets": buckets, "max_total": max_total, "n": len(rows)}
|
||||
|
||||
async def projector_ws(self, websocket: WebSocket, sid: str) -> None:
|
||||
await websocket.accept()
|
||||
self.projector_clients[sid].add(websocket)
|
||||
try:
|
||||
await websocket.send_json(await self.projector_snapshot(sid))
|
||||
while True:
|
||||
# Projector is read-only; we just keep the socket open and
|
||||
# accept ping/keepalive messages so reverse proxies don't
|
||||
# idle the connection out.
|
||||
try:
|
||||
data = await websocket.receive_json()
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(data, dict) and data.get("type") == "ping":
|
||||
try:
|
||||
await websocket.send_json({"type": "pong"})
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
break
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
pass
|
||||
finally:
|
||||
self.projector_clients[sid].discard(websocket)
|
||||
|
||||
async def broadcast_projectors(self, sid: str) -> None:
|
||||
if not self.projector_clients.get(sid):
|
||||
return
|
||||
try:
|
||||
snapshot = await self.projector_snapshot(sid)
|
||||
except Exception:
|
||||
return
|
||||
for ws in list(self.projector_clients[sid]):
|
||||
self._queue_send(ws, snapshot)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def live_histogram_message(self, sid: str, question_idx: int) -> dict[str, Any]:
|
||||
histogram = await self.histogram(sid, question_idx, pending=True)
|
||||
submitted_count = histogram["A"] + histogram["B"] + histogram["C"] + histogram["D"]
|
||||
@@ -768,6 +1153,7 @@ class RoomManager:
|
||||
self._queue_send(websocket, await self.question_closed_message(sid, question_idx, identity))
|
||||
await self.broadcast_instructors(sid, await self.question_closed_message(sid, question_idx))
|
||||
await self.broadcast_instructors(sid, await self.full_leaderboard_message(sid))
|
||||
await self.broadcast_projectors(sid)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def broadcast_between_questions(self, sid: str, next_idx: int) -> None:
|
||||
@@ -775,12 +1161,14 @@ class RoomManager:
|
||||
self._queue_send(websocket, await self.between_message(sid, next_idx, identity))
|
||||
await self.broadcast_instructors(sid, await self.between_message(sid, next_idx))
|
||||
await self.broadcast_instructors(sid, await self.full_leaderboard_message(sid))
|
||||
await self.broadcast_projectors(sid)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def broadcast_session_ended(self, sid: str) -> None:
|
||||
for websocket, identity in list(self.student_clients[sid].items()):
|
||||
self._queue_send(websocket, await self.ended_message(sid, identity))
|
||||
await self.broadcast_instructors(sid, await self.ended_message(sid))
|
||||
await self.broadcast_projectors(sid)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def broadcast_students(self, sid: str, message: dict[str, Any]) -> None:
|
||||
|
||||
@@ -9,12 +9,8 @@ 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
|
||||
|
||||
@@ -23,7 +19,7 @@ from app.config import Settings
|
||||
from app.csv_export import export_session_csv
|
||||
from app.models import AdminLoginRequest
|
||||
from app.rate_limit import TokenBucket, client_ip
|
||||
from app.room import RoomManager
|
||||
from app.room import RoomManager, _qr_data_url
|
||||
|
||||
|
||||
def router(settings: Settings, rooms: RoomManager) -> APIRouter:
|
||||
@@ -94,6 +90,22 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter:
|
||||
await rooms.reset(sid)
|
||||
return {"ok": True}
|
||||
|
||||
@api.delete("/admin/api/students/{student_id}")
|
||||
async def admin_clear_student(student_id: str, request: Request):
|
||||
# Recovery hatch for first-claim-wins: if a student lost their
|
||||
# cookie or their id was hijacked, the instructor can free the
|
||||
# slot here. Removes the participant + all of their submissions
|
||||
# and kicks any active WS for that id; the legitimate student
|
||||
# then re-joins via the normal flow.
|
||||
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")
|
||||
removed = await rooms.clear_student(sid, student_id)
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail="No such student in session")
|
||||
return {"ok": True}
|
||||
|
||||
@api.get("/admin/api/csv")
|
||||
async def csv_download(request: Request):
|
||||
require_admin(request)
|
||||
@@ -115,11 +127,3 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter:
|
||||
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}"
|
||||
|
||||
@@ -10,8 +10,9 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Redirect
|
||||
|
||||
from app import auth
|
||||
from app.config import Settings
|
||||
from app.models import JoinRequest
|
||||
from app.room import RoomManager
|
||||
from app.models import JoinRequest, StudentEventRequest
|
||||
from app.rate_limit import client_ip
|
||||
from app.room import DuplicateStudentId, RoomManager
|
||||
|
||||
|
||||
def router(settings: Settings, rooms: RoomManager) -> APIRouter:
|
||||
@@ -54,17 +55,66 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter:
|
||||
}
|
||||
|
||||
@api.post("/api/session/{sid}/join")
|
||||
async def join_session(sid: str, body: JoinRequest, response: Response):
|
||||
async def join_session(sid: str, body: JoinRequest, request: Request, 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())
|
||||
try:
|
||||
await rooms.add_participant(sid, student_id, name, cookie_id)
|
||||
except DuplicateStudentId:
|
||||
# First-claim-wins anti-hijack: a participant row already
|
||||
# exists for this student_id. Could be a hijack attempt
|
||||
# OR a legit student returning after clearing cookies. Log
|
||||
# the attempt with IP/UA/attempted-name so the instructor
|
||||
# can surface it on the live presence panel and decide.
|
||||
await rooms.log_event(
|
||||
sid,
|
||||
student_id=student_id,
|
||||
kind="duplicate_join",
|
||||
detail={
|
||||
"attempted_name": name,
|
||||
"ip": client_ip(request),
|
||||
"ua": (request.headers.get("user-agent") or "")[:200],
|
||||
},
|
||||
)
|
||||
await rooms.broadcast_presence(sid)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"This student ID is already in use. If this is your ID, "
|
||||
"ask your instructor to clear it for you."
|
||||
),
|
||||
) from None
|
||||
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.post("/api/session/{sid}/event")
|
||||
async def post_event(sid: str, body: StudentEventRequest, request: Request):
|
||||
# Audit-only endpoint: the student page POSTs here on tab blur
|
||||
# / visibility-hidden so the instructor can see engagement
|
||||
# signals during a live question. No state change.
|
||||
if not await rooms.session_exists(sid):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
identity = auth.get_student_identity(settings, request, sid)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Student cookie required")
|
||||
await rooms.log_event(
|
||||
sid,
|
||||
student_id=identity["student_id"],
|
||||
kind=body.kind,
|
||||
question_idx=body.question_idx,
|
||||
detail={"ip": client_ip(request)},
|
||||
)
|
||||
# blur / visibility_hidden are surfaced to the instructor; focus /
|
||||
# visibility_visible are recorded for completeness but don't need
|
||||
# an immediate broadcast.
|
||||
if body.kind in {"blur", "visibility_hidden"}:
|
||||
await rooms.broadcast_presence(sid)
|
||||
return {"ok": True}
|
||||
|
||||
@api.get("/api/session/{sid}/me")
|
||||
async def me(sid: str, request: Request):
|
||||
identity = auth.get_student_identity(settings, request, sid)
|
||||
@@ -97,4 +147,42 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter:
|
||||
return
|
||||
await rooms.student_ws(websocket, sid, identity)
|
||||
|
||||
# ---- Projector view (public, read-only) -------------------------------
|
||||
# The projector page runs at the front of the room on a smart TV / big
|
||||
# screen. No auth: it shows only aggregate / leaderboard data that
|
||||
# would already be visible on the student's own screen at reveal
|
||||
# time. Per-student histograms keep names but redact student_ids
|
||||
# (the student-id namespace is private).
|
||||
|
||||
@api.get("/projector/")
|
||||
async def projector_page(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>Projector — quiz unavailable</title>"
|
||||
"<main class='centered-shell'><div class='card narrow'>"
|
||||
"<h1>Projector — no live session</h1>"
|
||||
"<p class='muted'>Start the quiz from the admin dashboard.</p>"
|
||||
"</div></main>",
|
||||
status_code=404,
|
||||
)
|
||||
if not sid:
|
||||
return RedirectResponse(url=f"/projector/?sid={target_sid}", status_code=302)
|
||||
return FileResponse(Path("static/projector.html"))
|
||||
|
||||
@api.get("/api/session/{sid}/projector")
|
||||
async def projector_state(sid: str):
|
||||
if not await rooms.session_exists(sid):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return await rooms.projector_snapshot(sid)
|
||||
|
||||
@api.websocket("/ws/projector/{sid}")
|
||||
async def projector_socket(websocket: WebSocket, sid: str):
|
||||
if not await rooms.session_exists(sid):
|
||||
await websocket.close(code=4001)
|
||||
return
|
||||
await rooms.projector_ws(websocket, sid)
|
||||
|
||||
return api
|
||||
|
||||
Reference in New Issue
Block a user