Scoring-runden er ferdig og verifisert for ekte (samme scratch-container-mønster, denne gangen med hele /opt/teecup montert siden handicap_engine.py er et toppnivå-søskenmodul til app-pakken). Alle 10 planlagte tester besto, inkludert fourball better-ball-aggregering, poeng-caching ved tidlig avgjørelse, den sammenhengende-prefiks-regelen, og ADR-014-bryteren. Fant og fikset underveis at SessionCreate manglet scoring_mode helt — hole_result-modus kunne aldri vært brukt via API-et uten den fiksen.

Spørsmålene dine avdekket en reell bug (ikke bare et åpent spørsmål): siden ingenting eksplisitt lukker en match, kan noen fortsette å legge inn hull etter at matchen matematisk er avgjort, og det kan endre den cachede marginen ved neste omregning. Fanget i FEATURE_BACKLOG.md.

Basert på svarene dine:

Match-lås bygges snart — neste lille runde, tetter spøkelses-hull-buggen.
Walkover/konsesjon venter til brukerroller (kaptein/organisator) er avgjort.
Alt er dokumentert i FEATURE_BACKLOG.md under en ny seksjon, og CLAUDE.md er oppdatert.
This commit is contained in:
Erol Haagenrud 2026-07-16 14:38:42 +02:00
parent d68fbd89c9
commit b3659cc953
10 changed files with 805 additions and 60 deletions

View file

@ -58,7 +58,33 @@
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(len\\(d\\), 'spillere:', [p['display_name'] for p in d]\\)\")", "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(len\\(d\\), 'spillere:', [p['display_name'] for p in d]\\)\")",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"sequence\":2,\"format\":\"singles\",\"course_id\":\"00000000-0000-0000-0000-000000000000\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/sessions)", "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"sequence\":2,\"format\":\"singles\",\"course_id\":\"00000000-0000-0000-0000-000000000000\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/sessions)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"sequence\":2,\"format\":\"tullball\",\"course_id\":\"__TRACKED_VAR__\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/sessions)", "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"sequence\":2,\"format\":\"tullball\",\"course_id\":\"__TRACKED_VAR__\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/sessions)",
"Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/match_id.env)" "Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/match_id.env)",
"Bash(python3 -m py_compile app/main.py app/team_authz.py app/handicap.py app/routers/matches.py app/routers/scoring.py handicap_engine.py)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"sequence\":1,\"format\":\"singles\",\"course_id\":\"dddd4444-4444-4444-4444-444444444444\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/sessions)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"sequence\":1,\"team_a_id\":\"de111111-1111-1111-1111-111111111111\",\"team_b_id\":\"de222222-2222-2222-2222-222222222222\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/sessions/372f1e76-173e-4534-af60-4e458e26ad97/matches)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"a\",\"team_roster_id\":\"f0000001-0000-0000-0000-000000000001\",\"tee_id\":\"eeee5555-5555-5555-5555-555555555555\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/participants)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: b1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"b\",\"team_roster_id\":\"f0000003-0000-0000-0000-000000000003\",\"tee_id\":\"eeee5555-5555-5555-5555-555555555555\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/participants)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"sequence\":2,\"format\":\"fourball\",\"course_id\":\"dddd4444-4444-4444-4444-444444444444\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/sessions)",
"Bash(curl -s -o /dev/null -w 'A1 hull3: %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"a\",\"match_participant_id\":\"c81ed8c5-31f6-468f-8c10-8eba6d637fc3\",\"hole_number\":3,\"gross_strokes\":5}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
"Bash(curl -s -o /dev/null -w 'B1 hull3: %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: b1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"b\",\"match_participant_id\":\"0d94fe95-bac8-4851-9844-95a821dc8028\",\"hole_number\":3,\"gross_strokes\":4}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
"Bash(curl -s http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/scorecard)",
"Bash(curl -s -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/scorecard)",
"Bash(curl -s -o /dev/null -w 'hull1 A: %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"a\",\"match_participant_id\":\"c81ed8c5-31f6-468f-8c10-8eba6d637fc3\",\"hole_number\":1,\"gross_strokes\":4}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
"Bash(curl -s -o /dev/null -w 'hull1 B: %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: b1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"b\",\"match_participant_id\":\"0d94fe95-bac8-4851-9844-95a821dc8028\",\"hole_number\":1,\"gross_strokes\":4}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
"Bash(curl -s -o /dev/null -w 'hull2 A: %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"a\",\"match_participant_id\":\"c81ed8c5-31f6-468f-8c10-8eba6d637fc3\",\"hole_number\":2,\"gross_strokes\":5}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
"Bash(curl -s -o /dev/null -w 'hull2 B: %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: b1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"b\",\"match_participant_id\":\"0d94fe95-bac8-4851-9844-95a821dc8028\",\"hole_number\":2,\"gross_strokes\":3}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
"Bash(curl -s -o /dev/null -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"a\",\"match_participant_id\":\"3600ebd7-89d9-4c8d-aede-30a3a78e8e93\",\"hole_number\":1,\"gross_strokes\":5}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/76d682c9-10fb-4ec4-b837-d2762421eee1/hole-scores)",
"Bash(curl -s -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/76d682c9-10fb-4ec4-b837-d2762421eee1/scorecard)",
"Bash(curl -s -o /dev/null -w 'hull1\\(a\\): %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"hole_number\":1,\"winning_side\":\"a\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/d0bc2855-3aa9-44ba-a2db-da518d82a4c1/hole-results)",
"Bash(curl -s -o /dev/null -w 'hull2\\(delt\\): %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: b1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"hole_number\":2,\"winning_side\":null}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/d0bc2855-3aa9-44ba-a2db-da518d82a4c1/hole-results)",
"Bash(curl -s -o /dev/null -w 'hull3\\(b\\): %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"hole_number\":3,\"winning_side\":\"b\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/d0bc2855-3aa9-44ba-a2db-da518d82a4c1/hole-results)",
"Bash(curl -s -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/d0bc2855-3aa9-44ba-a2db-da518d82a4c1/scorecard)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"a\",\"hole_number\":15,\"gross_strokes\":4}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"hole_number\":15,\"winning_side\":\"a\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-results)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"a\",\"match_participant_id\":\"c81ed8c5-31f6-468f-8c10-8eba6d637fc3\",\"hole_number\":1,\"gross_strokes\":6}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: b2000000-0000-0000-0000-000000000002' -H 'Content-Type: application/json' -d '{\"team_side\":\"a\",\"match_participant_id\":\"c81ed8c5-31f6-468f-8c10-8eba6d637fc3\",\"hole_number\":16,\"gross_strokes\":5}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -H 'X-Debug-User-Id: __TRACKED_VAR__' __TRACKED_VAR__/orgs/__TRACKED_VAR__/matches/__TRACKED_VAR__/scorecard)",
"Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/hr_ids.env)"
] ]
} }
} }

View file

@ -57,24 +57,34 @@ Ferdig og verifisert:
deltakere/blind draw-lås, ADR-013-synlighet push-down i SQL). Delt deltakere/blind draw-lås, ADR-013-synlighet push-down i SQL). Delt
feiloversettelse i `app/errors.py`, delte synlighetsspørringer i feiloversettelse i `app/errors.py`, delte synlighetsspørringer i
`app/blind_draw.py`. `main.py` er nå bare app-factory + `include_router`. `app/blind_draw.py`. `main.py` er nå bare app-factory + `include_router`.
**Bevisst utelatt:** `course_handicap`/`playing_handicap` på - Scoring-runden er bygget og verifisert for ekte mot scratch-db (18-hulls
`match_participant` beregnes IKKE ennå (venter på motor-integrasjon i bane med `tee_rating`, 4 spillere for fourball-testing): `app/handicap.py`
scoring-runden — flerspillerformater trenger hele sidens spillere samtidig). (ADR-014 fire brytere via `parse_allowance_config`, handicap beregnes i
**Bevisst minimal autorisasjon:** kapteins-only er ikke bygget (se `compute_and_store_side_handicaps` rett etter deltaker-innsetting —
FEATURE_BACKLOG ❓); bar er i dag org-medlemskap for oppsett, og singles/fourball per spiller umiddelbart, foursome/greensome/scramble kun
"rostret på laget" for deltaker-/lås-handlinger. når siden er komplett), `app/routers/scoring.py` (`hole-scores`/
`hole-results`-upsert, `scorecard`-GET, matchstatus-recompute med
`FOR UPDATE`-lås mot race og SAMMENHENGENDE-prefiks-regel for uferdige
hull). `app/team_authz.py` skilt ut fra `matches.py` (delt med
`scoring.py`). Alle 10 planlagte tester bestått, inkl. fourball
better-ball-aggregering (MIN av to nettoer, venter til begge partnere har
registrert), poeng-caching ved tidlig avgjort match, og ADR-014-bryteren
`use_handicap=false`.
**Fant og fikset underveis:** `tournaments.py` sin `SessionCreate` manglet
`scoring_mode` helt (økter kunne aldri opprettes i `hole_result`-modus via
API-et) — lagt til.
**Bevisst utelatt/kjente begrensninger:** en side som aldri når forventet
deltakerantall (no-show) får aldri beregnet handicap og matchen kan da
aldri avgjøres — ingen manuell overstyring bygget. Score-skriving er
upsert (ingen avvisning ved duplikat) — ingen audit-trail på rettelser.
Kapteins-only autorisasjon fortsatt ikke bygget (FEATURE_BACKLOG ❓); bar
er «rostret på laget».
Neste steg: Neste steg:
1. Scoring-endepunkter: `hole_score`/`match_hole_result`-skriving, 1. Containerisere TeeCup-API-et (Dockerfile + compose-tjeneste), koble mot
matchstatus fra `handicap_engine.compute_match_state`, og
handicap-beregning (`course_handicap`/`playing_handicap`) for
`match_participant` via motoren. Bygg samtidig inn de fire konfigurerbare
bryterne fra ADR-014 (bruk handicap / bruk course handicap / hcp-prosent /
bruk matchplay-handicap) i `session.allowance_override` — IKKE bare
prosenten. Motoren støtter allerede alle fire som atskilte kall
(`course_handicap_raw`, en `AllowanceStrategy`, `match_play_strokes`); det
som mangler er at API-et leser bryterne og hopper over/kaller riktig steg.
2. Containerisere TeeCup-API-et (Dockerfile + compose-tjeneste), koble mot
`teecup_db` med `teecup_app`, rute via eksisterende Caddy til `teecup_db` med `teecup_app`, rute via eksisterende Caddy til
`teecup.teeoff.no`. `teecup.teeoff.no`. (Under scratch-verifisering måtte hele `/opt/teecup`
3. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 004). monteres, ikke bare `app/`, fordi `handicap_engine.py` er et
toppnivå-søskenmodul til `app`-pakken — Dockerfilen må `COPY` begge inn
med samme relative plassering.)
2. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 004).

View file

@ -23,9 +23,10 @@
| Spiller-pool m/ reserver, delvis deltakelse | ✅ | ADR-007. | | Spiller-pool m/ reserver, delvis deltakelse | ✅ | ADR-007. |
| Multi-tenant skjema + RLS | ✅ | ADR-001/003. Isolasjon bevist med oppførselstest. | | Multi-tenant skjema + RLS | ✅ | ADR-001/003. Isolasjon bevist med oppførselstest. |
| Dedikert app-rolle (ingen superuser/BYPASSRLS) | ✅ | migrasjon 002. | | Dedikert app-rolle (ingen superuser/BYPASSRLS) | ✅ | migrasjon 002. |
| API: oppsett (spillere/lag/roster/økter/matcher/blind draw) | ✅ | Verifisert for ekte mot scratch-db + engangscontainer. Scoring gjenstår. | | API: oppsett (spillere/lag/roster/økter/matcher/blind draw) | ✅ | Verifisert for ekte mot scratch-db + engangscontainer. |
| API: scoring (hole_score/match_hole_result, matchstatus, handicap-beregning) | ✅ | ADR-012/014. Verifisert for ekte, inkl. fourball better-ball og race-sikker recompute. |
| Banedata fra teeoff via API | 🔀 | ADR-004. Endret fra Geminis «delt database direkte». | | Banedata fra teeoff via API | 🔀 | ADR-004. Endret fra Geminis «delt database direkte». |
| Konfigurerbar handicap-pipeline (4 brytere) | 📋 | ADR-014. Motoren støtter alle fire steg (course handicap / allowance / matchplay-hcp / helt av) som atskilte kall; API/skjema eksponerer dem ikke som brytere ennå. Bygges sammen med scoring-endepunktene. | | Konfigurerbar handicap-pipeline (4 brytere) | ✅ | ADR-014. Bygget i `app/handicap.py`, brukt av scoring-runden. |
--- ---
@ -40,21 +41,60 @@
offentlige feeden (se Kommunikasjon). Kaptein-rollen bør kanskje gi spesifikke offentlige feeden (se Kommunikasjon). Kaptein-rollen bør kanskje gi spesifikke
rettigheter (sette oppstilling), ikke bare være et flagg. rettigheter (sette oppstilling), ikke bare være et flagg.
### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16)
- **Status:** ❓ trenger beslutning — direkte oppfølger av «Brukerroller» over.
- **Hvem fører score i dag:** alle med en `team_roster`-rad på laget (samme
minimale grense som deltaker/lås, se ADR-013-relatert kode). Ikke
kaptein-only, ikke begrenset til de(n) som faktisk spiller matchen.
- **Hvem kan korrigere en ført score i dag:** akkurat de samme — skriving er en
upsert (`ON CONFLICT ... DO UPDATE`), så en korrigering er ikke skilt fra en
førstegangs-innføring. Ingen godkjenning fra motstanderen, ingen audit-trail
(forrige verdi overskrives sporløst).
- **Hvem kan «lukke» en match eller turnering i dag:** INGEN eksplisitt
handling finnes. En match sin `status_text`/poeng blir bare cached
automatisk når `handicap_engine.compute_match_state` sier den er avgjort
(eller 18 hull er spilt) — det er ikke en handling noen utfører. **Reell,
ikke-teoretisk konsekvens oppdaget ved gjennomgang av dette:** ingenting
hindrer at noen fortsetter å legge inn hull ETTER at matchen matematisk er
avgjort (f.eks. «10&5» ved hull 13) — blir hull 14-18 likevel registrert,
regnes de med i sekvensen ved neste rekalkulering og kan endre den cachede
marginen, siden `compute_match_state` teller alle sammenhengende avgjorte
hull, ikke bare fram til avgjørelsespunktet. Ingen sperre bygget for dette.
Turnering har et `status`-felt (draft/active/completed/archived) men
INGEN endepunkt endrer det ennå — organisator kan ikke markere en turnering
ferdig via API-et i dag.
- **Manglende WO/konsesjon:** hvis en side aldri stiller nok spillere
(`match_participant`-antallet når aldri det økten krever), beregnes
handicap ALDRI (se `app/handicap.py`), og matchen kan derfor ALDRI få et
hull-resultat — den blir hengende uavgjort for alltid. Det finnes ingen
«gi bort hullet/matchen/turneringen»-mekanisme (walkover/konsesjon) i det
hele tatt ennå — verken datamodell eller endepunkt.
- **Avgjort 2026-07-16:**
- **Match-lås bygges SNART** (neste lille runde) — eksplisitt handling som
blokkerer videre hull-innlegging etter at en match er ferdig, og tetter
«spøkelses-hull etter avgjørelse»-buggen over. Hvem som får lås/åpne den
(kaptein? organisator? samme «rostret på laget»-bar som resten?) avgjøres
når den bygges — se punkt (a)/(b) fortsatt åpne under.
- **Walkover/konsesjon VENTER** til brukerroller (kaptein/organisator,
punktet over) er avgjort — å bygge den nå på dagens løse
«rostret på laget»-grense betyr sannsynligvis å bygge den om senere.
- **Fortsatt åpent:** (a) skal score-føring begrenses til faktiske
matchdeltakere (ikke bare «noen på laget»)? (b) skal korrigering kreve
motpartens godkjenning, eller er upsert-modellen god nok for v1? (c) skal
turnering-status (draft/active/completed/archived) kunne settes via API?
### Blind draw (skjult lagoppstilling) ### Blind draw (skjult lagoppstilling)
- **Status:** 🔨 skjema bygget (migrasjon 003, `lineup_lock`) — app-logikk gjenstår - **Status:** ✅ skjema (migrasjon 003, `lineup_lock`) + API bygget og verifisert
(`app/routers/matches.py`: synlighetsfilter i SQL, ikke Python-filter — se ADR-013).
- Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er - Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er
ferdige. ferdige.
- **Skjemakonsekvens:** oppstillinger trenger en tilstand «utkast → låst → - **Gjenstår:** hvem som FÅR låse et lag er i dag bare «rostret på laget», ikke
avslørt» før `match_participant` gjøres synlig. Egen `lineup_submission`-modell kaptein-spesifikt — se «Brukerroller» over.
e.l. Ikke bygget.
### Forenklet scoreføring (uten slagtall) ### Forenklet scoreføring (uten slagtall)
- **Status:** 🔨 skjema bygget (migrasjon 003) — begge moduser besluttet - **Status:** ✅ skjema (migrasjon 003) + API bygget og verifisert
- Ønske: kunne registrere ENTEN slag per hull ELLER bare «Lag rød vant hullet / (`app/routers/scoring.py`: `hole-scores` for `stroke`-modus,
delt». `hole-results` for `hole_result`-modus, begge mater samme `compute_match_state`).
- **Skjemakonsekvens:** dagens `hole_score.gross_strokes` er NOT NULL. En ren
hull-resultat-modus krever enten at slag kan være NULL + et resultatfelt, eller
en egen `match_hole_result`-tabell. Må avgjøres før scoring-API-et.
### Bøtekasse (Kangaroo Court) ### Bøtekasse (Kangaroo Court)
- **Status:** 📋 planlagt - **Status:** 📋 planlagt

199
app/handicap.py Normal file
View file

@ -0,0 +1,199 @@
"""
DB-bevisst bro mellom handicap_engine.py (ren, uten DB-avhengigheter) og
Postgres. Se ADR-005 (allowance er konfig), ADR-014 (fire uavhengige brytere),
ADR-007 (handicap_index_snapshot fryses i team_roster).
Kjent, bevisst begrensning (se plan): hvis en side aldri når forventet
deltakerantall, eller en deltakers tee mangler en matchende tee_rating-rad,
beregnes handicap for den siden ALDRI automatisk her -- ingen bakgrunnsjobb
prøver nytt. Dette løses senere (sannsynligvis en organisator-overstyring).
"""
from __future__ import annotations
from dataclasses import dataclass
from asyncpg import Connection
from handicap_engine import (
AllowanceStrategy,
CombinedPercentage,
DEFAULT_MATCHPLAY_ALLOWANCES,
Format,
PerPlayerPercentage,
RankedSplit,
WeightedLowHigh,
course_handicap_raw,
match_play_strokes,
round_half_up,
)
# Formater der ENHETEN som deler én Playing Handicap er SIDEN (foursome-paret,
# greensome-paret, hele scramble-laget) -- ikke spilleren. Singles/fourball er
# ikke med her: der er enheten spilleren, og beregnes individuelt.
_SIDE_IS_UNIT = {"foursome", "greensome", "scramble_2", "scramble_4"}
FORMAT_UNIT_SIZE: dict[str, int] = {
"singles": 1,
"fourball": 1,
"foursome": 2,
"greensome": 2,
"scramble_2": 2,
"scramble_4": 4,
}
@dataclass(frozen=True)
class AllowanceConfig:
use_handicap: bool
use_course_handicap: bool
use_matchplay_handicap: bool
strategy: AllowanceStrategy
def _strategy_from_json(format_: str, spec: dict | None) -> AllowanceStrategy:
if spec is None:
return DEFAULT_MATCHPLAY_ALLOWANCES[Format(format_)]
kind = spec["type"]
if kind == "per_player":
return PerPlayerPercentage(spec["percentage"])
if kind == "combined":
return CombinedPercentage(spec["percentage"])
if kind == "weighted_low_high":
return WeightedLowHigh(spec["low_weight"], spec["high_weight"])
if kind == "ranked_split":
return RankedSplit(tuple(spec["weights"]))
raise ValueError(f"Ukjent allowance-strategitype: {kind!r}")
def parse_allowance_config(format_: str, allowance_override: dict | None) -> AllowanceConfig:
"""ADR-014: fire uavhengige brytere. Manglende nøkler = på/standard."""
override = allowance_override or {}
return AllowanceConfig(
use_handicap=override.get("use_handicap", True),
use_course_handicap=override.get("use_course_handicap", True),
use_matchplay_handicap=override.get("use_matchplay_handicap", True),
strategy=_strategy_from_json(format_, override.get("strategy")),
)
async def compute_and_store_side_handicaps(
conn: Connection,
match_id: str,
team_side: str,
format_: str,
hole_config: str,
config: AllowanceConfig,
) -> None:
"""Beregn og lagre course_handicap/playing_handicap for én side i en match.
Kalles fra matches.py sin add_participant RETT ETTER hver innsetting.
Avgjør selv om den er klar til å beregne:
- singles/fourball (enhet = spiller): beregner uansett, for hver
deltaker som finnes (idempotent -- trygt å kalle flere ganger).
- foursome/greensome/scramble (enhet = side): beregner KUN når siden har
nøyaktig FORMAT_UNIT_SIZE[format] deltakere OG alle har en matchende
tee_rating -- ellers returnerer den uten å skrive noe (se
modul-docstring).
"""
participants = await conn.fetch(
"""
SELECT mp.id::text AS id,
tr.handicap_index_snapshot::float AS handicap_index_snapshot,
tee_rating.course_rating::float AS course_rating,
tee_rating.slope_rating::float AS slope_rating,
tee_rating.par AS par
FROM match_participant mp
JOIN team_roster tr ON tr.id = mp.team_roster_id
JOIN tee_rating ON tee_rating.tee_id = mp.tee_id AND tee_rating.scope = $3::rating_scope
WHERE mp.match_id = $1 AND mp.team_side = $2::team_side
""",
match_id,
team_side,
hole_config,
)
if format_ in _SIDE_IS_UNIT:
expected = FORMAT_UNIT_SIZE[format_]
total_on_side = await conn.fetchval(
"SELECT count(*) FROM match_participant WHERE match_id = $1 AND team_side = $2::team_side",
match_id,
team_side,
)
if total_on_side != expected or len(participants) != expected:
return # ikke komplett ennå, eller mangler tee_rating for noen
if not participants:
return
if not config.use_handicap:
for p in participants:
await conn.execute(
"UPDATE match_participant SET course_handicap = 0, playing_handicap = 0 WHERE id = $1",
p["id"],
)
return
course_handicaps = [
course_handicap_raw(p["handicap_index_snapshot"], p["slope_rating"], p["course_rating"], p["par"])
if config.use_course_handicap
else p["handicap_index_snapshot"]
for p in participants
]
if format_ in _SIDE_IS_UNIT:
playing = config.strategy.playing_handicap(course_handicaps)
for p, ch in zip(participants, course_handicaps):
await conn.execute(
"UPDATE match_participant SET course_handicap = $1, playing_handicap = $2 WHERE id = $3",
round_half_up(ch),
playing,
p["id"],
)
else:
for p, ch in zip(participants, course_handicaps):
playing = config.strategy.playing_handicap([ch])
await conn.execute(
"UPDATE match_participant SET course_handicap = $1, playing_handicap = $2 WHERE id = $3",
round_half_up(ch),
playing,
p["id"],
)
async def relative_strokes_for_match(
conn: Connection, match_id: str, format_: str, config: AllowanceConfig
) -> dict[str, int]:
"""Relative slag per scoringsenhet i matchen.
Nøkkel = match_participant_id for singles/fourball (fourball: ALLE fire
spillerne i matchen samlet i én liste før match_play_strokes -- jf. R&A-
regelen om at laveste av alle fire spiller av scratch, ikke side-vis).
Nøkkel = team_side ('a'/'b') for foursome/greensome/scramble.
Returnerer {} hvis handicap ikke er beregnet for én eller flere enheter
ennå (se compute_and_store_side_handicaps) -- kalleren tolker det som
"kan ikke avgjøre noen hull ennå".
"""
if format_ in _SIDE_IS_UNIT:
rows = await conn.fetch(
"""
SELECT DISTINCT ON (team_side) team_side::text AS unit, playing_handicap
FROM match_participant
WHERE match_id = $1
ORDER BY team_side
""",
match_id,
)
else:
rows = await conn.fetch(
"SELECT id::text AS unit, playing_handicap FROM match_participant WHERE match_id = $1",
match_id,
)
if not rows or any(r["playing_handicap"] is None for r in rows):
return {}
absolutes = [r["playing_handicap"] for r in rows]
relative = match_play_strokes(absolutes) if config.use_matchplay_handicap else absolutes
return {r["unit"]: v for r, v in zip(rows, relative)}

View file

@ -4,7 +4,7 @@ TeeCup API.
App-factory + livssyklus. All forretningslogikk ligger i app/routers/ hver App-factory + livssyklus. All forretningslogikk ligger i app/routers/ hver
rute der følger samme mønster: Depends(get_authorized_org) verifiserer rute der følger samme mønster: Depends(get_authorized_org) verifiserer
medlemskap, org_connection(organization_id) setter RLS-konteksten lokalt for medlemskap, org_connection(organization_id) setter RLS-konteksten lokalt for
transaksjonen. Se ARCHITECTURE_DECISIONS.md (ADR-001013) for reglene bak. transaksjonen. Se ARCHITECTURE_DECISIONS.md (ADR-001014) for reglene bak.
Kjøres i app-containeren (FastAPI + asyncpg + uvicorn): Kjøres i app-containeren (FastAPI + asyncpg + uvicorn):
uvicorn app.main:app --host 0.0.0.0 --port 8000 uvicorn app.main:app --host 0.0.0.0 --port 8000
@ -15,7 +15,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from .db import init_pool, close_pool from .db import init_pool, close_pool
from .routers import matches, players, tournaments from .routers import matches, players, scoring, tournaments
@asynccontextmanager @asynccontextmanager
@ -32,6 +32,7 @@ app = FastAPI(title="TeeCup API", lifespan=lifespan)
app.include_router(players.router) app.include_router(players.router)
app.include_router(tournaments.router) app.include_router(tournaments.router)
app.include_router(matches.router) app.include_router(matches.router)
app.include_router(scoring.router)
@app.get("/health") @app.get("/health")

View file

@ -13,6 +13,8 @@ er lagt i SQL (WHERE ... AND ($revealed OR team_id = ANY($egne_lag))), ikke et
etterfølgende Python-filter, slik at lekkasje er strukturelt umulig. etterfølgende Python-filter, slik at lekkasje er strukturelt umulig.
""" """
import json
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -20,6 +22,8 @@ from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..blind_draw import locked_team_ids, own_team_ids from ..blind_draw import locked_team_ids, own_team_ids
from ..db import org_connection from ..db import org_connection
from ..errors import translate_db_errors from ..errors import translate_db_errors
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
from ..team_authz import user_may_act_for_team
router = APIRouter() router = APIRouter()
@ -50,21 +54,6 @@ class MatchOut(BaseModel):
participants: list[MatchParticipantOut] participants: list[MatchParticipantOut]
async def _user_may_act_for_team(conn, team_id: str, user_id: str) -> bool:
"""Brukeren har en team_roster-rad på laget (se autorisasjonsgrense i toppen)."""
return await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM team_roster tr
JOIN player p ON p.id = tr.player_id
WHERE tr.team_id = $1 AND p.user_id = $2
)
""",
team_id,
user_id,
)
@router.post( @router.post(
"/orgs/{organization_id}/sessions/{session_id}/matches", "/orgs/{organization_id}/sessions/{session_id}/matches",
response_model=MatchOut, response_model=MatchOut,
@ -235,7 +224,12 @@ async def add_participant(
) )
session = await conn.fetchrow( session = await conn.fetchrow(
"SELECT course_id::text AS course_id FROM session WHERE id = $1", match["session_id"] """
SELECT course_id::text AS course_id, format, hole_config::text AS hole_config,
allowance_override::text AS allowance_override
FROM session WHERE id = $1
""",
match["session_id"],
) )
tee_course_id = await conn.fetchval( tee_course_id = await conn.fetchval(
"SELECT course_id::text FROM tee WHERE id = $1", body.tee_id "SELECT course_id::text FROM tee WHERE id = $1", body.tee_id
@ -245,7 +239,7 @@ async def add_participant(
status.HTTP_400_BAD_REQUEST, detail="tee_id tilhører ikke øktens bane." status.HTTP_400_BAD_REQUEST, detail="tee_id tilhører ikke øktens bane."
) )
if not await _user_may_act_for_team(conn, expected_team_id, user.user_id): if not await user_may_act_for_team(conn, expected_team_id, user.user_id):
raise HTTPException( raise HTTPException(
status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget." status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget."
) )
@ -257,11 +251,6 @@ async def add_participant(
detail="Laget har allerede låst oppstillingen for denne økten.", detail="Laget har allerede låst oppstillingen for denne økten.",
) )
# course_handicap/playing_handicap settes bevisst IKKE her — de krever
# hele sidens spillere samtidig for foursome/greensome/scramble
# (handicap_engine sine WeightedLowHigh/RankedSplit-strategier). Hører
# til motor-integrasjonen i scoring-runden, ikke ren strukturell
# oppsett (kolonnene er nullable).
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
WITH inserted AS ( WITH inserted AS (
@ -284,6 +273,18 @@ async def add_participant(
body.team_roster_id, body.team_roster_id,
body.tee_id, body.tee_id,
) )
# Beregn/lagre course_handicap/playing_handicap nå som deltakeren er
# satt inn. Funksjonen avgjør selv om siden er "klar" (singles/
# fourball: alltid; foursome/greensome/scramble: kun når siden er
# komplett) -- se app/handicap.py.
allowance_override = (
json.loads(session["allowance_override"]) if session["allowance_override"] else None
)
config = parse_allowance_config(session["format"], allowance_override)
await compute_and_store_side_handicaps(
conn, match_id, body.team_side, session["format"], session["hole_config"], config
)
return MatchParticipantOut(**dict(row)) return MatchParticipantOut(**dict(row))
@ -312,7 +313,7 @@ async def lock_lineup(
if session is None: if session is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Økten finnes ikke.") raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Økten finnes ikke.")
if not await _user_may_act_for_team(conn, body.team_id, user.user_id): if not await user_may_act_for_team(conn, body.team_id, user.user_id):
raise HTTPException( raise HTTPException(
status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget." status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget."
) )

439
app/routers/scoring.py Normal file
View file

@ -0,0 +1,439 @@
"""
Scoring (ADR-012): to moduser per økt, cachet matchstatus.
'stroke' -> hole_score (brutto per hull, motoren utleder netto/hull-resultat).
'hole_result' -> match_hole_result (bare hvem som vant hullet, ingen motor for
selve hull-resultatet -- handicapen er allerede bakt inn i
match_participant fra oppsettsrunden).
Autorisasjon: samme "rostret på laget"-grense som matches.py (team_authz.py),
IKKE kapteins-only ennå (FEATURE_BACKLOG: åpent spørsmål).
Individuell-vs-delt-ball (FEATURE_BACKLOG sitt app-lags-punkt, lukkes her):
singles/fourball ha match_participant_id; foursome/greensome/scramble
IKKE ha det. Ingenting i skjemaet håndhever dette.
Upsert i stedet for avvisning ved dobbel-innsending -- bevisst valg for at
live-scoring skal tåle rettelser. Ingen audit-trail korrigeringer ennå.
Matchstatus-recompute låser match-raden FØRST (FOR UPDATE) for å serialisere
samtidige scoreinnsendinger for SAMME match (typisk to fourball-partnere som
taster inn ulike hull samtidig) -- uten det kan to transaksjoner lese
hverandres data før commit og siste UPDATE kan overskrive en mer komplett
status med en mindre komplett.
"Hopp over uferdige hull": compute_match_state teller POSISJON i listen
(len(hole_results)), ikke hullnummer. Derfor tas kun den SAMMENHENGENDE
prefiksen fra hull 1 og ut til første hull som ikke kan avgjøres -- aldri hull
lenger ute i sekvensen, selv om de tilfeldigvis er komplette, siden det ville
forskjøvet holes_remaining/is_dormie/is_closed for resten av matchen.
"""
import json
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from handicap_engine import HoleResult, allocate_over_played_holes, compute_match_state
from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..db import org_connection
from ..errors import translate_db_errors
from ..handicap import parse_allowance_config, relative_strokes_for_match
from ..team_authz import user_may_act_for_team
router = APIRouter()
_INDIVIDUAL_FORMATS = {"singles", "fourball"}
def _played_hole_numbers(hole_config: str) -> list[int]:
if hole_config == "front_9":
return list(range(1, 10))
if hole_config == "back_9":
return list(range(10, 19))
return list(range(1, 19))
def _side_net(rows: list, strokes_per_hole: dict, format_: str, hole_number: int) -> int | None:
"""Sidens NETTO for ett hull, eller None hvis ikke avgjørbart ennå."""
if format_ not in _INDIVIDUAL_FORMATS:
if len(rows) != 1:
return None
r = rows[0]
per_hole = strokes_per_hole.get(r["team_side"])
if per_hole is None or hole_number not in per_hole:
return None
return r["gross_strokes"] - per_hole[hole_number]
# singles: forventer nøyaktig 1 rad. fourball: forventer nøyaktig 2
# (begge partnere) og tar den BESTE (laveste) netto -- klassisk
# better-ball-regel. Ufullstendig antall rader = ikke avgjørbart ennå.
expected_rows = 1 if format_ == "singles" else 2
if len(rows) != expected_rows:
return None
nets = []
for r in rows:
per_hole = strokes_per_hole.get(r["match_participant_id"])
if per_hole is None or hole_number not in per_hole:
return None
nets.append(r["gross_strokes"] - per_hole[hole_number])
return min(nets)
async def _compute_hole_results(conn, match_id: str, match) -> list[HoleResult]:
played = _played_hole_numbers(match["hole_config"])
if match["scoring_mode"] == "hole_result":
rows = await conn.fetch(
"""
SELECT hole_number, winning_side::text AS winning_side
FROM match_hole_result WHERE match_id = $1
""",
match_id,
)
by_hole = {r["hole_number"]: r["winning_side"] for r in rows}
results = []
for h in played:
if h not in by_hole:
break
ws = by_hole[h]
results.append(
HoleResult.SIDE_A if ws == "a" else HoleResult.SIDE_B if ws == "b" else HoleResult.HALVED
)
return results
# 'stroke'-modus
allowance_override = json.loads(match["allowance_override"]) if match["allowance_override"] else None
config = parse_allowance_config(match["format"], allowance_override)
relative = await relative_strokes_for_match(conn, match_id, match["format"], config)
if not relative:
return [] # handicap ikke klart for én eller flere enheter ennå
stroke_index_rows = await conn.fetch(
"SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
match["course_id"],
)
all_18_si = [r["stroke_index"] for r in stroke_index_rows]
strokes_per_hole: dict[str, dict[int, int]] = {
unit: dict(zip(played, allocate_over_played_holes(total, all_18_si, played)))
for unit, total in relative.items()
}
score_rows = await conn.fetch(
"""
SELECT hole_number, team_side::text AS team_side,
match_participant_id::text AS match_participant_id, gross_strokes
FROM hole_score WHERE match_id = $1
""",
match_id,
)
by_hole_side: dict[tuple[int, str], list] = {}
for r in score_rows:
by_hole_side.setdefault((r["hole_number"], r["team_side"]), []).append(r)
results = []
for h in played:
net_a = _side_net(by_hole_side.get((h, "a"), []), strokes_per_hole, match["format"], h)
net_b = _side_net(by_hole_side.get((h, "b"), []), strokes_per_hole, match["format"], h)
if net_a is None or net_b is None:
break
if net_a < net_b:
results.append(HoleResult.SIDE_A)
elif net_b < net_a:
results.append(HoleResult.SIDE_B)
else:
results.append(HoleResult.HALVED)
return results
async def _recompute_and_cache_match_state(conn, match_id: str) -> None:
match = await conn.fetchrow(
"""
SELECT m.id::text, s.format, s.scoring_mode,
s.hole_config::text AS hole_config, s.course_id::text AS course_id,
s.allowance_override::text AS allowance_override,
s.points_per_match::float AS points_per_match
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
FOR UPDATE OF m
""",
match_id,
)
results = await _compute_hole_results(conn, match_id, match)
played = _played_hole_numbers(match["hole_config"])
state = compute_match_state(results, total_holes=len(played))
complete = state.is_closed or state.holes_remaining == 0
if complete:
ppm = match["points_per_match"]
if state.lead > 0:
points_a, points_b = ppm, 0.0
elif state.lead < 0:
points_a, points_b = 0.0, ppm
else:
points_a = points_b = ppm / 2
await conn.execute(
"UPDATE match SET status_text = $1, points_side_a = $2, points_side_b = $3 WHERE id = $4",
state.describe(),
points_a,
points_b,
match_id,
)
else:
await conn.execute(
"UPDATE match SET status_text = $1 WHERE id = $2",
state.describe(),
match_id,
)
class HoleScoreCreate(BaseModel):
team_side: str = Field(pattern="^[ab]$")
match_participant_id: str | None = None
hole_number: int = Field(ge=1, le=18)
gross_strokes: int = Field(ge=1, le=20)
class HoleScoreOut(BaseModel):
hole_number: int
team_side: str
match_participant_id: str | None
gross_strokes: int
@router.post(
"/orgs/{organization_id}/matches/{match_id}/hole-scores",
response_model=HoleScoreOut,
status_code=201,
)
async def submit_hole_score(
match_id: str,
body: HoleScoreCreate,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> HoleScoreOut:
async with org_connection(organization_id) as conn, translate_db_errors():
match = await conn.fetchrow(
"""
SELECT m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
s.format, s.scoring_mode, s.hole_config::text AS hole_config
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Matchen finnes ikke.")
if match["scoring_mode"] != "stroke":
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Denne økten bruker hull-resultat-modus, ikke slagregistrering.",
)
played = _played_hole_numbers(match["hole_config"])
if body.hole_number not in played:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail="Hullnummeret er utenfor øktens spilte omfang."
)
is_individual = match["format"] in _INDIVIDUAL_FORMATS
if is_individual and body.match_participant_id is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Dette formatet krever match_participant_id (individuell ball).",
)
if not is_individual and body.match_participant_id is not None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Dette formatet bruker delt ball -- oppgi ikke match_participant_id.",
)
expected_team_id = match["team_a_id"] if body.team_side == "a" else match["team_b_id"]
if body.match_participant_id is not None:
participant = await conn.fetchrow(
"""
SELECT team_side::text AS team_side FROM match_participant
WHERE id = $1 AND match_id = $2
""",
body.match_participant_id,
match_id,
)
if participant is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, detail="Deltakeren finnes ikke i denne matchen."
)
if participant["team_side"] != body.team_side:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail="match_participant_id tilhører ikke angitt side."
)
if not await user_may_act_for_team(conn, expected_team_id, user.user_id):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget.")
if body.match_participant_id is not None:
row = await conn.fetchrow(
"""
INSERT INTO hole_score
(organization_id, match_id, team_side, match_participant_id, hole_number, gross_strokes)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (match_participant_id, hole_number) WHERE match_participant_id IS NOT NULL
DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes, updated_at = now()
RETURNING hole_number, team_side::text AS team_side,
match_participant_id::text AS match_participant_id, gross_strokes
""",
organization_id,
match_id,
body.team_side,
body.match_participant_id,
body.hole_number,
body.gross_strokes,
)
else:
row = await conn.fetchrow(
"""
INSERT INTO hole_score
(organization_id, match_id, team_side, match_participant_id, hole_number, gross_strokes)
VALUES ($1, $2, $3, NULL, $4, $5)
ON CONFLICT (match_id, team_side, hole_number) WHERE match_participant_id IS NULL
DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes, updated_at = now()
RETURNING hole_number, team_side::text AS team_side,
match_participant_id::text AS match_participant_id, gross_strokes
""",
organization_id,
match_id,
body.team_side,
body.hole_number,
body.gross_strokes,
)
await _recompute_and_cache_match_state(conn, match_id)
return HoleScoreOut(**dict(row))
class HoleResultCreate(BaseModel):
hole_number: int = Field(ge=1, le=18)
winning_side: str | None = Field(default=None, pattern="^[ab]$")
class HoleResultOut(BaseModel):
hole_number: int
winning_side: str | None
@router.post(
"/orgs/{organization_id}/matches/{match_id}/hole-results",
response_model=HoleResultOut,
status_code=201,
)
async def submit_hole_result(
match_id: str,
body: HoleResultCreate,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> HoleResultOut:
async with org_connection(organization_id) as conn, translate_db_errors():
match = await conn.fetchrow(
"""
SELECT m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
s.scoring_mode, s.hole_config::text AS hole_config
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Matchen finnes ikke.")
if match["scoring_mode"] != "hole_result":
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Denne økten bruker slagregistrering, ikke hull-resultat-modus.",
)
played = _played_hole_numbers(match["hole_config"])
if body.hole_number not in played:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail="Hullnummeret er utenfor øktens spilte omfang."
)
# Begge lag kan rapportere et hull-resultat (hvilken som helst side kan
# vinne/tape/dele) -- brukeren må være rostret på ETT av de to lagene.
if not (
await user_may_act_for_team(conn, match["team_a_id"], user.user_id)
or await user_may_act_for_team(conn, match["team_b_id"], user.user_id)
):
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på noen av lagene i denne matchen."
)
row = await conn.fetchrow(
"""
INSERT INTO match_hole_result (organization_id, match_id, hole_number, winning_side)
VALUES ($1, $2, $3, $4)
ON CONFLICT (match_id, hole_number)
DO UPDATE SET winning_side = EXCLUDED.winning_side
RETURNING hole_number, winning_side::text AS winning_side
""",
organization_id,
match_id,
body.hole_number,
body.winning_side,
)
await _recompute_and_cache_match_state(conn, match_id)
return HoleResultOut(**dict(row))
class ScorecardHole(BaseModel):
hole_number: int
result: str
class Scorecard(BaseModel):
match_id: str
status_text: str | None
points_side_a: float | None
points_side_b: float | None
holes: list[ScorecardHole]
_RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"}
@router.get(
"/orgs/{organization_id}/matches/{match_id}/scorecard",
response_model=Scorecard,
)
async def get_scorecard(
match_id: str,
organization_id: str = Depends(get_authorized_org),
) -> Scorecard:
async with org_connection(organization_id) as conn:
match = await conn.fetchrow(
"""
SELECT m.id::text, m.status_text, m.points_side_a::float AS points_side_a,
m.points_side_b::float AS points_side_b,
s.format, s.scoring_mode, s.hole_config::text AS hole_config,
s.course_id::text AS course_id, s.allowance_override::text AS allowance_override
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Matchen finnes ikke.")
results = await _compute_hole_results(conn, match_id, match)
played = _played_hole_numbers(match["hole_config"])
holes = [
ScorecardHole(hole_number=h, result=_RESULT_LABEL[r]) for h, r in zip(played, results)
]
return Scorecard(
match_id=match["id"],
status_text=match["status_text"],
points_side_a=match["points_side_a"],
points_side_b=match["points_side_b"],
holes=holes,
)

View file

@ -254,6 +254,9 @@ class SessionCreate(BaseModel):
course_id: str course_id: str
points_per_match: float = 1.0 points_per_match: float = 1.0
allowance_override: dict | None = None allowance_override: dict | None = None
# ADR-012: 'stroke' (hole_score, motoren utleder netto) eller 'hole_result'
# (match_hole_result, bare hvem som vant hullet). DB-default er 'stroke'.
scoring_mode: str = "stroke"
class SessionOut(BaseModel): class SessionOut(BaseModel):
@ -265,6 +268,7 @@ class SessionOut(BaseModel):
course_id: str course_id: str
points_per_match: float points_per_match: float
allowance_override: dict | None allowance_override: dict | None
scoring_mode: str
locked_team_ids: list[str] locked_team_ids: list[str]
revealed: bool revealed: bool
@ -279,6 +283,7 @@ def _session_out(row, locked: set[str]) -> SessionOut:
course_id=row["course_id"], course_id=row["course_id"],
points_per_match=row["points_per_match"], points_per_match=row["points_per_match"],
allowance_override=json.loads(row["allowance_override"]) if row["allowance_override"] else None, allowance_override=json.loads(row["allowance_override"]) if row["allowance_override"] else None,
scoring_mode=row["scoring_mode"],
locked_team_ids=sorted(locked), locked_team_ids=sorted(locked),
revealed=len(locked) >= 2, revealed=len(locked) >= 2,
) )
@ -297,7 +302,7 @@ async def list_sessions(
""" """
SELECT id::text, sequence, name, format, hole_config::text AS hole_config, SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
course_id::text, points_per_match::float AS points_per_match, course_id::text, points_per_match::float AS points_per_match,
allowance_override::text AS allowance_override allowance_override::text AS allowance_override, scoring_mode
FROM session FROM session
WHERE tournament_id = $1 WHERE tournament_id = $1
ORDER BY sequence ORDER BY sequence
@ -327,11 +332,11 @@ async def create_session(
""" """
INSERT INTO session INSERT INTO session
(organization_id, tournament_id, sequence, name, format, (organization_id, tournament_id, sequence, name, format,
hole_config, course_id, points_per_match, allowance_override) hole_config, course_id, points_per_match, allowance_override, scoring_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config, RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
course_id::text, points_per_match::float AS points_per_match, course_id::text, points_per_match::float AS points_per_match,
allowance_override::text AS allowance_override allowance_override::text AS allowance_override, scoring_mode
""", """,
organization_id, organization_id,
tournament_id, tournament_id,
@ -342,5 +347,6 @@ async def create_session(
body.course_id, body.course_id,
body.points_per_match, body.points_per_match,
allowance_json, allowance_json,
body.scoring_mode,
) )
return _session_out(row, set()) return _session_out(row, set())

23
app/team_authz.py Normal file
View file

@ -0,0 +1,23 @@
"""
Delt autorisasjonssjekk: er brukeren rostret et gitt lag?
Brukt av både matches.py (deltaker/lås-skriving) og scoring.py (score-skriving)
se plan for begrunnelsen (bevisst IKKE kapteins-only ennå, FEATURE_BACKLOG).
"""
from asyncpg import Connection
async def user_may_act_for_team(conn: Connection, team_id: str, user_id: str) -> bool:
"""Brukeren har en team_roster-rad på laget."""
return await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM team_roster tr
JOIN player p ON p.id = tr.player_id
WHERE tr.team_id = $1 AND p.user_id = $2
)
""",
team_id,
user_id,
)