Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,16 @@ No rubric, scoring, round, or results logic changed. `get_team_details`/`format_
- `update_judge_assignment_details` (`PUT /api/judge/assignments/<id>`) always 400'd "Assignment not found" — it looked assignments up via `fetch_judge_assignments_by_judge_id("")` (an empty judge_id can never match a real assignment) instead of by the assignment's own id. Added `fetch_judge_assignment_by_id` (direct doc-get, `db/firestore.py` + `db/db.py`) and used that instead.
- `api/github/github_views.py`'s `/issues` route let a request through with no `org` (the service then 400'd with a 200 status, since the view unconditionally returned `jsonify(...)` with no status code), and logged `len(issues)` where `issues` is the service's response **dict** — that logged the dict's key count, not the issue count. Both fixed while adding `/activity` (see below).

### Judging — round locks, judge progress, bulk assignments (Oct 2026, `api/judging/`)
Built for the "15 judges hitting /judge at once" case; rubric/rounds/results math unchanged.
- **Round locks** live in their own collection `judging_round_locks` (doc id `<event_id>__<round>`, `{locked, locked_at, locked_by, locked_by_name, updated_at}`), read with ONE uncached doc-get on every judge write — deliberately NOT on the hackathon doc, whose getter is behind a 10-min per-worker cache and would lag a live awards ceremony. `POST /api/judge/score` and `POST /api/judge/draft` answer **409 `{error:"round_locked", round, event_id, message}`** once locked (checked AFTER the assignment check, BEFORE the write; an unknown round is 400 `invalid_round` first). A lock *read* failure fails OPEN (never block a judge over a flaky read). Routes: `GET /api/judge/locks/<event_id>` (any signed-in user — judges render read-only pages from it; the judge teams payload also embeds `locks`), `POST /api/judge/admin/locks/<event_id>/<round>` `{locked: bool}` (`judge.admin`, bool-validated, Slack-audited, `locked_by_name` best-effort via `get_propel_user_details_by_id`).
- **`GET /api/judge/admin/progress/<event_id>/<round>`** (`judge.admin`) — judge × team matrix computed server-side from 3 queries (assignments, scores incl. drafts, judge applications) + one team batch: per judge `assigned/submitted/draft/missing_team_ids`, `complete`, `last_submitted_at`; per team `assigned/submitted_judge_ids`; `summary` incl. `drafts_total` (drafts whose pair has since been SUBMITTED are not drafts) and `orphan_submitted` (scores from judges who no longer hold an assignment — the Results total can exceed the matrix; surface it, don't hide it); plus the round's `lock`. Replaces the admin UI's one-request-per-(judge,team) score polling.
- **`POST /api/judge/admin/assignments/bulk`** (`judge.admin`) `{event_id, round, panel_id, judge_ids[], team_ids[], room?, demo_times?{team_id: iso}, replace?}` — creates every missing (judge, team) pair for the panel in one request, idempotent on `(panel_id, round, judge, team)`, re-syncs `room`/`demo_time` on kept pairs, and with `replace: true` deletes the panel's pairs outside the cross product (admin removed a finalist/judge and re-saved). **Scores are never touched by assignment changes.** Caps: 100 judges × 200 teams.
- `get_judge_teams` now returns `event {title,start_date,end_date,timezone}` + `locks`, and resolves members through `_batch_users_for_teams` (ONE `db.get_all` for every member of every assigned team) with the old per-user `get_user_by_id` loop kept as the fallback when `users_by_id` is None (tests patch that path). `services/teams_service.py::get_teams_batch` chunks the `__name__ in [...]` query by 30 (Firestore's cap) and dedupes ids.
- Tests: `api/judging/tests/test_round_locks_and_progress.py` (service), `test_judging_views_locks.py` (routes, auth-stubbed Flask client — includes the view-signature-vs-URL-params check), `test_teams_batch_chunking.py`.
- **Score/draft writes are assignment-checked per event AND round** (`is_judge_assigned_for_round`), not "any event, any round" (`is_judge_assigned_to_team`, still used by the read-only `/team/<id>` route): the old check let a Round-1-only judge post a Round 2 score for a finalist they'd seen in Round 1, and it counted in Results. Locks are a fairness feature; don't loosen this back.
- **Judge-facing lock payloads are `public_lock_view`** (`locked`, `locked_at`, `updated_at` only) — `locked_by` is a PropelAuth id (PII per this file) and `locked_by_name` is admin-only (progress endpoint). **`_rows()` RAISES on a fetcher's `{"data": [], "error": …}`** instead of treating a failed Firestore read as "no rows" (that would have re-inserted every pair in bulk-assign and shown an empty progress board mid-round). **Bulk-assign refuses** `replace` that isn't a JSON bool (`"false"` is truthy), empty `judge_ids`/`team_ids` (`empty_cross_product` — an empty set with replace would wipe the panel; single removals go through `DELETE /assignments/<id>`), non-string ids, non-string `room`, and non-`{str: str|None}` `demo_times`; with `replace` it also collapses duplicate docs of a kept pair (the old per-pair POSTs could create them).

### `GET /api/github/activity?org&repo` — team dashboard "Code activity" card
`common/utils/github.py::get_repo_activity(org, repo)` makes **exactly 3** GitHub API calls (rate-limit budget, not completeness — this is a live-ish snapshot): `g.get_repo(f"{org}/{repo}")`, `repo.get_commits().get_page(0)` (first page only, ≤100 commits; a 409 "empty repository" is a valid all-zeros result for a fresh team repo, not an error), `repo.get_pulls(state="open").totalCount`. Contributors are derived from that single commit page (a `Counter` over each commit's author), not a separate stats call. `api/github/github_service.py::get_github_activity` validates `org`/`repo` against `^[A-Za-z0-9_.-]{1,100}$`, caches successes only for 300s (`_ACTIVITY_CACHE`, separate from the existing `_ISSUES_CACHE`), and translates `UnknownObjectException`→404 `repo_not_found`, `RateLimitExceededException`→503 `github_rate_limited`, anything else→502 `github_unavailable`. **Commits by `activity_excluded_logins()` (env `GITHUB_ACTIVITY_EXCLUDED_LOGINS`, default `gregv` — the GITHUB_TOKEN owner who seeds LICENSE + README via `create_github_repo`) are dropped before any counting**, matched on GitHub login or, for unlinked commits, the git author name; otherwise every new team repo showed 2 commits and the dashboard's "Push code" row was done before the team pushed anything.

Expand Down
57 changes: 56 additions & 1 deletion api/judging/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,4 +401,59 @@ Remove a judge panel.
9. **Error Handling**: Check for assignment verification errors
10. **Round Management**: Support multiple judging rounds per event
11. **Panel UI**: Build admin interface for panel and assignment management
12. **Feedback**: Support rich text feedback from judges
12. **Feedback**: Support rich text feedback from judges

---

## Round Locks (Oct 2026)

Admins can close scoring for a round. While locked, `POST /api/judge/score` and `POST /api/judge/draft` return **409**:

```json
{"error": "round_locked", "round": "round2", "event_id": "fall-2026", "message": "Scoring for this round is closed. Your score was not changed."}
```

### Get locks
**GET** `/api/judge/locks/{event_id}` — any authenticated user.

```json
{"locks": {"round1": {"locked": false, "locked_at": null, "updated_at": null}, "round2": {"locked": true, "locked_at": "2026-10-11T17:40:00-07:00", "updated_at": "2026-10-11T17:40:00-07:00"}}}
```

This is the public view: it never includes who locked the round. Admins see `locked_by` / `locked_by_name` in the progress response below.

`GET /api/judge/teams/{judge_id}/{event_id}` also embeds the same `locks` object and an `event` summary (`title`, `start_date`, `end_date`, `timezone`).

### Set a lock (Admin)
**POST** `/api/judge/admin/locks/{event_id}/{round_name}` — `judge.admin`. Body `{"locked": true}` (JSON boolean required). Returns `{"success": true, "lock": {...}}`.

## Judge Progress (Admin)
**GET** `/api/judge/admin/progress/{event_id}/{round_name}` — `judge.admin`.

One call that answers "who has sent in scores and who hasn't":

```json
{
"lock": {"locked": false},
"judges": [{"judge_id": "…", "name": "Ada", "email": "…", "assigned": 5, "submitted": 3, "complete": false,
"assigned_team_ids": ["…"], "submitted_team_ids": ["…"], "draft_team_ids": ["…"], "missing_team_ids": ["…"],
"last_submitted_at": "…"}],
"teams": [{"team_id": "…", "name": "Alpha", "assigned": 15, "submitted": 12, "assigned_judge_ids": ["…"], "submitted_judge_ids": ["…"]}],
"summary": {"judges_total": 15, "judges_complete": 9, "assignments_total": 75, "submitted_total": 58, "drafts_total": 4, "orphan_submitted": 0}
}
```

Incomplete judges sort first. A draft that has since been submitted is not counted as a draft.

## Bulk Assignments (Admin)
**POST** `/api/judge/admin/assignments/bulk` — `judge.admin`.

```json
{"event_id": "fall-2026", "round": "round2", "panel_id": "<panel doc id>", "judge_ids": ["…"], "team_ids": ["…"],
"room": "Main Room", "demo_times": {"<team_id>": "2026-10-11T16:40:00-07:00"}, "replace": true}
```

Creates every missing (judge, team) pair for the panel in one request (idempotent). `replace: true` (JSON boolean required) also deletes the panel's pairs that are no longer in the cross product and collapses duplicate docs. Scores are never modified. Validation (400): `replace_must_be_boolean`, `empty_cross_product` (both lists must be non-empty — an empty set with replace would wipe the panel), `ids_must_be_non_empty_strings`, `room_must_be_string`, `demo_times_must_map_team_id_to_iso_string`. Response: `{"success": true, "created": 12, "kept": 63, "updated": 0, "removed": 3, "total": 75}`.

### Assignment check on writes
`POST /api/judge/score` and `POST /api/judge/draft` require an assignment for the judge **in that event and that round** (403 otherwise). Reads of a team (`GET /api/judge/team/<id>`) still only require an assignment for the team in any round.
Loading
Loading