feat(public): published-only public standings page (checklist B) #8

Merged
hermes-agent merged 6 commits from feature/public-standings into main 2026-10-25 20:32:38 +00:00
Owner

Implements checklist item B (B1-B6): a no-login, read-only public standings page.

Decisions (owner, 2026-10-25)

  • Gate, not snapshot. A month/year appears publicly only while a Contest Manager has published it. Nothing is copied, so a correction after publishing is live at once and Unpublish is the brake. The point is that a ranking is never public while entries are still faulty or unverified.
  • Month-scoped landing page. Default month = this month once its contest date has been reached, otherwise the previous month. When that month has no published ranking the page says so explicitly, and on the contest day itself it names the contest day. No silent fallback to an older published month.
  • Dropdowns list every year and month; unpublished ones are marked and not selectable.
  • Upcoming-dates table: the rest of the year, always at least 6 rows (rolling into the next year when it runs out), plus next year's dates from Q3 onward.

What changed

  • feat(db): published_at / published_by_id on monthly_contest and contest_year, migration 0002_publication_markers.
  • feat(public): app/web/routes_public.py + templates/public/ — /results, /results/{year}/{month}, /results/{year}, /results/goto. Reuses the public-safe document builders, so no private field is even present in the template context (R25).
  • feat(manager): audited publish/unpublish for a month and a year, buttons on the dashboard, all through record_audit in the same transaction.
  • test(public): 27 new tests (gate, audit trail, default month, contest-day notice, dropdowns, upcoming table, privacy leak, i18n parity).
  • docs: checklist B1-B6 ticked, decisions recorded, future-work/design/HANDOVER updated (next item = C).

Verification

  • Full suite: 174 passed (147 before + 27 new).
  • Live stack (Postgres 18, alembic upgrade head -> 0002_publication_markers, real uvicorn): entrant uploads of corpus 01, 02, 12, 13, 14, 08, 09 through preview+submit, manager publish, then public pages. Published ranking shows all seven callsigns and the corpus's Section-A score 36; no private value on any public route; unpublish returns 404 and restores the "not published yet" notice; upcoming table rendered 14 dates (Nov/Dec 2026 + 2027).
  • No changes to parsing, scoring, rules or ingest; parsing/, scoring/, rules/, reference/ still import nothing from i18n.

Open questions from the old checklist for B are answered by the decisions above; nothing in this PR depends on the Dutch Locator Reference.

Implements checklist item B (B1-B6): a no-login, read-only public standings page. ## Decisions (owner, 2026-10-25) - **Gate, not snapshot.** A month/year appears publicly only while a Contest Manager has published it. Nothing is copied, so a correction after publishing is live at once and Unpublish is the brake. The point is that a ranking is never public while entries are still faulty or unverified. - **Month-scoped landing page.** Default month = this month once its contest date has been reached, otherwise the previous month. When that month has no published ranking the page says so explicitly, and on the contest day itself it names the contest day. No silent fallback to an older published month. - **Dropdowns** list every year and month; unpublished ones are marked and not selectable. - **Upcoming-dates table**: the rest of the year, always at least 6 rows (rolling into the next year when it runs out), plus next year's dates from Q3 onward. ## What changed - `feat(db)`: `published_at` / `published_by_id` on `monthly_contest` and `contest_year`, migration `0002_publication_markers`. - `feat(public)`: `app/web/routes_public.py` + `templates/public/` — `/results`, `/results/{year}/{month}`, `/results/{year}`, `/results/goto`. Reuses the public-safe document builders, so no private field is even present in the template context (R25). - `feat(manager)`: audited publish/unpublish for a month and a year, buttons on the dashboard, all through `record_audit` in the same transaction. - `test(public)`: 27 new tests (gate, audit trail, default month, contest-day notice, dropdowns, upcoming table, privacy leak, i18n parity). - `docs`: checklist B1-B6 ticked, decisions recorded, future-work/design/HANDOVER updated (next item = C). ## Verification - Full suite: **174 passed** (147 before + 27 new). - Live stack (Postgres 18, `alembic upgrade head` -> `0002_publication_markers`, real uvicorn): entrant uploads of corpus `01`, `02`, `12`, `13`, `14`, `08`, `09` through preview+submit, manager publish, then public pages. Published ranking shows all seven callsigns and the corpus's Section-A score 36; no private value on any public route; unpublish returns 404 and restores the "not published yet" notice; upcoming table rendered 14 dates (Nov/Dec 2026 + 2027). - No changes to parsing, scoring, rules or ingest; `parsing/`, `scoring/`, `rules/`, `reference/` still import nothing from i18n. Open questions from the old checklist for B are answered by the decisions above; nothing in this PR depends on the Dutch Locator Reference.
Adds published_at / published_by_id to monthly_contest and contest_year
(checklist B1). A non-NULL published_at means a Contest Manager approved that
month/year for the public page. Nothing is snapshotted: the marker is a gate
over live data, so a correction made after publishing is visible at once and
unpublishing is the brake.

Migration 0002, verified against Postgres 18 (alembic upgrade head).
New no-auth surface (checklist B3, B5):

- GET /results — month-scoped landing page. Default month is this month once
  its contest date has been reached, else the previous month; when that month
  has no published ranking the page says so explicitly and names the contest
  day when today is it (never falls back silently).
- GET /results/{year}/{month} — ranking for a published month, else 404.
- GET /results/{year} — Yearly Standing for a published year, else 404.
- GET /results/goto — dropdown navigation; unpublished targets bounce back.

Year and month dropdowns list everything, marking unpublished options and
disabling them. The upcoming-dates table shows the rest of the year with at
least six rows (rolling into the next year when it runs out) and adds next
year from Q3.

Tables are built by the existing public-safe document builders, so only
callsign/Section/afdeling/score/multipliers are ever in the context (R25).
New msgids added to both catalogs; jargon untouched.
Publish/unpublish buttons on the manager dashboard for each Monthly Contest
and each Contest Year (checklist B2). Both directions set or clear the
publication marker and write their AuditLogEntry through record_audit in the
same transaction, then redirect back (POST-redirect-GET). A published month
also links to the public page.
27 tests: unpublished month/year are 404; publishing exposes the ranking with
public fields only; unpublishing removes it; publish/unpublish are audited with
the acting manager; the default month follows this-month-or-previous; the
contest-day notice appears only on the day; the dropdowns list all twelve
months and mark unpublished ones; the upcoming table stays at >=6 dates and
adds next year from Q3; no private value appears on any public route; new keys
exist in both catalogs.
Ticks B1-B6, records the owner's decisions (gate not snapshot, month-scoped
landing, dropdown and upcoming-table rules), updates future-work/design to stop
listing the public page as deferred, and refreshes HANDOVER: public routes, the
new module, the gate rule in "hard-won rules", next item = C.
Independent review of PR #8 found real defects; this addresses them.

- Cross-year browsing was broken: one form posted both selects, so the month
  number belonged to the previously rendered year and any other year bounced
  back to /results. The year is now its own form (GET /results?year=YYYY) and
  the month form carries the selected year as a hidden field. An older year
  opens its most recently published month, so the archive is actually
  reachable without hand-built URLs.
- The year option's disabled state came from the *yearly standing* gate, which
  labelled a year "not published" right above that year's published ranking.
  Selectability now follows month publication, and the current year is always
  selectable.
- Publishing a year aggregated every month, including months a manager had
  deliberately withheld, exposing rankings the gate exists to keep private.
  A year can now only be published when no month with entries is unpublished
  (server-side guard + a dashboard hint naming the withheld months).
- Audit rows fabricated their "why" (reason=action). They record no reason
  unless the manager gives one. Action names now follow the codebase's
  noun.verb convention (monthly_results.publish, yearly_standing.unpublish).
- Loading contests once instead of per month: the landing page issued ~21 SQL
  statements per unauthenticated request, now it is bounded (regression test).
- Unpublished month/year now renders a 404 page in the site's chrome instead of
  FastAPI's JSON; a None Monthly Score renders as a dash instead of "None";
  an empty month offers no publish button; the yearly button says "year
  standing" instead of reusing the monthly label; nl.po says Contest Manager
  like the rest of the catalog.
- Tests: 8 more (live-gate, withheld-year refusal, archive year, selectable
  state, January rollover, HTML 404, empty-month control, query count).
- Docs: checklist B2 no longer contradicts "gate, not snapshot", design.md §4
  documents the new columns, HANDOVER states the real main commit and test
  count.
Author
Owner

Review round 1 — addressed in f11502e

An independent code review plus an independent functional tester both ran against the live stack. Findings and what happened to them:

Fixed (real defects)

  1. Cross-year browsing was broken — one form posted both selects, so the month number belonged to the previously rendered year and every other year bounced to /results. The year is now its own form (GET /results?year=YYYY) and the month form carries the selected year as a hidden field. An older year opens its most recently published month.
  2. The year option's state came from the wrong gate — it showed "nog niet gepubliceerd" right above that year's published October ranking. Selectability now follows month publication; the current year is always selectable.
  3. Publishing a year exposed withheld months. The year page aggregates every month, so publishing it re-published rankings the manager had deliberately kept private. A year can now only be published when no month with entries is unpublished (server guard + a dashboard hint naming the withheld months). This is the one behaviour I decided rather than asked — see the PR conversation if you prefer filtering the year page to published months instead.
  4. Audit rows fabricated their reason (reason=action) — now no reason unless the manager gives one; action names follow the codebase's noun.verb convention (monthly_results.publish, yearly_standing.unpublish).
  5. ~21 SQL statements per unauthenticated GET /results (per-month lookups) — contests load once now; regression test asserted at ≤5.
  6. Unpublished month/year renders a 404 page in the site's chrome instead of FastAPI JSON; a None Monthly Score renders as a dash; an empty month offers no publish button; the yearly button says "year standing"; nl.po says Contest Manager consistently.
  7. Docs: B2 no longer contradicts "gate, not snapshot"; design.md §4 documents the new columns; HANDOVER's main commit and test count corrected.

Verification after the fixes

  • Full suite: 182 passed (147 baseline + 27 feature + 8 review follow-ups).
  • Live stack re-checked: cross-year form posts correctly, year publish refused while October is withheld and allowed afterwards, 404 is HTML, newest audit rows read monthly_results.publish / yearly_standing.publish with reason=None.

Still open as an owner decision: whether the yearly standing should instead be computed over published months only (which would make the standings differ from the manager's authoritative figures) — the current behaviour refuses publication until nothing is withheld.

## Review round 1 — addressed in f11502e An independent code review plus an independent functional tester both ran against the live stack. Findings and what happened to them: **Fixed (real defects)** 1. **Cross-year browsing was broken** — one form posted both selects, so the month number belonged to the previously rendered year and every other year bounced to `/results`. The year is now its own form (`GET /results?year=YYYY`) and the month form carries the selected year as a hidden field. An older year opens its most recently published month. 2. **The year option's state came from the wrong gate** — it showed "nog niet gepubliceerd" right above that year's published October ranking. Selectability now follows month publication; the current year is always selectable. 3. **Publishing a year exposed withheld months.** The year page aggregates every month, so publishing it re-published rankings the manager had deliberately kept private. A year can now only be published when no month with entries is unpublished (server guard + a dashboard hint naming the withheld months). **This is the one behaviour I decided rather than asked — see the PR conversation if you prefer filtering the year page to published months instead.** 4. **Audit rows fabricated their reason** (`reason=action`) — now no reason unless the manager gives one; action names follow the codebase's `noun.verb` convention (`monthly_results.publish`, `yearly_standing.unpublish`). 5. **~21 SQL statements per unauthenticated `GET /results`** (per-month lookups) — contests load once now; regression test asserted at ≤5. 6. Unpublished month/year renders a 404 page in the site's chrome instead of FastAPI JSON; a `None` Monthly Score renders as a dash; an empty month offers no publish button; the yearly button says "year standing"; `nl.po` says Contest Manager consistently. 7. Docs: B2 no longer contradicts "gate, not snapshot"; `design.md` §4 documents the new columns; HANDOVER's main commit and test count corrected. **Verification after the fixes** - Full suite: **182 passed** (147 baseline + 27 feature + 8 review follow-ups). - Live stack re-checked: cross-year form posts correctly, year publish refused while October is withheld and allowed afterwards, 404 is HTML, newest audit rows read `monthly_results.publish` / `yearly_standing.publish` with `reason=None`. Still open as an owner decision: whether the yearly standing should instead be computed over published months only (which would make the standings differ from the manager's authoritative figures) — the current behaviour refuses publication until nothing is withheld.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
public/vrza-contestrobot!8
No description provided.