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
|
||||
|
||||
@@ -1,4 +1,40 @@
|
||||
__DOMAIN__ {
|
||||
encode gzip
|
||||
reverse_proxy 127.0.0.1:8001
|
||||
|
||||
# Cap request bodies. Pool JSON is the largest legitimate payload and
|
||||
# tops out well under 1 MiB; cap at 1 MiB so abusive uploads (large
|
||||
# blobs to /admin/api/* or pathological websocket frames pretending to
|
||||
# be HTTP) get rejected at the edge before reaching uvicorn.
|
||||
request_body {
|
||||
max_size 1MB
|
||||
}
|
||||
|
||||
# /admin/login is rate-limited at the app layer (rate_limit.py:
|
||||
# 10/min/IP). A Caddy-edge limiter would be defense in depth, but
|
||||
# would require the non-stock `caddy-ratelimit` plugin; we keep this
|
||||
# bootstrap stock-Caddy-compatible.
|
||||
|
||||
# Security headers. CSP allows Google Fonts (used by style.css) and
|
||||
# WebSocket back to the same origin; everything else is self-only.
|
||||
# X-Frame-Options DENY prevents clickjacking the admin into an iframe.
|
||||
# HSTS pin (1y, includeSubDomains, preload) so once a browser has
|
||||
# talked HTTPS to this host it refuses HTTP downgrades; safe because
|
||||
# the host is HTTPS-only.
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()"
|
||||
Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self'; connect-src 'self' wss://__DOMAIN__ ws://__DOMAIN__; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
# Server header leaks Caddy version; strip it.
|
||||
-Server
|
||||
}
|
||||
|
||||
reverse_proxy 127.0.0.1:8001 {
|
||||
# Pass real client IP downstream so app-layer rate-limit + audit
|
||||
# logs see the actual student IP (not 127.0.0.1).
|
||||
header_up X-Forwarded-For {http.request.remote.host}
|
||||
header_up X-Real-IP {http.request.remote.host}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@ if ! id "$APP_USER" >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
stage "5/10: clone or update repo into $APP_DIR"
|
||||
# safe.directory mark for the root-run git ops: on a re-bootstrap the
|
||||
# repo is owned by $APP_USER (set on the previous run), and modern git
|
||||
# refuses cross-user operations without this marker.
|
||||
git config --global --add safe.directory "$APP_DIR" 2>/dev/null || true
|
||||
if [ -d "$APP_DIR/.git" ]; then
|
||||
git -C "$APP_DIR" fetch origin
|
||||
git -C "$APP_DIR" reset --hard "origin/$BRANCH"
|
||||
|
||||
126
static/admin.js
126
static/admin.js
@@ -15,6 +15,9 @@ const store = {
|
||||
session: null, // /admin/api/state response
|
||||
ws: null,
|
||||
roster: [],
|
||||
presence: [], // presence_update.rows — richer than roster
|
||||
orphanDuplicates: [], // presence_update.orphan_duplicate_joins
|
||||
currentQIdx: null, // tracked for "answered current?" rendering
|
||||
currentQuestion: null,
|
||||
histogram: null,
|
||||
totalCount: 0,
|
||||
@@ -130,7 +133,10 @@ function renderLogin(error = null) {
|
||||
function renderDashboard() {
|
||||
const session = store.session;
|
||||
if (!session) return;
|
||||
const state = store.endedPayload ? "finished" : (store.closedPayload?.state || session.state);
|
||||
// state derives from session (server-authoritative); endedPayload short-
|
||||
// circuits to "finished" for the post-final render where we may not
|
||||
// have re-fetched session.state yet.
|
||||
const state = store.endedPayload ? "finished" : session.state;
|
||||
app.innerHTML = `
|
||||
<header class="topbar">
|
||||
<div class="topbar-title">
|
||||
@@ -143,10 +149,11 @@ function renderDashboard() {
|
||||
</div>
|
||||
</header>
|
||||
${store.notice ? `<div class="alert info">${escapeText(store.notice)}</div>` : ""}
|
||||
${renderDuplicateJoinAlerts()}
|
||||
<section class="dashboard">
|
||||
<aside class="dashboard-side">
|
||||
${renderJoinPanel()}
|
||||
${renderRosterPanel()}
|
||||
${renderPresencePanel()}
|
||||
</aside>
|
||||
<main class="dashboard-main">
|
||||
${renderStatePanel(state)}
|
||||
@@ -155,6 +162,7 @@ function renderDashboard() {
|
||||
`;
|
||||
document.querySelector("#logout-btn").addEventListener("click", logout);
|
||||
bindStateActions();
|
||||
bindPresenceActions();
|
||||
if (state === "question_open") startCountdown();
|
||||
}
|
||||
|
||||
@@ -183,20 +191,84 @@ function renderJoinPanel() {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRosterPanel() {
|
||||
const r = store.roster || [];
|
||||
// Newest-first so late joiners are visible at the top of the list. The
|
||||
// first three are tagged so the CSS can warm their dot — gives the
|
||||
// operator a quick "yes the room is live" cue without an explicit log.
|
||||
const ordered = r.slice().reverse();
|
||||
function renderPresencePanel() {
|
||||
const presence = store.presence || [];
|
||||
const rosterCount = (store.roster || []).length;
|
||||
const connected = presence.filter((p) => p.connected).length;
|
||||
const idleStaleMs = 30_000;
|
||||
const now = Date.now();
|
||||
// Newest-first so late joiners stay visible at the top.
|
||||
const ordered = presence.slice().reverse();
|
||||
if (!ordered.length) {
|
||||
return `
|
||||
<div class="card panel">
|
||||
<h2>Joined <span class="count">${rosterCount}</span></h2>
|
||||
<p class="muted">No students have joined yet. Share the QR or URL.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const currentQIdx = store.currentQIdx ?? store.session?.current_question_idx;
|
||||
const isQuestionOpen = currentQIdx != null && store.session?.state === "question_open";
|
||||
return `
|
||||
<div class="card panel">
|
||||
<h2>Joined <span class="count">${r.length}</span></h2>
|
||||
${ordered.length
|
||||
? `<ul class="roster">${ordered.map((p, i) =>
|
||||
`<li class="${i < 3 ? "is-fresh" : ""}"><span class="dot"></span><span class="who"><b>${escapeText(p.name)}</b><small>${escapeText(p.student_id)}</small></span></li>`
|
||||
).join("")}</ul>`
|
||||
: `<p class="muted">No students have joined yet. Share the QR or URL.</p>`}
|
||||
<div class="card panel presence-panel">
|
||||
<h2>Presence <span class="count">${connected}/${presence.length}</span></h2>
|
||||
<ul class="presence-list">
|
||||
${ordered.map((row, i) => {
|
||||
const lastSeen = row.last_seen_ms || 0;
|
||||
const stale = !row.connected && lastSeen && (now - lastSeen) > idleStaleMs;
|
||||
const dotState = row.connected ? "is-online" : (stale ? "is-stale" : "is-offline");
|
||||
const blur = row.blur_count || 0;
|
||||
const hidden = row.hidden_count || 0;
|
||||
const dupCount = row.duplicate_join_attempts?.count || 0;
|
||||
const answered = row.answered_current;
|
||||
const fresh = i < 3 && row.connected ? "is-fresh" : "";
|
||||
return `
|
||||
<li class="presence-row ${dotState} ${fresh}" data-student-id="${escapeText(row.student_id)}">
|
||||
<span class="dot" title="${row.connected ? "Connected" : "Disconnected"}"></span>
|
||||
<span class="who">
|
||||
<b>${escapeText(row.name)}</b>
|
||||
<small>${escapeText(row.student_id)}</small>
|
||||
</span>
|
||||
<span class="presence-flags">
|
||||
${isQuestionOpen
|
||||
? `<span class="flag ${answered ? "flag-ok" : "flag-pending"}" title="${answered ? "Answered current question" : "Has not answered current question"}">${answered ? "✓" : "·"}</span>`
|
||||
: ""}
|
||||
${blur > 0 ? `<span class="flag flag-warn" title="Tab blur events">${blur}↗</span>` : ""}
|
||||
${hidden > 0 ? `<span class="flag flag-warn" title="Tab hidden events">${hidden}◌</span>` : ""}
|
||||
${dupCount > 0 ? `<span class="flag flag-danger" title="Duplicate-join attempts">!${dupCount}</span>` : ""}
|
||||
</span>
|
||||
<button class="btn ghost xtiny" data-clear-student="${escapeText(row.student_id)}" title="Remove this student so they can re-join (recovery for hijack / lost cookie)">×</button>
|
||||
</li>
|
||||
`;
|
||||
}).join("")}
|
||||
</ul>
|
||||
<p class="muted xsmall">
|
||||
<span class="legend-dot is-online"></span> connected
|
||||
<span class="legend-dot is-stale"></span> idle
|
||||
<span class="legend-dot is-offline"></span> dropped
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDuplicateJoinAlerts() {
|
||||
const orphans = store.orphanDuplicates || [];
|
||||
if (!orphans.length) return "";
|
||||
// An orphan attempt is a duplicate-join on a student_id that no real
|
||||
// participant currently holds — surface separately because it suggests
|
||||
// someone is probing student_ids that aren't even claimed yet.
|
||||
return `
|
||||
<div class="alert error duplicate-alerts">
|
||||
<h2 class="alert-title">Suspicious join attempts</h2>
|
||||
<ul class="dup-list">
|
||||
${orphans.map((o) => `
|
||||
<li>
|
||||
<code>${escapeText(o.student_id)}</code>
|
||||
<span class="muted small">${o.count}× attempted${o.latest_ts ? ` · last ${escapeText(o.latest_ts)}` : ""}</span>
|
||||
</li>
|
||||
`).join("")}
|
||||
</ul>
|
||||
<p class="muted small">No real participant holds these IDs yet. If a student claims one of them and asks for help, you can clear it from the presence list.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -399,6 +471,24 @@ function bindStateActions() {
|
||||
if (copy) copy.addEventListener("click", copyJoinUrl);
|
||||
}
|
||||
|
||||
function bindPresenceActions() {
|
||||
document.querySelectorAll("[data-clear-student]").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const studentId = btn.dataset.clearStudent;
|
||||
if (!studentId) return;
|
||||
if (!confirm(`Clear ${studentId}? Their submissions and presence row will be removed; they can then re-join with the same ID.`)) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api(`/admin/api/students/${encodeURIComponent(studentId)}`, { method: "DELETE" });
|
||||
} catch (err) {
|
||||
alert(err.message || "Could not clear student.");
|
||||
btn.disabled = false;
|
||||
}
|
||||
// Server pushes presence_update so the row will disappear naturally.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function onAction(action, btn) {
|
||||
if (action === "reset") {
|
||||
if (!confirm("Reset clears all participants and submissions. Continue?")) return;
|
||||
@@ -502,6 +592,12 @@ function handleWSMessage(message) {
|
||||
store.roster = message.participants || [];
|
||||
renderDashboard();
|
||||
break;
|
||||
case "presence_update":
|
||||
store.presence = message.rows || [];
|
||||
store.orphanDuplicates = message.orphan_duplicate_joins || [];
|
||||
store.currentQIdx = message.current_question_idx ?? null;
|
||||
renderDashboard();
|
||||
break;
|
||||
case "question_open":
|
||||
store.session.state = "question_open";
|
||||
store.session.current_question_idx = message.question_idx;
|
||||
|
||||
1204
static/projector.css
Normal file
1204
static/projector.css
Normal file
File diff suppressed because it is too large
Load Diff
16
static/projector.html
Normal file
16
static/projector.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Quiz — Projector</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/projector.css">
|
||||
</head>
|
||||
<body class="projector-body">
|
||||
<main id="projector-app" aria-live="polite">
|
||||
<div class="bootstrap-loading">Loading projector</div>
|
||||
</main>
|
||||
<script type="module" src="/static/projector.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
676
static/projector.js
Normal file
676
static/projector.js
Normal file
@@ -0,0 +1,676 @@
|
||||
/* ============================================================
|
||||
* Projector view — front-of-room display.
|
||||
*
|
||||
* Read-only public WS client. The server is authoritative; we only
|
||||
* receive `projector_state` snapshots and render them. There are no
|
||||
* outbound mutations, no auth, no cookies.
|
||||
*
|
||||
* The projector is intentionally one-shot per state change: a render
|
||||
* blows away `#projector-app` and re-builds it, except for two hot
|
||||
* paths that need partial updates:
|
||||
* 1) the countdown ring ticks at 4Hz (computed from deadline),
|
||||
* 2) the lobby participant counter bumps on increment without
|
||||
* rebuilding the whole lobby.
|
||||
*
|
||||
* Layout intent: one screen, no scroll, big-screen typography.
|
||||
* ============================================================ */
|
||||
|
||||
const app = document.querySelector("#projector-app");
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const sid = params.get("sid");
|
||||
|
||||
const store = {
|
||||
ws: null,
|
||||
snapshot: null,
|
||||
prevSnapshot: null,
|
||||
countdownTimer: null,
|
||||
connected: false,
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Helpers
|
||||
// --------------------------------------------------------------
|
||||
|
||||
function escapeText(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[c]);
|
||||
}
|
||||
const escapeAttr = escapeText;
|
||||
|
||||
function fmtScore(value) {
|
||||
return Number(value || 0).toFixed(2);
|
||||
}
|
||||
|
||||
function clamp(n, lo, hi) {
|
||||
return Math.max(lo, Math.min(hi, n));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Boot + WS
|
||||
// --------------------------------------------------------------
|
||||
|
||||
async function boot() {
|
||||
if (!sid) {
|
||||
app.innerHTML = `
|
||||
<section class="projector-shell">
|
||||
<span class="reg-tr"></span><span class="reg-bl"></span>
|
||||
<header class="projector-topbar">
|
||||
<div class="topbar-left"><span class="brand">Live Quiz</span></div>
|
||||
<div class="topbar-mid"></div>
|
||||
<div class="topbar-right"></div>
|
||||
</header>
|
||||
<div class="projector-card fatal-card">
|
||||
<h1 class="lobby-headline">Projector view</h1>
|
||||
<p class="lobby-sub">Open <code>/projector/?sid=<your-sid></code></p>
|
||||
</div>
|
||||
<footer class="projector-foot">
|
||||
<span class="left"><span class="dot dim"></span> offline</span>
|
||||
<span class="center"></span>
|
||||
<span class="right">no session</span>
|
||||
</footer>
|
||||
</section>`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/session/${sid}/projector`);
|
||||
if (!r.ok) throw new Error("not found");
|
||||
store.snapshot = await r.json();
|
||||
render();
|
||||
} catch {
|
||||
app.innerHTML = `
|
||||
<section class="projector-shell">
|
||||
<span class="reg-tr"></span><span class="reg-bl"></span>
|
||||
<header class="projector-topbar">
|
||||
<div class="topbar-left"><span class="brand">Live Quiz</span></div>
|
||||
<div class="topbar-mid"></div>
|
||||
<div class="topbar-right"></div>
|
||||
</header>
|
||||
<div class="projector-card fatal-card">
|
||||
<h1 class="lobby-headline">Quiz unavailable</h1>
|
||||
<p class="lobby-sub">No live session at <code>${escapeText(sid)}</code>.</p>
|
||||
</div>
|
||||
<footer class="projector-foot">
|
||||
<span class="left"><span class="dot dim"></span> offline</span>
|
||||
<span class="center"></span>
|
||||
<span class="right">${escapeText(sid)}</span>
|
||||
</footer>
|
||||
</section>`;
|
||||
return;
|
||||
}
|
||||
connect();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/projector/${sid}`);
|
||||
store.ws = ws;
|
||||
ws.addEventListener("open", () => {
|
||||
store.connected = true;
|
||||
refreshConnDot();
|
||||
});
|
||||
ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "projector_state") {
|
||||
store.prevSnapshot = store.snapshot;
|
||||
store.snapshot = msg;
|
||||
render();
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
ws.addEventListener("close", () => {
|
||||
store.connected = false;
|
||||
refreshConnDot();
|
||||
setTimeout(connect, 2000);
|
||||
});
|
||||
// Periodic ping to keep proxies from idling the socket out.
|
||||
setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(JSON.stringify({ type: "ping" })); } catch {}
|
||||
}
|
||||
}, 25_000);
|
||||
}
|
||||
|
||||
function refreshConnDot() {
|
||||
const dot = document.querySelector(".projector-foot .dot");
|
||||
if (!dot) return;
|
||||
dot.classList.toggle("dim", !store.connected);
|
||||
const left = dot.parentElement;
|
||||
if (left) {
|
||||
const text = store.connected ? "live" : "reconnecting";
|
||||
// last text node holds status word
|
||||
const nodes = Array.from(left.childNodes);
|
||||
const t = nodes.reverse().find((n) => n.nodeType === 3);
|
||||
if (t) t.nodeValue = " " + text;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Top-level render
|
||||
// --------------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
const s = store.snapshot;
|
||||
if (!s) return;
|
||||
stopCountdown();
|
||||
|
||||
const view =
|
||||
s.state === "lobby" ? renderLobby(s)
|
||||
: s.state === "question_open" ? renderQuestion(s, false)
|
||||
: s.state === "question_closed" ? renderQuestion(s, true)
|
||||
: s.state === "between_questions" ? renderBetween(s)
|
||||
: s.state === "finished" ? renderFinished(s)
|
||||
: `<div class="projector-card"><p class="muted">State: ${escapeText(s.state)}</p></div>`;
|
||||
|
||||
app.innerHTML = `
|
||||
<section class="projector-shell" data-state="${escapeText(s.state)}">
|
||||
<span class="reg-tr"></span><span class="reg-bl"></span>
|
||||
${renderTopbar(s)}
|
||||
${view}
|
||||
${renderFoot(s)}
|
||||
</section>
|
||||
`;
|
||||
|
||||
// Lobby counter bump animation (post-mount): if the count went up
|
||||
// since the previous snapshot, briefly mark .bump on the counter.
|
||||
if (s.state === "lobby") {
|
||||
const prev = store.prevSnapshot?.participant_count ?? -1;
|
||||
if (prev >= 0 && s.participant_count > prev) {
|
||||
const el = document.querySelector(".participant-count");
|
||||
if (el) {
|
||||
el.classList.remove("bump");
|
||||
// force reflow then re-add to restart animation
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
void el.offsetWidth;
|
||||
el.classList.add("bump");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start the countdown ticker for the question_open state
|
||||
if (s.state === "question_open" && s.question) {
|
||||
startCountdown(
|
||||
Date.now() + (s.question.remaining_ms ?? 0),
|
||||
s.question.time_limit ?? s.pool_meta?.time_limit_default ?? 60
|
||||
);
|
||||
} else if (s.state === "question_closed" && s.question) {
|
||||
// freeze the ring at "spent"
|
||||
const ring = document.querySelector(".countdown-ring");
|
||||
if (ring) {
|
||||
ring.style.setProperty("--pct", "0");
|
||||
ring.classList.remove("urgent");
|
||||
ring.classList.add("spent");
|
||||
const num = ring.querySelector(".num");
|
||||
if (num) num.textContent = "0s";
|
||||
}
|
||||
}
|
||||
|
||||
refreshConnDot();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Topbar (masthead)
|
||||
// --------------------------------------------------------------
|
||||
|
||||
function renderTopbar(s) {
|
||||
const idx = s.question?.idx ?? null;
|
||||
const total = s.pool_meta?.question_count ?? s.question?.total_questions ?? 0;
|
||||
const showQ = idx != null;
|
||||
const stateLabel = ({
|
||||
lobby: "Lobby",
|
||||
question_open: "Live",
|
||||
question_closed: "Reveal",
|
||||
between_questions: "Between",
|
||||
finished: "Finished",
|
||||
})[s.state] || s.state;
|
||||
|
||||
return `
|
||||
<header class="projector-topbar">
|
||||
<div class="topbar-left">
|
||||
<span class="brand">Live Quiz</span>
|
||||
<h1 class="topbar-title">${escapeText(s.title || "Quiz")}</h1>
|
||||
</div>
|
||||
<div class="topbar-mid">
|
||||
${showQ
|
||||
? `<span class="folio">Question <b>${idx + 1}</b> of <b>${total}</b></span>`
|
||||
: (total ? `<span class="folio"><b>${total}</b> questions</span>` : "")
|
||||
}
|
||||
<span class="state-badge state-${escapeText(s.state)}">${escapeText(stateLabel)}</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
${s.sid ? `<span class="folio">SID <b>${escapeText(s.sid)}</b></span>` : ""}
|
||||
<span class="folio">${formatClock(s.server_ts)}</span>
|
||||
</div>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatClock(ts) {
|
||||
if (!ts) return "";
|
||||
const d = new Date(ts);
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Footer
|
||||
// --------------------------------------------------------------
|
||||
|
||||
function renderFoot(s) {
|
||||
const dotClass = store.connected ? "dot" : "dot dim";
|
||||
const status = store.connected ? "live" : "reconnecting";
|
||||
const right = (() => {
|
||||
if (s.state === "lobby") return `awaiting start`;
|
||||
if (s.state === "finished") return `quiz complete`;
|
||||
if (s.state === "between_questions") return `interlude`;
|
||||
if (s.state === "question_closed") return `answers revealed`;
|
||||
if (s.state === "question_open" && s.live_histogram) {
|
||||
const c = s.live_histogram;
|
||||
return `${c.submitted_count}/${c.total_count} submitted`;
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
return `
|
||||
<footer class="projector-foot">
|
||||
<span class="left"><span class="${dotClass}"></span> ${status}</span>
|
||||
<span class="center">${escapeText(s.title || "")}</span>
|
||||
<span class="right">${escapeText(right)}</span>
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// State: LOBBY
|
||||
// --------------------------------------------------------------
|
||||
|
||||
function renderLobby(s) {
|
||||
const n = s.participant_count || 0;
|
||||
const dotMax = 96;
|
||||
const dots = Math.min(n, dotMax);
|
||||
const time = s.pool_meta?.time_limit_default ?? 60;
|
||||
const qcount = s.pool_meta?.question_count ?? 0;
|
||||
const scoreFn = (s.pool_meta?.score_fn || "linear").replace(/_/g, " ");
|
||||
|
||||
return `
|
||||
<div class="projector-grid lobby">
|
||||
<div class="projector-card join-card">
|
||||
<div>
|
||||
<p class="lobby-eyebrow">Scan to join</p>
|
||||
<h2 class="lobby-headline">Open the quiz on your phone.</h2>
|
||||
<p class="lobby-sub">Point your camera at the code, or type the address below into a browser.</p>
|
||||
</div>
|
||||
<div class="qr-frame">
|
||||
<div class="qr-big"><img src="${escapeAttr(s.qr_url || "")}" alt="Join QR code"></div>
|
||||
</div>
|
||||
<div class="lobby-url">${escapeText(s.join_url || "")}</div>
|
||||
</div>
|
||||
|
||||
<div class="projector-card lobby-status">
|
||||
<p class="lobby-eyebrow">Joined so far</p>
|
||||
<div class="participant-count">
|
||||
<b>${n}</b>
|
||||
<div class="label">
|
||||
<span class="word">student${n === 1 ? "" : "s"} ready,</span>
|
||||
<span class="meta">↳ waiting on instructor</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="constellation" aria-label="${n} participants joined">
|
||||
${Array.from({ length: dots }).map((_, i) => {
|
||||
const d = (i % 24) * 18;
|
||||
return `<li style="--d:${d}ms"></li>`;
|
||||
}).join("")}
|
||||
</ol>
|
||||
|
||||
<div>
|
||||
<div class="lobby-rule">— how it runs —</div>
|
||||
<div class="lobby-meta-grid">
|
||||
<div class="cell"><span class="v">${qcount}</span><span class="k">Questions</span></div>
|
||||
<div class="cell"><span class="v">${time}s</span><span class="k">Per question</span></div>
|
||||
<div class="cell"><span class="v">${escapeText(scoreFn)}</span><span class="k">Scoring</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// State: QUESTION (open + closed/reveal)
|
||||
// --------------------------------------------------------------
|
||||
|
||||
function renderQuestion(s, revealed) {
|
||||
const q = s.question;
|
||||
if (!q) return `<div class="projector-card"><p class="muted">Loading question…</p></div>`;
|
||||
|
||||
const hist = s.live_histogram?.counts || { A: 0, B: 0, C: 0, D: 0 };
|
||||
const submitted = s.live_histogram?.submitted_count || 0;
|
||||
const total = Math.max(1, s.live_histogram?.total_count || 1);
|
||||
const reveal = s.reveal;
|
||||
const correct = reveal?.correct;
|
||||
|
||||
// Pre-vote state: nobody has submitted yet AND we're not revealed.
|
||||
// Hide the bars to keep the layout calm during reading time.
|
||||
const hasVotes = ["A", "B", "C", "D"].some((k) => (hist[k] || 0) > 0);
|
||||
const preVote = !revealed && !hasVotes;
|
||||
|
||||
const limit = q.time_limit || s.pool_meta?.time_limit_default || 60;
|
||||
const remainingMs = q.remaining_ms ?? 0;
|
||||
const initialPct = revealed ? 0 : clamp(100 * (remainingMs / 1000) / limit, 0, 100);
|
||||
const initialSec = Math.ceil(remainingMs / 1000);
|
||||
const ringClass =
|
||||
revealed ? "countdown-ring spent"
|
||||
: (initialSec <= 10 && initialSec > 0) ? "countdown-ring urgent"
|
||||
: "countdown-ring";
|
||||
|
||||
const submittedPct = clamp(100 * submitted / Math.max(1, s.live_histogram?.total_count || 1), 0, 100);
|
||||
|
||||
return `
|
||||
<div class="projector-grid question">
|
||||
<div class="projector-card question-card">
|
||||
<div class="question-head">
|
||||
<h2 class="big-question">${escapeText(q.text)}</h2>
|
||||
<div class="${ringClass}" id="big-countdown"
|
||||
style="--pct:${initialPct}"
|
||||
role="timer" aria-label="time remaining">
|
||||
<span class="num">${revealed ? "0s" : initialSec + "s"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="big-options ${revealed ? "revealed" : ""} ${preVote ? "pre-vote" : ""}">
|
||||
${["A","B","C","D"].map((k) => {
|
||||
const v = hist[k] || 0;
|
||||
const pct = Math.round(100 * v / total);
|
||||
const isCorrect = revealed && k === correct;
|
||||
const isIncorrect = revealed && k !== correct;
|
||||
const cls = [
|
||||
isCorrect ? "correct" : "",
|
||||
isIncorrect ? "incorrect" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
return `
|
||||
<li class="${cls}">
|
||||
<span class="opt-key">${k}</span>
|
||||
<span class="opt-text">${escapeText(q.options?.[k] || "")}</span>
|
||||
<span class="opt-bar"><span class="opt-bar-fill" style="width:${pct}%"></span></span>
|
||||
<span class="opt-count">${v}<small>${pct}%</small></span>
|
||||
</li>
|
||||
`;
|
||||
}).join("")}
|
||||
</ol>
|
||||
|
||||
${revealed && reveal?.explanation
|
||||
? `<p class="big-explanation">${escapeText(reveal.explanation)}</p>`
|
||||
: `<div class="submission-strip">
|
||||
<span class="label">Submissions</span>
|
||||
<span class="track"><span class="fill" style="--p:${submittedPct.toFixed(1)}%"></span></span>
|
||||
<span class="nums">${submitted}<small>of ${s.live_histogram?.total_count || s.participant_count || 0}</small></span>
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="projector-card side-card">
|
||||
<p class="card-eyebrow">Response time</p>
|
||||
${renderResponseTime(s.response_time_distribution)}
|
||||
<p class="card-eyebrow">Top 5</p>
|
||||
${renderLeaderboard((s.leaderboard || []).slice(0, 5))}
|
||||
<p class="side-meta">${submitted} of ${s.live_histogram?.total_count || s.participant_count || 0} answered</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// State: BETWEEN
|
||||
// --------------------------------------------------------------
|
||||
|
||||
function renderBetween(s) {
|
||||
const next = (s.question?.idx ?? -1) >= 0
|
||||
? `Next: question ${s.question.idx + 2} of ${s.pool_meta?.question_count ?? "?"}`
|
||||
: "";
|
||||
return `
|
||||
<div class="projector-grid between">
|
||||
<div class="projector-card">
|
||||
<p class="card-eyebrow">Score distribution</p>
|
||||
${renderScoreArea(s.score_distribution)}
|
||||
<p class="side-meta">${escapeText(next)}</p>
|
||||
</div>
|
||||
<div class="projector-card">
|
||||
<p class="card-eyebrow">Standings</p>
|
||||
${renderLeaderboard((s.leaderboard || []).slice(0, 10))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// State: FINISHED
|
||||
// --------------------------------------------------------------
|
||||
|
||||
function renderFinished(s) {
|
||||
const dist = s.score_distribution;
|
||||
const top = (s.leaderboard || [])[0];
|
||||
const headline = top
|
||||
? `${escapeText(top.name)} took the broadside.`
|
||||
: `The quiz is complete.`;
|
||||
return `
|
||||
<div class="projector-grid finished">
|
||||
<div class="projector-card finished-grid">
|
||||
<div class="finished-banner">
|
||||
<span class="kicker">— The Final Tally —</span>
|
||||
<h2>${headline}</h2>
|
||||
<p class="summary">${dist?.n ?? 0} student${(dist?.n ?? 0) === 1 ? "" : "s"} answered · max possible ${(dist?.max_total ?? 0).toFixed(1)} points</p>
|
||||
</div>
|
||||
${renderScoreArea(dist)}
|
||||
</div>
|
||||
<div class="projector-card">
|
||||
<p class="card-eyebrow">Final leaderboard</p>
|
||||
${renderLeaderboard(s.leaderboard || [])}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Leaderboard
|
||||
// --------------------------------------------------------------
|
||||
|
||||
function renderLeaderboard(rows) {
|
||||
if (!rows || !rows.length) {
|
||||
return `<div class="empty-state"><span class="glyph">— no scores yet —</span><p>Standings appear after the first question is scored.</p></div>`;
|
||||
}
|
||||
return `
|
||||
<ol class="big-leaderboard">
|
||||
${rows.map((r, i) => `
|
||||
<li style="--d:${i * 35}ms">
|
||||
<span class="rank">${r.rank}</span>
|
||||
<span class="name">${escapeText(r.name)}</span>
|
||||
<span class="score">${fmtScore(r.score)}</span>
|
||||
</li>
|
||||
`).join("")}
|
||||
</ol>
|
||||
`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Charts
|
||||
// --------------------------------------------------------------
|
||||
|
||||
/** Vertical bar chart with axis baseline + gridlines (CSS-driven). */
|
||||
function renderResponseTime(dist) {
|
||||
if (!dist || !dist.total) {
|
||||
return `<div class="empty-state"><span class="glyph">— awaiting submissions —</span></div>`;
|
||||
}
|
||||
const max = Math.max(1, ...dist.buckets.map((b) => b.count));
|
||||
const cells = dist.buckets.map((b) => {
|
||||
const h = Math.max(2, Math.round(100 * b.count / max));
|
||||
const empty = b.count === 0;
|
||||
return `
|
||||
<div class="bar-cell">
|
||||
<span class="bar-fill" style="--h:${h}%" data-empty="${empty}"></span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
const nums = dist.buckets.map((b) => `<span class="bar-num">${b.count}</span>`).join("");
|
||||
const labels = dist.buckets.map((b) => `<span class="bar-label">${escapeText(b.label)}</span>`).join("");
|
||||
return `
|
||||
<div class="bar-chart small">
|
||||
<div class="bars">${cells}</div>
|
||||
<div class="nums">${nums}</div>
|
||||
<div class="labels">${labels}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score distribution as a smoothed step-area chart. Gives a feel for the
|
||||
* shape of the class result rather than 10 detached bars; reads well at
|
||||
* lecture-hall distance because the silhouette is unambiguous.
|
||||
*
|
||||
* The SVG is intentionally drawn in a fixed 1000×360 box and stretched.
|
||||
* We use a stepped path so each x-bucket looks like a flat top (since the
|
||||
* bucket is a range, not a point), then close it down to the axis to fill.
|
||||
*/
|
||||
function renderScoreArea(dist) {
|
||||
if (!dist || !dist.buckets || !dist.buckets.length) {
|
||||
return `<div class="empty-state"><span class="glyph">— scores not yet tallied —</span><p>The distribution appears after the first question is scored.</p></div>`;
|
||||
}
|
||||
const W = 1000, H = 360;
|
||||
const padL = 56, padR = 16, padT = 22, padB = 44;
|
||||
const innerW = W - padL - padR;
|
||||
const innerH = H - padT - padB;
|
||||
const buckets = dist.buckets;
|
||||
const n = buckets.length;
|
||||
const total = dist.n || buckets.reduce((a, b) => a + b.count, 0) || 0;
|
||||
const max = Math.max(1, ...buckets.map((b) => b.count));
|
||||
|
||||
// X coords for the *edges* between buckets (n+1 edges)
|
||||
const xEdge = (i) => padL + (innerW * i) / n;
|
||||
const yFor = (count) => padT + innerH * (1 - count / max);
|
||||
|
||||
// Stepped polyline: for each bucket draw flat top from xEdge(i) to xEdge(i+1)
|
||||
const linePath = [];
|
||||
buckets.forEach((b, i) => {
|
||||
const x0 = xEdge(i), x1 = xEdge(i + 1), y = yFor(b.count);
|
||||
if (i === 0) linePath.push(`M ${x0} ${y}`);
|
||||
else linePath.push(`L ${x0} ${y}`);
|
||||
linePath.push(`L ${x1} ${y}`);
|
||||
});
|
||||
const fillPath = [
|
||||
...linePath,
|
||||
`L ${xEdge(n)} ${padT + innerH}`,
|
||||
`L ${xEdge(0)} ${padT + innerH}`,
|
||||
`Z`,
|
||||
];
|
||||
|
||||
// Y gridlines at 0, .25, .5, .75, 1
|
||||
const yGrid = [0, 0.25, 0.5, 0.75, 1].map((t) => {
|
||||
const y = padT + innerH * t;
|
||||
const v = Math.round(max * (1 - t));
|
||||
return `
|
||||
<line class="grid-line" x1="${padL}" x2="${padL + innerW}" y1="${y}" y2="${y}"></line>
|
||||
<text class="y-tick-label" x="${padL - 8}" y="${y}">${v}</text>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
// X-axis tick labels at each bucket centre
|
||||
const xLabels = buckets.map((b, i) => {
|
||||
const cx = (xEdge(i) + xEdge(i + 1)) / 2;
|
||||
return `<text class="x-tick-label" x="${cx}" y="${H - padB + 16}">${escapeText(b.label)}</text>`;
|
||||
}).join("");
|
||||
|
||||
// Per-bucket count labels above each top, only if non-zero
|
||||
const dataLabels = buckets.map((b, i) => {
|
||||
if (b.count === 0) return "";
|
||||
const cx = (xEdge(i) + xEdge(i + 1)) / 2;
|
||||
const cy = yFor(b.count) - 8;
|
||||
return `<text class="data-label" x="${cx}" y="${cy - 12}">${b.count}</text>
|
||||
<circle class="data-point" cx="${cx}" cy="${yFor(b.count)}" r="5.5"></circle>`;
|
||||
}).join("");
|
||||
|
||||
// Median tag — find the bucket containing the cumulative midpoint
|
||||
let medianIdx = -1;
|
||||
if (total > 0) {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < buckets.length; i++) {
|
||||
acc += buckets[i].count;
|
||||
if (acc >= total / 2) { medianIdx = i; break; }
|
||||
}
|
||||
}
|
||||
let medianMarks = "";
|
||||
if (medianIdx >= 0) {
|
||||
const mx = (xEdge(medianIdx) + xEdge(medianIdx + 1)) / 2;
|
||||
medianMarks = `
|
||||
<line class="median-line" x1="${mx}" x2="${mx}" y1="${padT}" y2="${padT + innerH}"></line>
|
||||
<text class="median-tag" x="${mx}" y="${padT - 6}" text-anchor="middle">median</text>
|
||||
`;
|
||||
}
|
||||
|
||||
// Summary stats
|
||||
const mean = total ? buckets.reduce((acc, b, i) => {
|
||||
// approximate bucket midpoint as i+0.5 normalized to max_total
|
||||
const mid = ((i + 0.5) / n) * (dist.max_total || n);
|
||||
return acc + b.count * mid;
|
||||
}, 0) / total : 0;
|
||||
|
||||
return `
|
||||
<div class="area-chart">
|
||||
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="Score distribution">
|
||||
${yGrid}
|
||||
<line class="axis" x1="${padL}" x2="${padL + innerW}" y1="${padT + innerH}" y2="${padT + innerH}"></line>
|
||||
<line class="axis" x1="${padL}" x2="${padL}" y1="${padT}" y2="${padT + innerH}"></line>
|
||||
<text class="axis-title" x="${padL + innerW / 2}" y="${H - 6}" text-anchor="middle">Score band (out of ${(dist.max_total || 0).toFixed(1)})</text>
|
||||
<text class="axis-title" x="${padL - 36}" y="${padT + innerH / 2}" transform="rotate(-90 ${padL - 36} ${padT + innerH / 2})" text-anchor="middle">Students</text>
|
||||
<path class="area-fill" d="${fillPath.join(" ")}"></path>
|
||||
<path class="area-line" d="${linePath.join(" ")}"></path>
|
||||
${dataLabels}
|
||||
${medianMarks}
|
||||
</svg>
|
||||
<div class="chart-legend">
|
||||
<span>10 score bands · ${n} buckets</span>
|
||||
<span class="stat">n = <b>${total}</b> · mean <b>${mean.toFixed(2)}</b> · max <b>${(dist.max_total || 0).toFixed(1)}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Countdown ring (partial update, runs at 4Hz)
|
||||
// --------------------------------------------------------------
|
||||
|
||||
function startCountdown(deadlineMs, totalSec) {
|
||||
stopCountdown();
|
||||
const tick = () => {
|
||||
const ring = document.querySelector("#big-countdown");
|
||||
if (!ring) return stopCountdown();
|
||||
const remaining = Math.max(0, deadlineMs - Date.now());
|
||||
const sec = Math.ceil(remaining / 1000);
|
||||
const pct = clamp(100 * (remaining / 1000) / Math.max(1, totalSec), 0, 100);
|
||||
ring.style.setProperty("--pct", pct.toFixed(2));
|
||||
const num = ring.querySelector(".num");
|
||||
if (num) num.textContent = `${sec}s`;
|
||||
const isUrgent = remaining > 0 && remaining <= 10000;
|
||||
ring.classList.toggle("urgent", isUrgent);
|
||||
if (remaining <= 0) {
|
||||
ring.classList.add("spent");
|
||||
stopCountdown();
|
||||
}
|
||||
};
|
||||
tick();
|
||||
store.countdownTimer = setInterval(tick, 250);
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
if (store.countdownTimer) clearInterval(store.countdownTimer);
|
||||
store.countdownTimer = null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Boot
|
||||
// --------------------------------------------------------------
|
||||
|
||||
boot();
|
||||
@@ -34,6 +34,61 @@ const RECONNECT = {
|
||||
|
||||
let countdownTimer = null;
|
||||
|
||||
/* Tab-blur audit. We POST a server event whenever the student
|
||||
* backgrounds the page (visibilitychange) or moves focus away from the
|
||||
* window (blur). Both are debounced so a rapid alt-tab roundtrip
|
||||
* doesn't spam events. The server records each event in `student_events`
|
||||
* and surfaces a count to the instructor presence panel.
|
||||
*
|
||||
* We only ping during a question_open state — switching tabs between
|
||||
* questions is fine and we don't want to noise the audit. */
|
||||
const FOCUS = {
|
||||
lastBlur: 0,
|
||||
lastHidden: 0,
|
||||
debounceMs: 1500,
|
||||
};
|
||||
|
||||
function postEvent(kind) {
|
||||
if (!sid || !store.currentQuestion || store.submitted) return;
|
||||
// Use sendBeacon when leaving the page so the event survives the
|
||||
// navigation; otherwise fetch with credentials so the cookie rides.
|
||||
const body = JSON.stringify({ kind, question_idx: store.currentQuestion.question_idx });
|
||||
const url = `/api/session/${sid}/event`;
|
||||
if (kind === "visibility_hidden" && navigator.sendBeacon) {
|
||||
const blob = new Blob([body], { type: "application/json" });
|
||||
navigator.sendBeacon(url, blob);
|
||||
return;
|
||||
}
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
const now = Date.now();
|
||||
if (now - FOCUS.lastBlur < FOCUS.debounceMs) return;
|
||||
FOCUS.lastBlur = now;
|
||||
postEvent("blur");
|
||||
}
|
||||
|
||||
function onVisibility() {
|
||||
const now = Date.now();
|
||||
if (document.visibilityState === "hidden") {
|
||||
if (now - FOCUS.lastHidden < FOCUS.debounceMs) return;
|
||||
FOCUS.lastHidden = now;
|
||||
postEvent("visibility_hidden");
|
||||
} else if (document.visibilityState === "visible") {
|
||||
postEvent("visibility_visible");
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("blur", onBlur);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
function fmtScore(value) {
|
||||
// Scores are floats on a 0.05 grid in [0, 1]. Display as a fixed
|
||||
// two-decimal string so users see e.g. "0.85" instead of
|
||||
@@ -136,7 +191,15 @@ function renderJoin(error = null) {
|
||||
connect();
|
||||
} catch (err) {
|
||||
submit.disabled = false;
|
||||
renderJoin(err.message || "Could not join.");
|
||||
let msg = err.message || "Could not join.";
|
||||
// The /join endpoint returns the FastAPI default JSON error envelope
|
||||
// ({"detail": "..."}) — surface the human-readable detail rather
|
||||
// than the raw JSON blob in the alert.
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
if (parsed && parsed.detail) msg = parsed.detail;
|
||||
} catch {}
|
||||
renderJoin(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
139
static/style.css
139
static/style.css
@@ -1256,6 +1256,145 @@ h2.question-text.small {
|
||||
.options.student-reveal li.yours.correct::before { color: var(--correct-border); }
|
||||
.options.student-reveal li.yours.wrong-pick::before { color: var(--wrong-border); }
|
||||
|
||||
/* ---------- Live presence panel (admin) ---------- */
|
||||
|
||||
.presence-panel { padding: 18px 20px 16px; }
|
||||
|
||||
.presence-list {
|
||||
list-style: none;
|
||||
margin: 0 0 12px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.presence-row {
|
||||
display: grid;
|
||||
grid-template-columns: 14px 1fr auto auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px dotted var(--border);
|
||||
}
|
||||
.presence-row:last-child { border-bottom: 0; }
|
||||
.presence-row .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--muted-2);
|
||||
flex-shrink: 0;
|
||||
transition: background 0.4s ease, box-shadow 0.4s ease;
|
||||
}
|
||||
.presence-row.is-online .dot {
|
||||
background: var(--correct-border);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--correct-border) 18%, transparent);
|
||||
}
|
||||
.presence-row.is-online.is-fresh .dot {
|
||||
animation: rosterDotPulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
.presence-row.is-stale .dot { background: var(--warn); }
|
||||
.presence-row.is-offline .dot { background: var(--muted-2); }
|
||||
|
||||
.presence-row .who { display: grid; line-height: 1.2; min-width: 0; }
|
||||
.presence-row .who b {
|
||||
font-weight: 500;
|
||||
font-size: 0.92rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.presence-row .who small {
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.presence-flags {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.flag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
padding: 2px 7px;
|
||||
border-radius: 0;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.flag-ok { color: var(--correct-border); border-color: var(--correct-border); }
|
||||
.flag-pending { color: var(--muted-2); }
|
||||
.flag-warn { color: var(--warn); border-color: var(--warn); }
|
||||
.flag-danger {
|
||||
color: var(--danger-text);
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.btn.xtiny {
|
||||
padding: 2px 8px;
|
||||
font-size: 0.78rem;
|
||||
min-height: 0;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
margin-right: 4px;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
background: var(--muted-2);
|
||||
}
|
||||
.legend-dot:first-child { margin-left: 0; }
|
||||
.legend-dot.is-online { background: var(--correct-border); }
|
||||
.legend-dot.is-stale { background: var(--warn); }
|
||||
.legend-dot.is-offline { background: var(--muted-2); }
|
||||
|
||||
.xsmall { font-size: 0.72rem; letter-spacing: 0.04em; }
|
||||
|
||||
.duplicate-alerts {
|
||||
margin: 0 32px 16px;
|
||||
}
|
||||
.duplicate-alerts .alert-title {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
color: var(--danger);
|
||||
margin: 0 0 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.dup-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 6px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.dup-list li { display: flex; gap: 12px; align-items: baseline; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.duplicate-alerts { margin: 0 18px 12px; }
|
||||
}
|
||||
|
||||
/* ---------- Responsive: mobile student view ---------- */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
||||
89
tests/test_anti_cheat.py
Normal file
89
tests/test_anti_cheat.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Anti-cheat / audit-event coverage:
|
||||
- tab-blur events are recorded and surfaced in CSV + presence
|
||||
- duplicate-join attempts are 409 + audited
|
||||
- admin clear-student removes the participant + submissions
|
||||
- submit lockout (one answer per Q per student) is server-enforced
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from conftest import admin_login, join_student
|
||||
|
||||
|
||||
def test_blur_event_is_logged_and_counted(client, sid):
|
||||
join_student(client, sid, "s1", "Alice")
|
||||
response = client.post(f"/api/session/{sid}/event", json={"kind": "blur", "question_idx": 0})
|
||||
assert response.status_code == 200
|
||||
response = client.post(f"/api/session/{sid}/event", json={"kind": "blur", "question_idx": 0})
|
||||
assert response.status_code == 200
|
||||
response = client.post(f"/api/session/{sid}/event", json={"kind": "visibility_hidden"})
|
||||
assert response.status_code == 200
|
||||
|
||||
# The event count is exposed via CSV export. Two blur events + one
|
||||
# visibility_hidden event should land on the s1 row.
|
||||
admin_login(client)
|
||||
csv_text = client.get("/admin/api/csv").text
|
||||
s1_row = next(line for line in csv_text.splitlines() if ",s1," in line)
|
||||
# Trailing fields are blur_count, hidden_count, duplicate_join_attempts.
|
||||
assert s1_row.endswith(",2,1,0"), s1_row
|
||||
|
||||
|
||||
def test_event_endpoint_rejects_unknown_kind(client, sid):
|
||||
join_student(client, sid, "s1", "Alice")
|
||||
response = client.post(f"/api/session/{sid}/event", json={"kind": "screenshot"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_event_endpoint_requires_student_cookie(client, sid):
|
||||
response = client.post(f"/api/session/{sid}/event", json={"kind": "blur"})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_duplicate_join_is_logged_in_csv(client, sid):
|
||||
"""A 409 join attempt records a `duplicate_join` audit row whose
|
||||
count rolls up into CSV + presence_update."""
|
||||
join_student(client, sid, "s1", "Alice")
|
||||
# Second client tries to claim s1 from a fresh cookie jar.
|
||||
fresh = client.__class__(client.app)
|
||||
response = fresh.post(f"/api/session/{sid}/join", json={"student_id": "s1", "name": "Hijacker"})
|
||||
assert response.status_code == 409
|
||||
|
||||
admin_login(client)
|
||||
csv_text = client.get("/admin/api/csv").text
|
||||
s1_row = next(line for line in csv_text.splitlines() if ",s1," in line)
|
||||
assert s1_row.endswith(",0,0,1"), s1_row
|
||||
|
||||
|
||||
def test_admin_clear_student_frees_id(client, sid):
|
||||
"""First-claim-wins recovery: admin can clear a participant so the
|
||||
legitimate student (or anyone, since there's no further identity
|
||||
check) can re-join with that id."""
|
||||
join_student(client, sid, "s1", "Alice")
|
||||
admin_login(client)
|
||||
response = client.delete("/admin/api/students/s1")
|
||||
assert response.status_code == 200
|
||||
# The slot is now free; the same id can be re-claimed from a fresh
|
||||
# cookie jar.
|
||||
fresh = client.__class__(client.app)
|
||||
response = fresh.post(f"/api/session/{sid}/join", json={"student_id": "s1", "name": "Alice Again"})
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_admin_clear_student_404s_when_no_match(client, sid):
|
||||
admin_login(client)
|
||||
assert client.delete("/admin/api/students/nobody").status_code == 404
|
||||
|
||||
|
||||
def test_submit_lockout_is_server_enforced(client, sid):
|
||||
"""Server-side: a second submit for the same (sid, student_id, qidx)
|
||||
returns the *original* ack rather than overwriting the answer. The
|
||||
PK constraint + existing_submit_ack early-return guarantees this."""
|
||||
join_student(client, sid, "s1", "Alice")
|
||||
rooms = client.app.state.rooms
|
||||
client.portal.call(rooms.open_question, sid, 0, 5)
|
||||
first = client.portal.call(rooms.submit_answer, sid, "s1", 0, "B")
|
||||
second = client.portal.call(rooms.submit_answer, sid, "s1", 0, "C")
|
||||
assert first["type"] == "submit_ack"
|
||||
assert second["type"] == "submit_ack"
|
||||
assert second["answer"] == first["answer"] == "B"
|
||||
assert second["score"] == first["score"]
|
||||
@@ -11,14 +11,25 @@ def test_session_metadata_join_me_and_stats(client, sid):
|
||||
assert join["ok"] is True
|
||||
assert "qz_student" in client.cookies
|
||||
|
||||
join_student(client, sid, "s1", "Updated Name")
|
||||
me = client.get(f"/api/session/{sid}/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json()["name"] == "Updated Name"
|
||||
assert me.json()["name"] == "First Name"
|
||||
|
||||
stats = client.get(f"/api/session/{sid}/stats").json()
|
||||
assert stats["question_idx"] is None
|
||||
assert stats["top5"][0]["name"] == "Updated Name"
|
||||
assert stats["top5"][0]["name"] == "First Name"
|
||||
|
||||
|
||||
def test_duplicate_student_id_join_is_rejected(client, sid):
|
||||
"""First-claim-wins anti-hijack: a second join attempting the same
|
||||
student_id must 409 (without overwriting name or rotating the cookie).
|
||||
The original cookie keeps working; recovery is via admin clear-student."""
|
||||
join_student(client, sid, "s1", "First Name")
|
||||
response = client.post(f"/api/session/{sid}/join", json={"student_id": "s1", "name": "Hijacker"})
|
||||
assert response.status_code == 409
|
||||
assert "already in use" in response.text.lower()
|
||||
me = client.get(f"/api/session/{sid}/me").json()
|
||||
assert me["name"] == "First Name"
|
||||
|
||||
|
||||
def test_root_without_sid_redirects_to_canonical(client, sid):
|
||||
|
||||
@@ -12,6 +12,9 @@ def test_csv_export_contains_one_row_per_submission(client, sid):
|
||||
|
||||
response = client.get("/admin/api/csv")
|
||||
lines = response.text.strip().splitlines()
|
||||
assert lines[0] == "sid,student_id,name,question_idx,answer,elapsed_ms,score,status"
|
||||
assert lines[0] == "sid,student_id,name,question_idx,answer,elapsed_ms,score,status,blur_count,hidden_count,duplicate_join_attempts"
|
||||
assert len(lines) == 2
|
||||
assert ",s1,Student One,0,B," in lines[1]
|
||||
# Default audit-event counts are 0 for a clean run (no blur events,
|
||||
# no duplicate-join attempts).
|
||||
assert lines[1].endswith(",0,0,0")
|
||||
|
||||
76
tests/test_projector.py
Normal file
76
tests/test_projector.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Projector view (public read-only):
|
||||
- snapshot endpoint returns the expected shape
|
||||
- leaderboard never carries student_ids (privacy)
|
||||
- WS client receives a projector_state message on connect
|
||||
- state changes (open question, submit, close) push fresh snapshots
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from conftest import admin_login, join_student
|
||||
|
||||
|
||||
def test_projector_snapshot_includes_required_fields(client, sid):
|
||||
join_student(client, sid, "s1", "Alice")
|
||||
response = client.get(f"/api/session/{sid}/projector")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["type"] == "projector_state"
|
||||
assert body["state"] == "lobby"
|
||||
assert body["sid"] == sid
|
||||
assert body["participant_count"] == 1
|
||||
assert "qr_url" in body and body["qr_url"].startswith("data:image/svg+xml")
|
||||
assert "join_url" in body
|
||||
assert body["pool_meta"]["question_count"] >= 1
|
||||
assert "score_distribution" in body
|
||||
assert "leaderboard" in body
|
||||
|
||||
|
||||
def test_projector_leaderboard_redacts_student_ids(client, sid):
|
||||
"""The /admin board carries student_ids; the public projector
|
||||
leaderboard must NOT — student_id namespace is private."""
|
||||
join_student(client, sid, "s1", "Alice")
|
||||
join_student(client, sid, "s2", "Bob")
|
||||
rooms = client.app.state.rooms
|
||||
client.portal.call(rooms.open_question, sid, 0, 5)
|
||||
client.portal.call(rooms.submit_answer, sid, "s1", 0, "B")
|
||||
client.portal.call(rooms.close_question, sid)
|
||||
|
||||
snapshot = client.get(f"/api/session/{sid}/projector").json()
|
||||
for row in snapshot["leaderboard"]:
|
||||
assert "student_id" not in row, "projector leaderboard leaks student_ids"
|
||||
|
||||
|
||||
def test_projector_ws_pushes_snapshot_on_state_change(client, sid):
|
||||
join_student(client, sid, "s1", "Alice")
|
||||
admin_login(client)
|
||||
with client.websocket_connect(f"/ws/projector/{sid}") as ws:
|
||||
initial = ws.receive_json()
|
||||
assert initial["type"] == "projector_state"
|
||||
assert initial["state"] == "lobby"
|
||||
|
||||
# Trigger a state change via the room manager directly.
|
||||
rooms = client.app.state.rooms
|
||||
client.portal.call(rooms.open_question, sid, 0, 5)
|
||||
push = ws.receive_json()
|
||||
assert push["type"] == "projector_state"
|
||||
assert push["state"] == "question_open"
|
||||
assert push["question"] is not None
|
||||
assert push["question"]["idx"] == 0
|
||||
|
||||
|
||||
def test_projector_404_for_unknown_sid(client):
|
||||
assert client.get("/api/session/UNKNOWN/projector").status_code == 404
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect("/ws/projector/UNKNOWN"):
|
||||
pass
|
||||
assert exc.value.code == 4001
|
||||
|
||||
|
||||
def test_projector_page_redirects_when_no_sid(client, sid):
|
||||
response = client.get("/projector/", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"].endswith(f"sid={sid}")
|
||||
@@ -11,6 +11,17 @@ def test_instructor_ws_requires_admin_cookie(client, sid):
|
||||
assert exc.value.code == 4001
|
||||
|
||||
|
||||
def _drain_until(ws, target_type, max_msgs=12):
|
||||
"""Helper: pull messages off `ws` until one matches `target_type`. Lets
|
||||
tests skip auxiliary state-tracking messages (presence_update,
|
||||
full_leaderboard) that fire as side-effects of state changes."""
|
||||
for _ in range(max_msgs):
|
||||
msg = ws.receive_json()
|
||||
if msg["type"] == target_type:
|
||||
return msg
|
||||
raise AssertionError(f"did not see message {target_type!r} after {max_msgs} attempts")
|
||||
|
||||
|
||||
def test_instructor_next_command_drives_full_loop(client, sid):
|
||||
"""The 'next' WS message drives the entire lifecycle:
|
||||
lobby → opens Q0 → closes Q0 + opens Q1 → ... → closes last + ends."""
|
||||
@@ -19,16 +30,14 @@ def test_instructor_next_command_drives_full_loop(client, sid):
|
||||
with client.websocket_connect(f"/ws/student/{sid}") as student_ws:
|
||||
assert student_ws.receive_json()["type"] == "state"
|
||||
with client.websocket_connect(f"/ws/instructor/{sid}") as admin_ws:
|
||||
# Drain lobby snapshot.
|
||||
assert admin_ws.receive_json()["type"] == "state"
|
||||
assert admin_ws.receive_json()["type"] == "lobby_update"
|
||||
# Drain lobby snapshot (state + lobby_update + presence_update).
|
||||
_drain_until(admin_ws, "presence_update")
|
||||
|
||||
# First "next" opens Q0 from lobby.
|
||||
admin_ws.send_json({"type": "next"})
|
||||
assert student_ws.receive_json()["type"] == "question_open"
|
||||
admin_open = admin_ws.receive_json()
|
||||
assert admin_open["type"] == "question_open"
|
||||
assert admin_ws.receive_json()["type"] == "live_histogram"
|
||||
_drain_until(admin_ws, "question_open")
|
||||
_drain_until(admin_ws, "live_histogram")
|
||||
|
||||
# Second "next" closes Q0 and opens Q1.
|
||||
admin_ws.send_json({"type": "next"})
|
||||
@@ -42,17 +51,16 @@ def test_instructor_close_then_next_emits_clean_open(client, sid):
|
||||
with client.websocket_connect(f"/ws/student/{sid}") as student_ws:
|
||||
assert student_ws.receive_json()["type"] == "state"
|
||||
with client.websocket_connect(f"/ws/instructor/{sid}") as admin_ws:
|
||||
assert admin_ws.receive_json()["type"] == "state"
|
||||
assert admin_ws.receive_json()["type"] == "lobby_update"
|
||||
_drain_until(admin_ws, "presence_update")
|
||||
admin_ws.send_json({"type": "open_question", "question_idx": 0, "time_limit": 2})
|
||||
assert student_ws.receive_json()["type"] == "question_open"
|
||||
assert admin_ws.receive_json()["type"] == "question_open"
|
||||
assert admin_ws.receive_json()["type"] == "live_histogram"
|
||||
_drain_until(admin_ws, "question_open")
|
||||
_drain_until(admin_ws, "live_histogram")
|
||||
|
||||
admin_ws.send_json({"type": "close_question"})
|
||||
assert student_ws.receive_json()["type"] == "question_closed"
|
||||
admin_msgs = [admin_ws.receive_json(), admin_ws.receive_json()]
|
||||
assert {m["type"] for m in admin_msgs} == {"question_closed", "full_leaderboard"}
|
||||
_drain_until(admin_ws, "question_closed")
|
||||
_drain_until(admin_ws, "full_leaderboard")
|
||||
|
||||
admin_ws.send_json({"type": "next"})
|
||||
assert student_ws.receive_json()["type"] == "question_open"
|
||||
@@ -62,16 +70,12 @@ def test_reset_command_returns_session_to_lobby(client, sid):
|
||||
join_student(client, sid, "s1", "Student One")
|
||||
admin_login(client)
|
||||
with client.websocket_connect(f"/ws/instructor/{sid}") as admin_ws:
|
||||
assert admin_ws.receive_json()["type"] == "state"
|
||||
assert admin_ws.receive_json()["type"] == "lobby_update"
|
||||
_drain_until(admin_ws, "presence_update")
|
||||
admin_ws.send_json({"type": "open_question", "question_idx": 0, "time_limit": 2})
|
||||
assert admin_ws.receive_json()["type"] == "question_open"
|
||||
assert admin_ws.receive_json()["type"] == "live_histogram"
|
||||
_drain_until(admin_ws, "question_open")
|
||||
_drain_until(admin_ws, "live_histogram")
|
||||
|
||||
admin_ws.send_json({"type": "reset"})
|
||||
# After reset, the instructor receives a state=lobby snapshot + lobby_update.
|
||||
msgs = []
|
||||
while len(msgs) < 2:
|
||||
msgs.append(admin_ws.receive_json())
|
||||
types = [m["type"] for m in msgs]
|
||||
assert "state" in types
|
||||
# After reset, instructor receives a state=lobby snapshot.
|
||||
msg = _drain_until(admin_ws, "state")
|
||||
assert msg["state"] == "lobby"
|
||||
|
||||
Reference in New Issue
Block a user