"""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