Skip to content

feat(judging): round locks, judge progress endpoint, bulk assignments — backend for the Fall 2026 judging overhaul - #307

Merged
gregv merged 2 commits into
developfrom
feat/judging-locks-progress
Oct 22, 2026
Merged

gregv merged 2 commits into
developfrom
feat/judging-locks-progress

Conversation

@gregv

@gregv gregv commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Backend half of the Fall 2026 judging overhaul (frontend PR opportunity-hack/frontend-ohack.dev#407). Built for ~15 judges scoring at once. The rubric, the two rounds and the results math are unchanged; this adds the controls the admin page needs and removes two correctness holes.

  • Round locks — new collection judging_round_locks (doc id <event_id>__<round>), read with ONE uncached doc-get on every judge write so flipping the lock takes effect immediately (the hackathon doc is behind a 10-min cache). POST /api/judge/score and POST /api/judge/draft answer 409 {error: "round_locked"} once locked. GET /api/judge/locks/<event_id> (any signed-in user; public view — never the locking admin's PropelAuth id or name) and POST /api/judge/admin/locks/<event_id>/<round> {locked: bool} (judge.admin, Slack-audited). A lock read failure fails open: a flaky read can never block a judge.
  • GET /api/judge/admin/progress/<event_id>/<round> (judge.admin) — "who has sent in scores and who hasn't": per judge assigned/submitted/draft/missing_team_ids + complete + last_submitted_at, per team assigned/submitted_judge_ids, a summary (incl. drafts_total, orphan_submitted) and the round's lock. One call; replaces the admin UI's one-request-per-(judge, team) polling.
  • POST /api/judge/admin/assignments/bulk (judge.admin) — every missing (judge, team) pair for a panel in one request, idempotent; replace: true prunes the panel's pairs outside the cross product and collapses duplicate docs. Scores are never touched. Input is strict: replace must be a JSON bool ("false" is truthy), empty lists are refused (empty_cross_product — an empty replace would wipe the panel), ids must be non-empty strings.
  • get_judge_teams embeds event {title, start_date, end_date, timezone} + public locks, resolves members with ONE db.get_all (was a serial get_user_by_id per member per team per judge) with the old loop as fallback, and prefers the Round 2 assignment for demo_time/room.
  • Correctness fixes found on the way: score/draft writes now require an assignment for this event and this round (is_judge_assigned_for_round) — the old "any event, any round" check let a Round-1-only judge post a Round 2 score that counted in results. _rows() raises when a Firestore fetcher reports {error} instead of treating a failed read as "no rows" (bulk would have re-inserted every pair; progress would have shown an empty board mid-round). services/teams_service.py::get_teams_batch chunks the __name__ in [...] query by 30 (Firestore's cap) and dedupes.
  • Docs: api/judging/README.md (new routes + semantics), CLAUDE.md judging section.

Deploy this before the frontend PR. Every new frontend call treats a 404 on these routes as "feature off".

Verification

  • pytest api/judging/tests api/teams/tests: 93 passed (new: test_round_locks_and_progress.py, test_judging_views_locks.py — auth-stubbed Flask client incl. the view-signature-vs-URL-params check —, test_teams_batch_chunking.py).
  • pylint -E on judging_service.py / judging_views.py: clean (CI only lints api/*.py).
  • Reviewed by Claude Opus 5.1 in a separate pass; all should-fix items addressed in the second commit (round-scoped check, strict bulk input, raise on failed reads, public lock view, duplicate collapse, dead branch).

Test plan (please click through — API level; the UI lives in the frontend PR)

Use a judge token and an admin token against the test backend (event_id = fall-2026).

  1. Judge payload: GET /api/judge/teams/<judge_propel_id>/fall-2026 → response now has event.title and locks.round1 / locks.round2 with only locked, locked_at, updated_at, event_id, round (no locked_by). GET /api/judge/locks/fall-2026 returns the same public shape.
  2. Lock flow: admin POST /api/judge/admin/locks/fall-2026/round2 {"locked": true} → 200 with lock.locked_by_name. Judge POST /api/judge/score with round: "round2" → 409 {error: "round_locked", round: "round2", message: …}; the same judge's round1 score still 200. POST /api/judge/draft for round2 → 409 too. Unlock with {"locked": false} → the round2 score goes through again. {"locked": "yes"} → 400 locked_must_be_boolean; /round3 → 400 invalid_round.
  3. Progress: GET /api/judge/admin/progress/fall-2026/round1 → every judge with a Round 1 assignment, incomplete judges first; a judge with only a saved draft for a team lists it under draft_team_ids and summary.drafts_total counts it; a draft whose score was later submitted is NOT a draft.
  4. Bulk assignments: POST the same {event_id, round, panel_id, judge_ids, team_ids} twice → first created: n, second created: 0, kept: n. Drop a team and resend with "replace": true → removed = number of judges. Send "replace": "false" → 400 replace_must_be_boolean; team_ids: [] → 400 empty_cross_product; judge_ids: [{"id": 1}] → 400 ids_must_be_non_empty_strings.
  5. Round-scoped write check: a judge assigned to a team only in round1 posting that team with round: "round2" → 403 "not assigned to this team for this round" (used to be accepted).
  6. Judge dashboard still shows members with names after the batched lookup (compare members[] of one team before/after deploy).

🤖 Generated with Claude Code

gregv and others added 2 commits October 21, 2026 23:04
…, batched member lookup

- judging_round_locks collection read uncached on every judge write; POST /score
  and /draft answer 409 round_locked once an admin locks the round (fail-open on
  read errors). GET /api/judge/locks/<event>, POST /api/judge/admin/locks/<event>/<round>.
- GET /api/judge/admin/progress/<event>/<round>: judge x team submitted/draft/
  missing matrix + summary + lock, computed server-side in 3 queries.
- POST /api/judge/admin/assignments/bulk: idempotent cross-product assignment
  creation with optional replace (prunes pairs outside the panel's set).
- get_judge_teams embeds event summary + locks and resolves members with ONE
  db.get_all instead of a serial get_user_by_id per member per team.
- get_teams_batch chunks the __name__ in query by 30 and dedupes ids.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ignment check, strict bulk input, raise on failed reads, public lock view

- /score and /draft now require an assignment for THIS event + round
  (is_judge_assigned_for_round); the any-round check let a Round-1-only judge
  post a Round 2 score that counted in results.
- bulk-assign: replace must be a JSON bool, empty judge/team lists are rejected
  (empty_cross_product — replace would otherwise wipe the panel), ids must be
  non-empty strings, room/demo_times typed; replace collapses duplicate docs.
- _rows() raises on a fetcher's {error} result instead of treating a failed
  Firestore read as no rows (bulk would re-insert everything; progress would
  show an empty board mid-round).
- GET /locks and the judge teams payload use public_lock_view (no locked_by /
  locked_by_name — PropelAuth ids are PII); admins keep the full lock.
- Dead except-TypeError branch removed; _locks_db renamed _db.
- Tests: batched member lookup (one get_all, missing docs dropped, failure →
  per-user fallback), get_judge_teams shape (event + public locks + round2
  slot preference), failed-read paths, bulk validation, duplicate collapse,
  round-scoped 403 at the route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
if is_round_locked(data['event_id'], data['round']):
info(logger, "Score rejected: round locked", judge_id=user_id,
team_id=data['team_id'], event_id=data['event_id'], round_name=data['round'])
return _round_locked_response(data['event_id'], data['round'])
return {"error": "Forbidden: Judge not assigned to this team for this round"}, 403

if is_round_locked(data['event_id'], data['round']):
return _round_locked_response(data['event_id'], data['round'])
"""Lock state for both rounds. Any signed-in user (judges read it to
render a read-only scoring page); it carries no scores."""
info(logger, "API called: GET /locks/{event_id}", event_id=event_id)
return {"locks": get_round_locks(event_id, public=True)}
result = set_round_lock(event_id, round_name, locked,
actor_id=user_id, actor_name=_actor_display_name(user_id))
if not result.get("success"):
return result, 400
actor_id=user_id, actor_name=_actor_display_name(user_id))
if not result.get("success"):
return result, 400
return result
return {"error": "invalid_round"}, 400
result = get_judging_progress(event_id, round_name)
if "error" in result:
return result, 500
result = get_judging_progress(event_id, round_name)
if "error" in result:
return result, 500
return result
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants