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:
parent
d68fbd89c9
commit
b3659cc953
10 changed files with 805 additions and 60 deletions
|
|
@ -58,7 +58,33 @@
|
||||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(len\\(d\\), 'spillere:', [p['display_name'] for p in d]\\)\")",
|
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(len\\(d\\), 'spillere:', [p['display_name'] for p in d]\\)\")",
|
||||||
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"sequence\":2,\"format\":\"singles\",\"course_id\":\"00000000-0000-0000-0000-000000000000\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/sessions)",
|
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"sequence\":2,\"format\":\"singles\",\"course_id\":\"00000000-0000-0000-0000-000000000000\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/sessions)",
|
||||||
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"sequence\":2,\"format\":\"tullball\",\"course_id\":\"__TRACKED_VAR__\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/sessions)",
|
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"sequence\":2,\"format\":\"tullball\",\"course_id\":\"__TRACKED_VAR__\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/sessions)",
|
||||||
"Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/match_id.env)"
|
"Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/match_id.env)",
|
||||||
|
"Bash(python3 -m py_compile app/main.py app/team_authz.py app/handicap.py app/routers/matches.py app/routers/scoring.py handicap_engine.py)",
|
||||||
|
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"sequence\":1,\"format\":\"singles\",\"course_id\":\"dddd4444-4444-4444-4444-444444444444\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/sessions)",
|
||||||
|
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"sequence\":1,\"team_a_id\":\"de111111-1111-1111-1111-111111111111\",\"team_b_id\":\"de222222-2222-2222-2222-222222222222\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/sessions/372f1e76-173e-4534-af60-4e458e26ad97/matches)",
|
||||||
|
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"a\",\"team_roster_id\":\"f0000001-0000-0000-0000-000000000001\",\"tee_id\":\"eeee5555-5555-5555-5555-555555555555\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/participants)",
|
||||||
|
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: b1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"b\",\"team_roster_id\":\"f0000003-0000-0000-0000-000000000003\",\"tee_id\":\"eeee5555-5555-5555-5555-555555555555\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/participants)",
|
||||||
|
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"sequence\":2,\"format\":\"fourball\",\"course_id\":\"dddd4444-4444-4444-4444-444444444444\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/sessions)",
|
||||||
|
"Bash(curl -s -o /dev/null -w 'A1 hull3: %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"a\",\"match_participant_id\":\"c81ed8c5-31f6-468f-8c10-8eba6d637fc3\",\"hole_number\":3,\"gross_strokes\":5}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
|
||||||
|
"Bash(curl -s -o /dev/null -w 'B1 hull3: %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: b1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"b\",\"match_participant_id\":\"0d94fe95-bac8-4851-9844-95a821dc8028\",\"hole_number\":3,\"gross_strokes\":4}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
|
||||||
|
"Bash(curl -s http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/scorecard)",
|
||||||
|
"Bash(curl -s -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/scorecard)",
|
||||||
|
"Bash(curl -s -o /dev/null -w 'hull1 A: %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"a\",\"match_participant_id\":\"c81ed8c5-31f6-468f-8c10-8eba6d637fc3\",\"hole_number\":1,\"gross_strokes\":4}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
|
||||||
|
"Bash(curl -s -o /dev/null -w 'hull1 B: %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: b1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"b\",\"match_participant_id\":\"0d94fe95-bac8-4851-9844-95a821dc8028\",\"hole_number\":1,\"gross_strokes\":4}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
|
||||||
|
"Bash(curl -s -o /dev/null -w 'hull2 A: %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"a\",\"match_participant_id\":\"c81ed8c5-31f6-468f-8c10-8eba6d637fc3\",\"hole_number\":2,\"gross_strokes\":5}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
|
||||||
|
"Bash(curl -s -o /dev/null -w 'hull2 B: %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: b1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"b\",\"match_participant_id\":\"0d94fe95-bac8-4851-9844-95a821dc8028\",\"hole_number\":2,\"gross_strokes\":3}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
|
||||||
|
"Bash(curl -s -o /dev/null -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"a\",\"match_participant_id\":\"3600ebd7-89d9-4c8d-aede-30a3a78e8e93\",\"hole_number\":1,\"gross_strokes\":5}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/76d682c9-10fb-4ec4-b837-d2762421eee1/hole-scores)",
|
||||||
|
"Bash(curl -s -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/76d682c9-10fb-4ec4-b837-d2762421eee1/scorecard)",
|
||||||
|
"Bash(curl -s -o /dev/null -w 'hull1\\(a\\): %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"hole_number\":1,\"winning_side\":\"a\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/d0bc2855-3aa9-44ba-a2db-da518d82a4c1/hole-results)",
|
||||||
|
"Bash(curl -s -o /dev/null -w 'hull2\\(delt\\): %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: b1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"hole_number\":2,\"winning_side\":null}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/d0bc2855-3aa9-44ba-a2db-da518d82a4c1/hole-results)",
|
||||||
|
"Bash(curl -s -o /dev/null -w 'hull3\\(b\\): %{http_code}\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"hole_number\":3,\"winning_side\":\"b\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/d0bc2855-3aa9-44ba-a2db-da518d82a4c1/hole-results)",
|
||||||
|
"Bash(curl -s -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/d0bc2855-3aa9-44ba-a2db-da518d82a4c1/scorecard)",
|
||||||
|
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"a\",\"hole_number\":15,\"gross_strokes\":4}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
|
||||||
|
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"hole_number\":15,\"winning_side\":\"a\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-results)",
|
||||||
|
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: a1000000-0000-0000-0000-000000000001' -H 'Content-Type: application/json' -d '{\"team_side\":\"a\",\"match_participant_id\":\"c81ed8c5-31f6-468f-8c10-8eba6d637fc3\",\"hole_number\":1,\"gross_strokes\":6}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
|
||||||
|
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: b2000000-0000-0000-0000-000000000002' -H 'Content-Type: application/json' -d '{\"team_side\":\"a\",\"match_participant_id\":\"c81ed8c5-31f6-468f-8c10-8eba6d637fc3\",\"hole_number\":16,\"gross_strokes\":5}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/matches/aebcd63e-f452-4326-8a94-89bde1ace11d/hole-scores)",
|
||||||
|
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -H 'X-Debug-User-Id: __TRACKED_VAR__' __TRACKED_VAR__/orgs/__TRACKED_VAR__/matches/__TRACKED_VAR__/scorecard)",
|
||||||
|
"Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/hr_ids.env)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
CLAUDE.md
46
CLAUDE.md
|
|
@ -57,24 +57,34 @@ Ferdig og verifisert:
|
||||||
deltakere/blind draw-lås, ADR-013-synlighet push-down i SQL). Delt
|
deltakere/blind draw-lås, ADR-013-synlighet push-down i SQL). Delt
|
||||||
feiloversettelse i `app/errors.py`, delte synlighetsspørringer i
|
feiloversettelse i `app/errors.py`, delte synlighetsspørringer i
|
||||||
`app/blind_draw.py`. `main.py` er nå bare app-factory + `include_router`.
|
`app/blind_draw.py`. `main.py` er nå bare app-factory + `include_router`.
|
||||||
**Bevisst utelatt:** `course_handicap`/`playing_handicap` på
|
- Scoring-runden er bygget og verifisert for ekte mot scratch-db (18-hulls
|
||||||
`match_participant` beregnes IKKE ennå (venter på motor-integrasjon i
|
bane med `tee_rating`, 4 spillere for fourball-testing): `app/handicap.py`
|
||||||
scoring-runden — flerspillerformater trenger hele sidens spillere samtidig).
|
(ADR-014 fire brytere via `parse_allowance_config`, handicap beregnes i
|
||||||
**Bevisst minimal autorisasjon:** kapteins-only er ikke bygget (se
|
`compute_and_store_side_handicaps` rett etter deltaker-innsetting —
|
||||||
FEATURE_BACKLOG ❓); bar er i dag org-medlemskap for oppsett, og
|
singles/fourball per spiller umiddelbart, foursome/greensome/scramble kun
|
||||||
"rostret på laget" for deltaker-/lås-handlinger.
|
når siden er komplett), `app/routers/scoring.py` (`hole-scores`/
|
||||||
|
`hole-results`-upsert, `scorecard`-GET, matchstatus-recompute med
|
||||||
|
`FOR UPDATE`-lås mot race og SAMMENHENGENDE-prefiks-regel for uferdige
|
||||||
|
hull). `app/team_authz.py` skilt ut fra `matches.py` (delt med
|
||||||
|
`scoring.py`). Alle 10 planlagte tester bestått, inkl. fourball
|
||||||
|
better-ball-aggregering (MIN av to nettoer, venter til begge partnere har
|
||||||
|
registrert), poeng-caching ved tidlig avgjort match, og ADR-014-bryteren
|
||||||
|
`use_handicap=false`.
|
||||||
|
**Fant og fikset underveis:** `tournaments.py` sin `SessionCreate` manglet
|
||||||
|
`scoring_mode` helt (økter kunne aldri opprettes i `hole_result`-modus via
|
||||||
|
API-et) — lagt til.
|
||||||
|
**Bevisst utelatt/kjente begrensninger:** en side som aldri når forventet
|
||||||
|
deltakerantall (no-show) får aldri beregnet handicap og matchen kan da
|
||||||
|
aldri avgjøres — ingen manuell overstyring bygget. Score-skriving er
|
||||||
|
upsert (ingen avvisning ved duplikat) — ingen audit-trail på rettelser.
|
||||||
|
Kapteins-only autorisasjon fortsatt ikke bygget (FEATURE_BACKLOG ❓); bar
|
||||||
|
er «rostret på laget».
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
1. Scoring-endepunkter: `hole_score`/`match_hole_result`-skriving,
|
1. Containerisere TeeCup-API-et (Dockerfile + compose-tjeneste), koble mot
|
||||||
matchstatus fra `handicap_engine.compute_match_state`, og
|
|
||||||
handicap-beregning (`course_handicap`/`playing_handicap`) for
|
|
||||||
`match_participant` via motoren. Bygg samtidig inn de fire konfigurerbare
|
|
||||||
bryterne fra ADR-014 (bruk handicap / bruk course handicap / hcp-prosent /
|
|
||||||
bruk matchplay-handicap) i `session.allowance_override` — IKKE bare
|
|
||||||
prosenten. Motoren støtter allerede alle fire som atskilte kall
|
|
||||||
(`course_handicap_raw`, en `AllowanceStrategy`, `match_play_strokes`); det
|
|
||||||
som mangler er at API-et leser bryterne og hopper over/kaller riktig steg.
|
|
||||||
2. Containerisere TeeCup-API-et (Dockerfile + compose-tjeneste), koble mot
|
|
||||||
`teecup_db` med `teecup_app`, rute via eksisterende Caddy til
|
`teecup_db` med `teecup_app`, rute via eksisterende Caddy til
|
||||||
`teecup.teeoff.no`.
|
`teecup.teeoff.no`. (Under scratch-verifisering måtte hele `/opt/teecup`
|
||||||
3. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 004).
|
monteres, ikke bare `app/`, fordi `handicap_engine.py` er et
|
||||||
|
toppnivå-søskenmodul til `app`-pakken — Dockerfilen må `COPY` begge inn
|
||||||
|
med samme relative plassering.)
|
||||||
|
2. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 004).
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,10 @@
|
||||||
| Spiller-pool m/ reserver, delvis deltakelse | ✅ | ADR-007. |
|
| Spiller-pool m/ reserver, delvis deltakelse | ✅ | ADR-007. |
|
||||||
| Multi-tenant skjema + RLS | ✅ | ADR-001/003. Isolasjon bevist med oppførselstest. |
|
| Multi-tenant skjema + RLS | ✅ | ADR-001/003. Isolasjon bevist med oppførselstest. |
|
||||||
| Dedikert app-rolle (ingen superuser/BYPASSRLS) | ✅ | migrasjon 002. |
|
| Dedikert app-rolle (ingen superuser/BYPASSRLS) | ✅ | migrasjon 002. |
|
||||||
| API: oppsett (spillere/lag/roster/økter/matcher/blind draw) | ✅ | Verifisert for ekte mot scratch-db + engangscontainer. Scoring gjenstår. |
|
| API: oppsett (spillere/lag/roster/økter/matcher/blind draw) | ✅ | Verifisert for ekte mot scratch-db + engangscontainer. |
|
||||||
|
| API: scoring (hole_score/match_hole_result, matchstatus, handicap-beregning) | ✅ | ADR-012/014. Verifisert for ekte, inkl. fourball better-ball og race-sikker recompute. |
|
||||||
| Banedata fra teeoff via API | 🔀 | ADR-004. Endret fra Geminis «delt database direkte». |
|
| Banedata fra teeoff via API | 🔀 | ADR-004. Endret fra Geminis «delt database direkte». |
|
||||||
| Konfigurerbar handicap-pipeline (4 brytere) | 📋 | ADR-014. Motoren støtter alle fire steg (course handicap / allowance / matchplay-hcp / helt av) som atskilte kall; API/skjema eksponerer dem ikke som brytere ennå. Bygges sammen med scoring-endepunktene. |
|
| Konfigurerbar handicap-pipeline (4 brytere) | ✅ | ADR-014. Bygget i `app/handicap.py`, brukt av scoring-runden. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -40,21 +41,60 @@
|
||||||
offentlige feeden (se Kommunikasjon). Kaptein-rollen bør kanskje gi spesifikke
|
offentlige feeden (se Kommunikasjon). Kaptein-rollen bør kanskje gi spesifikke
|
||||||
rettigheter (sette oppstilling), ikke bare være et flagg.
|
rettigheter (sette oppstilling), ikke bare være et flagg.
|
||||||
|
|
||||||
|
### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16)
|
||||||
|
- **Status:** ❓ trenger beslutning — direkte oppfølger av «Brukerroller» over.
|
||||||
|
- **Hvem fører score i dag:** alle med en `team_roster`-rad på laget (samme
|
||||||
|
minimale grense som deltaker/lås, se ADR-013-relatert kode). Ikke
|
||||||
|
kaptein-only, ikke begrenset til de(n) som faktisk spiller matchen.
|
||||||
|
- **Hvem kan korrigere en ført score i dag:** akkurat de samme — skriving er en
|
||||||
|
upsert (`ON CONFLICT ... DO UPDATE`), så en korrigering er ikke skilt fra en
|
||||||
|
førstegangs-innføring. Ingen godkjenning fra motstanderen, ingen audit-trail
|
||||||
|
(forrige verdi overskrives sporløst).
|
||||||
|
- **Hvem kan «lukke» en match eller turnering i dag:** INGEN eksplisitt
|
||||||
|
handling finnes. En match sin `status_text`/poeng blir bare cached
|
||||||
|
automatisk når `handicap_engine.compute_match_state` sier den er avgjort
|
||||||
|
(eller 18 hull er spilt) — det er ikke en handling noen utfører. **Reell,
|
||||||
|
ikke-teoretisk konsekvens oppdaget ved gjennomgang av dette:** ingenting
|
||||||
|
hindrer at noen fortsetter å legge inn hull ETTER at matchen matematisk er
|
||||||
|
avgjort (f.eks. «10&5» ved hull 13) — blir hull 14-18 likevel registrert,
|
||||||
|
regnes de med i sekvensen ved neste rekalkulering og kan endre den cachede
|
||||||
|
marginen, siden `compute_match_state` teller alle sammenhengende avgjorte
|
||||||
|
hull, ikke bare fram til avgjørelsespunktet. Ingen sperre bygget for dette.
|
||||||
|
Turnering har et `status`-felt (draft/active/completed/archived) men
|
||||||
|
INGEN endepunkt endrer det ennå — organisator kan ikke markere en turnering
|
||||||
|
ferdig via API-et i dag.
|
||||||
|
- **Manglende WO/konsesjon:** hvis en side aldri stiller nok spillere
|
||||||
|
(`match_participant`-antallet når aldri det økten krever), beregnes
|
||||||
|
handicap ALDRI (se `app/handicap.py`), og matchen kan derfor ALDRI få et
|
||||||
|
hull-resultat — den blir hengende uavgjort for alltid. Det finnes ingen
|
||||||
|
«gi bort hullet/matchen/turneringen»-mekanisme (walkover/konsesjon) i det
|
||||||
|
hele tatt ennå — verken datamodell eller endepunkt.
|
||||||
|
- **Avgjort 2026-07-16:**
|
||||||
|
- **Match-lås bygges SNART** (neste lille runde) — eksplisitt handling som
|
||||||
|
blokkerer videre hull-innlegging etter at en match er ferdig, og tetter
|
||||||
|
«spøkelses-hull etter avgjørelse»-buggen over. Hvem som får lås/åpne den
|
||||||
|
(kaptein? organisator? samme «rostret på laget»-bar som resten?) avgjøres
|
||||||
|
når den bygges — se punkt (a)/(b) fortsatt åpne under.
|
||||||
|
- **Walkover/konsesjon VENTER** til brukerroller (kaptein/organisator,
|
||||||
|
punktet over) er avgjort — å bygge den nå på dagens løse
|
||||||
|
«rostret på laget»-grense betyr sannsynligvis å bygge den om senere.
|
||||||
|
- **Fortsatt åpent:** (a) skal score-føring begrenses til faktiske
|
||||||
|
matchdeltakere (ikke bare «noen på laget»)? (b) skal korrigering kreve
|
||||||
|
motpartens godkjenning, eller er upsert-modellen god nok for v1? (c) skal
|
||||||
|
turnering-status (draft/active/completed/archived) kunne settes via API?
|
||||||
|
|
||||||
### Blind draw (skjult lagoppstilling)
|
### Blind draw (skjult lagoppstilling)
|
||||||
- **Status:** 🔨 skjema bygget (migrasjon 003, `lineup_lock`) — app-logikk gjenstår
|
- **Status:** ✅ skjema (migrasjon 003, `lineup_lock`) + API bygget og verifisert
|
||||||
|
(`app/routers/matches.py`: synlighetsfilter i SQL, ikke Python-filter — se ADR-013).
|
||||||
- Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er
|
- Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er
|
||||||
ferdige.
|
ferdige.
|
||||||
- **Skjemakonsekvens:** oppstillinger trenger en tilstand «utkast → låst →
|
- **Gjenstår:** hvem som FÅR låse et lag er i dag bare «rostret på laget», ikke
|
||||||
avslørt» før `match_participant` gjøres synlig. Egen `lineup_submission`-modell
|
kaptein-spesifikt — se «Brukerroller» over.
|
||||||
e.l. Ikke bygget.
|
|
||||||
|
|
||||||
### Forenklet scoreføring (uten slagtall)
|
### Forenklet scoreføring (uten slagtall)
|
||||||
- **Status:** 🔨 skjema bygget (migrasjon 003) — begge moduser besluttet
|
- **Status:** ✅ skjema (migrasjon 003) + API bygget og verifisert
|
||||||
- Ønske: kunne registrere ENTEN slag per hull ELLER bare «Lag rød vant hullet /
|
(`app/routers/scoring.py`: `hole-scores` for `stroke`-modus,
|
||||||
delt».
|
`hole-results` for `hole_result`-modus, begge mater samme `compute_match_state`).
|
||||||
- **Skjemakonsekvens:** dagens `hole_score.gross_strokes` er NOT NULL. En ren
|
|
||||||
hull-resultat-modus krever enten at slag kan være NULL + et resultatfelt, eller
|
|
||||||
en egen `match_hole_result`-tabell. Må avgjøres før scoring-API-et.
|
|
||||||
|
|
||||||
### Bøtekasse (Kangaroo Court)
|
### Bøtekasse (Kangaroo Court)
|
||||||
- **Status:** 📋 planlagt
|
- **Status:** 📋 planlagt
|
||||||
|
|
|
||||||
Binary file not shown.
199
app/handicap.py
Normal file
199
app/handicap.py
Normal 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 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)}
|
||||||
|
|
@ -4,7 +4,7 @@ TeeCup API.
|
||||||
App-factory + livssyklus. All forretningslogikk ligger i app/routers/ — hver
|
App-factory + livssyklus. All forretningslogikk ligger i app/routers/ — hver
|
||||||
rute der følger samme mønster: Depends(get_authorized_org) verifiserer
|
rute der følger samme mønster: Depends(get_authorized_org) verifiserer
|
||||||
medlemskap, org_connection(organization_id) setter RLS-konteksten lokalt for
|
medlemskap, org_connection(organization_id) setter RLS-konteksten lokalt for
|
||||||
transaksjonen. Se ARCHITECTURE_DECISIONS.md (ADR-001…013) for reglene bak.
|
transaksjonen. Se ARCHITECTURE_DECISIONS.md (ADR-001…014) for reglene bak.
|
||||||
|
|
||||||
Kjøres i app-containeren (FastAPI + asyncpg + uvicorn):
|
Kjøres i app-containeren (FastAPI + asyncpg + uvicorn):
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
|
@ -15,7 +15,7 @@ from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from .db import init_pool, close_pool
|
from .db import init_pool, close_pool
|
||||||
from .routers import matches, players, tournaments
|
from .routers import matches, players, scoring, tournaments
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|
@ -32,6 +32,7 @@ app = FastAPI(title="TeeCup API", lifespan=lifespan)
|
||||||
app.include_router(players.router)
|
app.include_router(players.router)
|
||||||
app.include_router(tournaments.router)
|
app.include_router(tournaments.router)
|
||||||
app.include_router(matches.router)
|
app.include_router(matches.router)
|
||||||
|
app.include_router(scoring.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ er lagt i SQL (WHERE ... AND ($revealed OR team_id = ANY($egne_lag))), ikke et
|
||||||
etterfølgende Python-filter, slik at lekkasje er strukturelt umulig.
|
etterfølgende Python-filter, slik at lekkasje er strukturelt umulig.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
@ -20,6 +22,8 @@ from ..auth import CurrentUser, get_authorized_org, get_current_user
|
||||||
from ..blind_draw import locked_team_ids, own_team_ids
|
from ..blind_draw import locked_team_ids, own_team_ids
|
||||||
from ..db import org_connection
|
from ..db import org_connection
|
||||||
from ..errors import translate_db_errors
|
from ..errors import translate_db_errors
|
||||||
|
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
|
||||||
|
from ..team_authz import user_may_act_for_team
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -50,21 +54,6 @@ class MatchOut(BaseModel):
|
||||||
participants: list[MatchParticipantOut]
|
participants: list[MatchParticipantOut]
|
||||||
|
|
||||||
|
|
||||||
async def _user_may_act_for_team(conn, team_id: str, user_id: str) -> bool:
|
|
||||||
"""Brukeren har en team_roster-rad på laget (se autorisasjonsgrense i toppen)."""
|
|
||||||
return await conn.fetchval(
|
|
||||||
"""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1 FROM team_roster tr
|
|
||||||
JOIN player p ON p.id = tr.player_id
|
|
||||||
WHERE tr.team_id = $1 AND p.user_id = $2
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
team_id,
|
|
||||||
user_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/orgs/{organization_id}/sessions/{session_id}/matches",
|
"/orgs/{organization_id}/sessions/{session_id}/matches",
|
||||||
response_model=MatchOut,
|
response_model=MatchOut,
|
||||||
|
|
@ -235,7 +224,12 @@ async def add_participant(
|
||||||
)
|
)
|
||||||
|
|
||||||
session = await conn.fetchrow(
|
session = await conn.fetchrow(
|
||||||
"SELECT course_id::text AS course_id FROM session WHERE id = $1", match["session_id"]
|
"""
|
||||||
|
SELECT course_id::text AS course_id, format, hole_config::text AS hole_config,
|
||||||
|
allowance_override::text AS allowance_override
|
||||||
|
FROM session WHERE id = $1
|
||||||
|
""",
|
||||||
|
match["session_id"],
|
||||||
)
|
)
|
||||||
tee_course_id = await conn.fetchval(
|
tee_course_id = await conn.fetchval(
|
||||||
"SELECT course_id::text FROM tee WHERE id = $1", body.tee_id
|
"SELECT course_id::text FROM tee WHERE id = $1", body.tee_id
|
||||||
|
|
@ -245,7 +239,7 @@ async def add_participant(
|
||||||
status.HTTP_400_BAD_REQUEST, detail="tee_id tilhører ikke øktens bane."
|
status.HTTP_400_BAD_REQUEST, detail="tee_id tilhører ikke øktens bane."
|
||||||
)
|
)
|
||||||
|
|
||||||
if not await _user_may_act_for_team(conn, expected_team_id, user.user_id):
|
if not await user_may_act_for_team(conn, expected_team_id, user.user_id):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget."
|
status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget."
|
||||||
)
|
)
|
||||||
|
|
@ -257,11 +251,6 @@ async def add_participant(
|
||||||
detail="Laget har allerede låst oppstillingen for denne økten.",
|
detail="Laget har allerede låst oppstillingen for denne økten.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# course_handicap/playing_handicap settes bevisst IKKE her — de krever
|
|
||||||
# hele sidens spillere samtidig for foursome/greensome/scramble
|
|
||||||
# (handicap_engine sine WeightedLowHigh/RankedSplit-strategier). Hører
|
|
||||||
# til motor-integrasjonen i scoring-runden, ikke ren strukturell
|
|
||||||
# oppsett (kolonnene er nullable).
|
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
WITH inserted AS (
|
WITH inserted AS (
|
||||||
|
|
@ -284,6 +273,18 @@ async def add_participant(
|
||||||
body.team_roster_id,
|
body.team_roster_id,
|
||||||
body.tee_id,
|
body.tee_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Beregn/lagre course_handicap/playing_handicap nå som deltakeren er
|
||||||
|
# satt inn. Funksjonen avgjør selv om siden er "klar" (singles/
|
||||||
|
# fourball: alltid; foursome/greensome/scramble: kun når siden er
|
||||||
|
# komplett) -- se app/handicap.py.
|
||||||
|
allowance_override = (
|
||||||
|
json.loads(session["allowance_override"]) if session["allowance_override"] else None
|
||||||
|
)
|
||||||
|
config = parse_allowance_config(session["format"], allowance_override)
|
||||||
|
await compute_and_store_side_handicaps(
|
||||||
|
conn, match_id, body.team_side, session["format"], session["hole_config"], config
|
||||||
|
)
|
||||||
return MatchParticipantOut(**dict(row))
|
return MatchParticipantOut(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -312,7 +313,7 @@ async def lock_lineup(
|
||||||
if session is None:
|
if session is None:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Økten finnes ikke.")
|
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Økten finnes ikke.")
|
||||||
|
|
||||||
if not await _user_may_act_for_team(conn, body.team_id, user.user_id):
|
if not await user_may_act_for_team(conn, body.team_id, user.user_id):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget."
|
status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget."
|
||||||
)
|
)
|
||||||
|
|
|
||||||
439
app/routers/scoring.py
Normal file
439
app/routers/scoring.py
Normal 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 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,
|
||||||
|
)
|
||||||
|
|
@ -254,6 +254,9 @@ class SessionCreate(BaseModel):
|
||||||
course_id: str
|
course_id: str
|
||||||
points_per_match: float = 1.0
|
points_per_match: float = 1.0
|
||||||
allowance_override: dict | None = None
|
allowance_override: dict | None = None
|
||||||
|
# ADR-012: 'stroke' (hole_score, motoren utleder netto) eller 'hole_result'
|
||||||
|
# (match_hole_result, bare hvem som vant hullet). DB-default er 'stroke'.
|
||||||
|
scoring_mode: str = "stroke"
|
||||||
|
|
||||||
|
|
||||||
class SessionOut(BaseModel):
|
class SessionOut(BaseModel):
|
||||||
|
|
@ -265,6 +268,7 @@ class SessionOut(BaseModel):
|
||||||
course_id: str
|
course_id: str
|
||||||
points_per_match: float
|
points_per_match: float
|
||||||
allowance_override: dict | None
|
allowance_override: dict | None
|
||||||
|
scoring_mode: str
|
||||||
locked_team_ids: list[str]
|
locked_team_ids: list[str]
|
||||||
revealed: bool
|
revealed: bool
|
||||||
|
|
||||||
|
|
@ -279,6 +283,7 @@ def _session_out(row, locked: set[str]) -> SessionOut:
|
||||||
course_id=row["course_id"],
|
course_id=row["course_id"],
|
||||||
points_per_match=row["points_per_match"],
|
points_per_match=row["points_per_match"],
|
||||||
allowance_override=json.loads(row["allowance_override"]) if row["allowance_override"] else None,
|
allowance_override=json.loads(row["allowance_override"]) if row["allowance_override"] else None,
|
||||||
|
scoring_mode=row["scoring_mode"],
|
||||||
locked_team_ids=sorted(locked),
|
locked_team_ids=sorted(locked),
|
||||||
revealed=len(locked) >= 2,
|
revealed=len(locked) >= 2,
|
||||||
)
|
)
|
||||||
|
|
@ -297,7 +302,7 @@ async def list_sessions(
|
||||||
"""
|
"""
|
||||||
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
course_id::text, points_per_match::float AS points_per_match,
|
course_id::text, points_per_match::float AS points_per_match,
|
||||||
allowance_override::text AS allowance_override
|
allowance_override::text AS allowance_override, scoring_mode
|
||||||
FROM session
|
FROM session
|
||||||
WHERE tournament_id = $1
|
WHERE tournament_id = $1
|
||||||
ORDER BY sequence
|
ORDER BY sequence
|
||||||
|
|
@ -327,11 +332,11 @@ async def create_session(
|
||||||
"""
|
"""
|
||||||
INSERT INTO session
|
INSERT INTO session
|
||||||
(organization_id, tournament_id, sequence, name, format,
|
(organization_id, tournament_id, sequence, name, format,
|
||||||
hole_config, course_id, points_per_match, allowance_override)
|
hole_config, course_id, points_per_match, allowance_override, scoring_mode)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
|
||||||
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
|
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
course_id::text, points_per_match::float AS points_per_match,
|
course_id::text, points_per_match::float AS points_per_match,
|
||||||
allowance_override::text AS allowance_override
|
allowance_override::text AS allowance_override, scoring_mode
|
||||||
""",
|
""",
|
||||||
organization_id,
|
organization_id,
|
||||||
tournament_id,
|
tournament_id,
|
||||||
|
|
@ -342,5 +347,6 @@ async def create_session(
|
||||||
body.course_id,
|
body.course_id,
|
||||||
body.points_per_match,
|
body.points_per_match,
|
||||||
allowance_json,
|
allowance_json,
|
||||||
|
body.scoring_mode,
|
||||||
)
|
)
|
||||||
return _session_out(row, set())
|
return _session_out(row, set())
|
||||||
|
|
|
||||||
23
app/team_authz.py
Normal file
23
app/team_authz.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue