Repository navigation
feat(judging): round locks, judge progress endpoint, bulk assignments — backend for the Fall 2026 judging overhaul - #307
Merged
Conversation
…, 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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/scoreandPOST /api/judge/draftanswer 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) andPOST /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 judgeassigned/submitted/draft/missing_team_ids+complete+last_submitted_at, per teamassigned/submitted_judge_ids, asummary(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: trueprunes the panel's pairs outside the cross product and collapses duplicate docs. Scores are never touched. Input is strict:replacemust 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_teamsembedsevent {title, start_date, end_date, timezone}+ publiclocks, resolves members with ONEdb.get_all(was a serialget_user_by_idper member per team per judge) with the old loop as fallback, and prefers the Round 2 assignment fordemo_time/room.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_batchchunks the__name__ in [...]query by 30 (Firestore's cap) and dedupes.api/judging/README.md(new routes + semantics),CLAUDE.mdjudging 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 -Eonjudging_service.py/judging_views.py: clean (CI only lintsapi/*.py).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).GET /api/judge/teams/<judge_propel_id>/fall-2026→ response now hasevent.titleandlocks.round1/locks.round2with onlylocked,locked_at,updated_at,event_id,round(nolocked_by).GET /api/judge/locks/fall-2026returns the same public shape.POST /api/judge/admin/locks/fall-2026/round2{"locked": true}→ 200 withlock.locked_by_name. JudgePOST /api/judge/scorewithround: "round2"→ 409{error: "round_locked", round: "round2", message: …}; the same judge'sround1score still 200.POST /api/judge/draftfor round2 → 409 too. Unlock with{"locked": false}→ the round2 score goes through again.{"locked": "yes"}→ 400locked_must_be_boolean;/round3→ 400invalid_round.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 underdraft_team_idsandsummary.drafts_totalcounts it; a draft whose score was later submitted is NOT a draft.{event_id, round, panel_id, judge_ids, team_ids}twice → firstcreated: n, secondcreated: 0, kept: n. Drop a team and resend with"replace": true→removed= number of judges. Send"replace": "false"→ 400replace_must_be_boolean;team_ids: []→ 400empty_cross_product;judge_ids: [{"id": 1}]→ 400ids_must_be_non_empty_strings.round: "round2"→ 403 "not assigned to this team for this round" (used to be accepted).members[]of one team before/after deploy).🤖 Generated with Claude Code