diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f68bf0b..32a32bd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 7ebb705..d4f29e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index dc02b5c..600f653 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -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 diff --git a/__pycache__/handicap_engine.cpython-312.pyc b/__pycache__/handicap_engine.cpython-312.pyc index f263e6b..e9c00a7 100644 Binary files a/__pycache__/handicap_engine.cpython-312.pyc and b/__pycache__/handicap_engine.cpython-312.pyc differ diff --git a/app/handicap.py b/app/handicap.py new file mode 100644 index 0000000..aef64ff --- /dev/null +++ b/app/handicap.py @@ -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 på 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)} diff --git a/app/main.py b/app/main.py index 5ce6f13..4b30c29 100644 --- a/app/main.py +++ b/app/main.py @@ -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-001…013) for reglene bak. +transaksjonen. Se ARCHITECTURE_DECISIONS.md (ADR-001…014) 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") diff --git a/app/routers/matches.py b/app/routers/matches.py index d6ddb04..57e3e1a 100644 --- a/app/routers/matches.py +++ b/app/routers/matches.py @@ -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." ) diff --git a/app/routers/scoring.py b/app/routers/scoring.py new file mode 100644 index 0000000..5309175 --- /dev/null +++ b/app/routers/scoring.py @@ -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 MÅ ha match_participant_id; foursome/greensome/scramble MÅ +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 på 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, + ) diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index f00dd9c..7435368 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -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()) diff --git a/app/team_authz.py b/app/team_authz.py new file mode 100644 index 0000000..f8a1efe --- /dev/null +++ b/app/team_authz.py @@ -0,0 +1,23 @@ +""" +Delt autorisasjonssjekk: er brukeren rostret på 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, + )