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(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(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
feiloversettelse i `app/errors.py`, delte synlighetsspørringer i
`app/blind_draw.py`. `main.py` er nå bare app-factory + `include_router`.
**Bevisst utelatt:** `course_handicap`/`playing_handicap` på
`match_participant` beregnes IKKE ennå (venter på motor-integrasjon i
scoring-runden — flerspillerformater trenger hele sidens spillere samtidig).
**Bevisst minimal autorisasjon:** kapteins-only er ikke bygget (se
FEATURE_BACKLOG ❓); bar er i dag org-medlemskap for oppsett, og
"rostret på laget" for deltaker-/lås-handlinger.
- Scoring-runden er bygget og verifisert for ekte mot scratch-db (18-hulls
bane med `tee_rating`, 4 spillere for fourball-testing): `app/handicap.py`
(ADR-014 fire brytere via `parse_allowance_config`, handicap beregnes i
`compute_and_store_side_handicaps` rett etter deltaker-innsetting —
singles/fourball per spiller umiddelbart, foursome/greensome/scramble kun
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:
1. Scoring-endepunkter: `hole_score`/`match_hole_result`-skriving,
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
1. Containerisere TeeCup-API-et (Dockerfile + compose-tjeneste), koble mot
`teecup_db` med `teecup_app`, rute via eksisterende Caddy til
`teecup.teeoff.no`.
3. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 004).
`teecup.teeoff.no`. (Under scratch-verifisering måtte hele `/opt/teecup`
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. |
| Multi-tenant skjema + RLS | ✅ | ADR-001/003. Isolasjon bevist med oppførselstest. |
| 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». |
| 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
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)
- **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
ferdige.
- **Skjemakonsekvens:** oppstillinger trenger en tilstand «utkast → låst →
avslørt» før `match_participant` gjøres synlig. Egen `lineup_submission`-modell
e.l. Ikke bygget.
- **Gjenstår:** hvem som FÅR låse et lag er i dag bare «rostret på laget», ikke
kaptein-spesifikt — se «Brukerroller» over.
### Forenklet scoreføring (uten slagtall)
- **Status:** 🔨 skjema bygget (migrasjon 003) — begge moduser besluttet
- Ønske: kunne registrere ENTEN slag per hull ELLER bare «Lag rød vant hullet /
delt».
- **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.
- **Status:** ✅ skjema (migrasjon 003) + API bygget og verifisert
(`app/routers/scoring.py`: `hole-scores` for `stroke`-modus,
`hole-results` for `hole_result`-modus, begge mater samme `compute_match_state`).
### Bøtekasse (Kangaroo Court)
- **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
rute der følger samme mønster: Depends(get_authorized_org) verifiserer
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):
uvicorn app.main:app --host 0.0.0.0 --port 8000
@ -15,7 +15,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from .db import init_pool, close_pool
from .routers import matches, players, tournaments
from .routers import matches, players, scoring, tournaments
@asynccontextmanager
@ -32,6 +32,7 @@ app = FastAPI(title="TeeCup API", lifespan=lifespan)
app.include_router(players.router)
app.include_router(tournaments.router)
app.include_router(matches.router)
app.include_router(scoring.router)
@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.
"""
import json
from fastapi import APIRouter, Depends, HTTPException, status
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 ..db import org_connection
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()
@ -50,21 +54,6 @@ class MatchOut(BaseModel):
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(
"/orgs/{organization_id}/sessions/{session_id}/matches",
response_model=MatchOut,
@ -235,7 +224,12 @@ async def add_participant(
)
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(
"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."
)
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(
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.",
)
# 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(
"""
WITH inserted AS (
@ -284,6 +273,18 @@ async def add_participant(
body.team_roster_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))
@ -312,7 +313,7 @@ async def lock_lineup(
if session is None:
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(
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
points_per_match: float = 1.0
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):
@ -265,6 +268,7 @@ class SessionOut(BaseModel):
course_id: str
points_per_match: float
allowance_override: dict | None
scoring_mode: str
locked_team_ids: list[str]
revealed: bool
@ -279,6 +283,7 @@ def _session_out(row, locked: set[str]) -> SessionOut:
course_id=row["course_id"],
points_per_match=row["points_per_match"],
allowance_override=json.loads(row["allowance_override"]) if row["allowance_override"] else None,
scoring_mode=row["scoring_mode"],
locked_team_ids=sorted(locked),
revealed=len(locked) >= 2,
)
@ -297,7 +302,7 @@ async def list_sessions(
"""
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
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
WHERE tournament_id = $1
ORDER BY sequence
@ -327,11 +332,11 @@ async def create_session(
"""
INSERT INTO session
(organization_id, tournament_id, sequence, name, format,
hole_config, course_id, points_per_match, allowance_override)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
hole_config, course_id, points_per_match, allowance_override, scoring_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
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,
tournament_id,
@ -342,5 +347,6 @@ async def create_session(
body.course_id,
body.points_per_match,
allowance_json,
body.scoring_mode,
)
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,
)