feat(roster): gate joins on registered student-ID list

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."
This commit is contained in:
ameer
2026-05-05 22:02:03 +08:00
parent 19603abc58
commit 74c1745559
12 changed files with 289 additions and 7 deletions

View File

@@ -105,6 +105,7 @@ if [ ! -f "$ENV_FILE" ]; then
cat > "$ENV_FILE" <<EOF
QUIZ_DB_PATH=$APP_DIR/quiz.db
QUIZ_POOL_PATH=$APP_DIR/pool.json
QUIZ_ROSTER_PATH=$APP_DIR/roster.json
QUIZ_SECRET_KEY=$QUIZ_SECRET_KEY
QUIZ_ADMIN_PASSWORD=$QUIZ_ADMIN_PASSWORD
QUIZ_HOST=127.0.0.1

70
deploy/build_roster.py Normal file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Generate roster.json from a class-register XLSX.
Reads the first column (student IDs) and emits a JSON file the quiz app
loads at startup. Names from the second column, if present, are kept in
the JSON for human auditability but are NOT used for the gate.
Usage:
python deploy/build_roster.py <attendance.xlsx> [-o roster.json]
The XLSX is expected to have a header row, then one row per student.
Column 1 = student ID, column 2 = name (optional).
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def build(xlsx_path: Path, out_path: Path) -> int:
try:
import openpyxl
except ImportError:
print("openpyxl is required: pip install openpyxl", file=sys.stderr)
return 2
wb = openpyxl.load_workbook(xlsx_path)
ws = wb.worksheets[0]
students = []
seen: set[str] = set()
for row in ws.iter_rows(values_only=True):
if not row:
continue
sid_raw = row[0]
if sid_raw is None:
continue
sid = str(sid_raw).strip()
if not sid or sid in {"学号", "Student ID", "ID"}:
continue
if sid.upper() in seen:
continue
seen.add(sid.upper())
name = ""
if len(row) > 1 and row[1] is not None:
name = str(row[1]).strip()
students.append({"id": sid, "name": name})
payload = {
"source": str(xlsx_path),
"count": len(students),
"students": students,
}
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
print(f"Wrote {len(students)} students to {out_path}")
return 0
def main() -> int:
p = argparse.ArgumentParser(description="Build roster.json for the quiz app.")
p.add_argument("xlsx", type=Path, help="Path to attendance.xlsx")
p.add_argument("-o", "--out", type=Path, default=Path("roster.json"))
args = p.parse_args()
return build(args.xlsx, args.out)
if __name__ == "__main__":
sys.exit(main())