Adds an optional roster.json (set of allowed student IDs) loaded at startup. add_participant() raises StudentIdNotInRoster when the gate is on and the supplied id is not present; route returns 403 with a clear message and logs a roster_reject audit event. Names are NOT checked against the roster: the join form asks for a current name as a soft deterrent, but the only hard check is the id. Includes a deploy/build_roster.py helper that turns class_register attendance.xlsx into roster.json. Bootstrap env file now exports QUIZ_ROSTER_PATH; missing file disables the gate (legacy behaviour). Also drops the user-facing "The cookie is per-device." line from the join card — students don't need to know the implementation; replaced with "Enter your registered student ID and your current full name."
63 lines
2.3 KiB
Python
63 lines
2.3 KiB
Python
"""Roster gate for the join flow.
|
|
|
|
When a roster file is present, only student IDs listed there can join.
|
|
The check is case-insensitive and ignores surrounding whitespace, so a
|
|
trailing space or a lowercased prefix does not lock a legit student
|
|
out. Names are NOT checked against the roster — the join form asks for
|
|
a name purely so the instructor's presence panel and CSV export read
|
|
naturally; the roster acts as the access gate.
|
|
|
|
Roster file format is permissive: either a JSON array of IDs, or an
|
|
object with a `student_ids` key (list of strings) or a `students` key
|
|
(list of objects with an `id` field). Missing roster file means no gate
|
|
is applied (legacy behaviour).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
log = logging.getLogger("quiz.roster")
|
|
|
|
|
|
def _normalize(student_id: str) -> str:
|
|
return student_id.strip().upper()
|
|
|
|
|
|
def load_roster(path: str | Path) -> set[str] | None:
|
|
"""Return the set of normalized allowed student IDs, or None if no
|
|
roster file exists at `path` (gate disabled)."""
|
|
p = Path(path)
|
|
if not p.exists():
|
|
log.info("No roster file at %s — roster gate DISABLED.", p)
|
|
return None
|
|
try:
|
|
raw = json.loads(p.read_text())
|
|
except (json.JSONDecodeError, OSError) as exc:
|
|
log.error("Roster file %s could not be parsed: %s", p, exc)
|
|
return None
|
|
ids: list[str] = []
|
|
if isinstance(raw, list):
|
|
ids = [str(x) for x in raw]
|
|
elif isinstance(raw, dict):
|
|
if isinstance(raw.get("student_ids"), list):
|
|
ids = [str(x) for x in raw["student_ids"]]
|
|
elif isinstance(raw.get("students"), list):
|
|
ids = [str(s.get("id", "")) for s in raw["students"] if isinstance(s, dict)]
|
|
cleaned = {_normalize(i) for i in ids if i and i.strip()}
|
|
if not cleaned:
|
|
log.warning("Roster file %s parsed empty — gate DISABLED.", p)
|
|
return None
|
|
log.info("Roster gate ENABLED with %d allowed student IDs from %s.", len(cleaned), p)
|
|
return cleaned
|
|
|
|
|
|
def is_allowed(roster: set[str] | None, student_id: str) -> bool:
|
|
"""True if `student_id` passes the roster gate. If `roster` is None,
|
|
no gate is applied and every well-formed ID is allowed."""
|
|
if roster is None:
|
|
return True
|
|
return _normalize(student_id) in roster
|