From 3270d044decd6b89e1c78b225c6c0186af8ce2ba Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Wed, 22 Jul 2026 11:31:08 +0200 Subject: [PATCH] =?UTF-8?q?Migrasjonen=20er=20kj=C3=B8rt,=20og=20jeg=20har?= =?UTF-8?q?=20fortsatt=20helt=20til=20et=20komplett,=20verifisert=20API-la?= =?UTF-8?q?g.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Underveis fant og fikset jeg et reelt hull: round_participant manglet kolonner for selve rating-tallene (kun tee-navnet var lagret) — løst med migrasjon 021, kjørt mot ekte database sammen med resten. Bygget og rullet ut: app/routers/rounds.py — opprett/liste/hent/slett runde, legg til/fjern gjestedeltakere, registrer statistikk hull for hull, og en fullfør-endepunkt som kjører hele kjeden (Adjusted Gross Score → Score Differential → tellende-vurdering). 26 scratch-sjekker grønt, pluss en egen test mot den ekte kjørende teeoff-tjenesten som bekreftet at live-oppslaget fungerer nøyaktig som tiltenkt (ingen bane-kopi skrives noe sted). Begge migrasjoner + ny kode er nå live mot ekte teecup_db/teecup_api. teeoff.no upåvirket. Jeg la også inn /rounds og /personal-courses i frontendens proxy-oppsett proaktivt, siden prosjektet har blitt bitt av glemte proxy-ruter før — ikke deployet ennå, tas med neste gang frontend faktisk bygges. Notatet om slaglengde-måling og avstand-til-punkter-på-banen er lagret i CLAUDE.md og FEATURE_BACKLOG.md, med en tydelig flagget avhengighet: dette krever geografiske/GPS-data som ingen nåværende kilde (verken teeoff eller den nye banekatalogen) har. --- .claude/settings.local.json | 4 +- 021_round_participant_rating_snapshot.sql | 21 + ARCHITECTURE_DECISIONS.md | 76 ++- CLAUDE.md | 27 +- FEATURE_BACKLOG.md | 30 +- __pycache__/handicap_engine.cpython-312.pyc | Bin 23731 -> 24776 bytes app/main.py | 2 + app/routers/rounds.py | 619 ++++++++++++++++++++ frontend/next.config.mjs | 4 + handicap_engine.py | 21 + test_handicap_engine.py | 16 + 11 files changed, 800 insertions(+), 20 deletions(-) create mode 100644 021_round_participant_rating_snapshot.sql create mode 100644 app/routers/rounds.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 61cb050..170aaa0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -348,7 +348,9 @@ "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teeoff.no/)", "Bash(sudo apt-get install -y poppler-utils)", "Bash(sudo -n apt-get install -y poppler-utils)", - "Bash(python3 -m pytest test_handicap_engine.py -q)" + "Bash(python3 -m pytest test_handicap_engine.py -q)", + "Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/rounds.py'\\).read\\(\\)\\)\")", + "Bash(curl -s -o /dev/null -w \"teeoff.no: %{http_code}\\\\n\" https://teeoff.no/)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/021_round_participant_rating_snapshot.sql b/021_round_participant_rating_snapshot.sql new file mode 100644 index 0000000..fa6c1c3 --- /dev/null +++ b/021_round_participant_rating_snapshot.sql @@ -0,0 +1,21 @@ +-- ===================================================================== +-- TeeCup — rating-snapshot på round_participant (migrasjon 021) +-- ===================================================================== +-- Reelt hull funnet ved API-designet rett etter migrasjon 020: en +-- deltakers Course Handicap/Score Differential kan ikke regnes ut uten +-- ratingen (Course Rating/Slope Rating/Par) som faktisk ble brukt -- +-- `round.tee_name_snapshot` alene holder kun navnet, ikke tallene. Disse +-- hører hjemme PER DELTAKER (ikke per runde), siden ratingen er +-- kjønnsspesifikk (ADR-029) og to deltakere i samme runde kan derfor ha +-- ulik rating selv fra samme fysiske utslag. +-- +-- Samme reproduserbarhets-prinsipp som resten av round_participant +-- (ADR-007-mønsteret): et snapshot, ikke en live-referanse. +-- ===================================================================== + +\set ON_ERROR_STOP on + +ALTER TABLE round_participant ADD COLUMN course_rating_snapshot numeric(4,1); +ALTER TABLE round_participant ADD COLUMN slope_rating_snapshot smallint + CHECK (slope_rating_snapshot IS NULL OR slope_rating_snapshot BETWEEN 55 AND 155); +ALTER TABLE round_participant ADD COLUMN tee_par_snapshot smallint; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 26e75d5..2c7d0b2 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -1849,9 +1849,60 @@ CHECK), to markerte eiere på samme runde avvist, duplikat hullnummer per deltaker avvist, ugyldig approach_result-verdi avvist, kaskade-sletting av en runde fjerner alle dens deltakere+hull men lar ANDRE runder stå urørt. `test_isolation.sql` fortsatt 12/12 (ingen RLS-regresjon på -eksisterende tabeller). **Gjenstår:** API-lag og frontend — ingen av -disse er startet, og migrasjonen er IKKE kjørt mot ekte `teecup_db` ennå -(venter på egen bekreftelse, som all annen skriving mot ekte database). +eksisterende tabeller). **Rullet ut mot ekte `teecup_db` 2026-07-22,** +bruker bekreftet eksplisitt: alle sju tabeller bekreftet opprettet, +`test_isolation.sql` fortsatt 12/12 mot ekte database. **Gjenstår:** +API-lag og frontend — ingen av disse er startet. + +**API-laget er ✅ BYGGET, SCRATCH-VERIFISERT OG RULLET UT LIVE 2026-07-22,** +som tredje byggesteg. Ny `app/routers/rounds.py` (registrert i `main.py`): +`POST/GET /rounds` (opprett/list egne runder), `GET/DELETE /rounds/{id}`, +`POST/DELETE /rounds/{id}/participants` (kun gjester i v1, se moduldoc), +`PATCH /rounds/{id}/participants/{pid}/holes/{n}` (hull-for-hull- +registrering), `POST /rounds/{id}/complete` (kjører hele motor-kjeden: +Adjusted Gross Score → Score Differential → `counts_for_handicap` via +`round_counts_for_handicap`), pluss `GET/POST /personal-courses` for den +globale banekatalogen. Ny motor-funksjon lagt til underveis: +`round_counts_for_handicap(played_holes_count, holes_planned)` — to +distinkte terskler (Rule 2.2a: min 10/18 ved 18-hulls-intensjon; Rule +2.2b: ALLE 9 ved 9-hulls-intensjon, ikke "minst 9"), testet (2 nye +tester, 43/43 totalt i `handicap_engine.py`). +**Reelt hull funnet OG fikset FØR API-et kunne fullføres:** +`round_participant` manglet kolonner for selve rating-tallene (Course +Rating/Slope Rating/Par) brukt til å beregne Course Handicap — kun +`round.tee_name_snapshot` (navn) fantes, ikke tallene. Ny migrasjon +`021_round_participant_rating_snapshot.sql` (tre nye nullable kolonner) +skrevet, scratch-verifisert sammen med resten, og rullet ut. +**Scratch-verifisert grundig, 26 sjekker** (isolert scratch-rolle+MinIO+ +engangs API-container): full livssyklus for en custom-bane-runde +(course_handicap_snapshot regnet riktig — Index 15/Slope 128/Rating +71.5/Par 72 → 16, verifisert for hånd), en gjest UTEN HCP (ingen +snapshot/differensial, teller aldri), 18/18 spilt → tellende med korrekt +differensial (16.3, verifisert for hånd), 9 av 18 spilt ved 18-hulls- +intensjon → IKKE tellende, 9 av 9 spilt ved 9-hulls-intensjon → TELLENDE +(Net Par fyller resten av de 18, se Beslutning G punkt 2), full +autorisasjons-isolasjon (en fremmed bruker avvist 403 fra både lesing og +hull-oppdatering, egen runde-liste tom), kan ikke fjerne eieren, slett- +runde-kaskade, ufullstendig profil avvist fra å opprette runde. **Egen, +separat verifisering av teeoff-LIVE-oppslaget** (Beslutning C) mot den +ekte kjørende `teeoff_api`-containeren (Borregaard Golfklubb, samme +anlegg som ADR-019s opprinnelige verifisering) — bekreftet at INGEN +`course`/`hole`/`tee`-rad skrives noe sted, kun et navn-snapshot +("Borregaard Golfklubb – Hovedbanen") og et rating-snapshot; en andre +deltaker lagt til samme runde utløste et FERSK, uavhengig live-oppslag +mot teeoff (ikke gjenbruk av cachet data). `test_isolation.sql` fortsatt +12/12. +**Rullet ut mot ekte systemer 2026-07-22:** migrasjon 021 kjørt mot ekte +`teecup_db` (kolonner bekreftet, `test_isolation.sql` fortsatt 12/12), +`docker compose up -d --build teecup_api` (kun backend — ingen frontend- +skjerm bygget for dette ennå), boot-et rent, `/health`/`/dashboard` → 200, +`teeoff.no` upåvirket. `frontend/next.config.mjs` sin `rewrites()` fikk +`/rounds/*` og `/personal-courses/*` lagt til proaktivt (samme lærdom som +ADR-016/medlemsside-hendelsen — enhver ny API-prefiks MÅ inn her FØR en +frontend-side bygges) — denne ENDRINGEN ligger IKKE deployet ennå (ingen +frontend-kode bruker den), tas med i neste frontend-runde. +**Gjenstår:** hele frontend-en for frittstående rundeføring — ingenting +bygget ennå. **Motor-komponenten (punkt 1-11) er ✅ BYGGET OG TESTET 2026-07-22,** som første, isolerte byggesteg (ren Python, ingen DB/API/frontend ennå — @@ -1908,14 +1959,17 @@ Alle tre store åpne punktene fra første utkast (banedata, WHS 9-hulls- regel, full HCP-indeksformel) er enten eksplisitt bekreftet med bruker (Beslutning C) eller presist kildebelagt fra den offisielle WHS Rules of Handicapping 2024 (Beslutning F/G) — ikke lenger antatt eller tilnærmet. -**To byggesteg ferdig samme dag:** (1) hele HCP-indeks-motor-komponenten -(Beslutning G, punkt 1-11) bygget og testet i `handicap_engine.py` -(41/41 tester), (2) full databasemigrasjon (`020_personal_rounds.sql`, -sju tabeller) skrevet og scratch-verifisert (9 sjekker, se detalj under -Beslutning G/skjema-avsnittet). Gjenstår: API-lag, frontend, OG selve -kjøringen av migrasjonen mot ekte `teecup_db` (venter på egen -bekreftelse) — samme inkrementelle, scratch-verifiserte rytme som resten -av prosjektet. +**Tre byggesteg ferdig samme dag, alle rullet ut mot ekte systemer:** +(1) hele HCP-indeks-motor-komponenten (Beslutning G, punkt 1-11) bygget +og testet i `handicap_engine.py` (43/43 tester), (2) full +databasemigrasjon (`020_personal_rounds.sql` + rettefiksen `021_round_ +participant_rating_snapshot.sql`, åtte tabeller/utvidelser) kjørt mot +ekte `teecup_db`, (3) fullt API-lag (`app/routers/rounds.py`) bygget, +scratch-verifisert (26 sjekker + egen teeoff-live-oppslag-test) og +redeployet (`teecup_api`). **Gjenstår: HELE frontend-en** — ingenting +bygget ennå. Notat fra bruker 2026-07-22 (IKKE designet): planer om +slaglengde-måling + avstand-til-punkter-på-banen (golf-GPS/rangefinder), +krever geografiske data ingen kilde har i dag — se FEATURE_BACKLOG.md. --- diff --git a/CLAUDE.md b/CLAUDE.md index 8ae353a..aafa5e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2330,9 +2330,30 @@ Neste steg: `teecup_app_scratch`, ikke superbruker — CHECK-constraints, XOR user_id/guest_name, kaskade-sletting, GIR-derivering bekreftet mot ekte data). INGEN RLS på disse tabellene (Beslutning A). `test_ - isolation.sql` fortsatt 12/12. **IKKE kjørt mot ekte `teecup_db` - ennå** — venter på egen bekreftelse. API-lag og frontend heller ikke - startet. Se ADR-033 i ARCHITECTURE_DECISIONS.md for full detalj. + isolation.sql` fortsatt 12/12. + **Rullet ut mot ekte `teecup_db` 2026-07-22,** bruker bekreftet + eksplisitt: alle sju tabeller bekreftet opprettet, `test_isolation.sql` + fortsatt 12/12 mot ekte database. + **Deretter, samme dag: fullt API-lag bygget** (`app/routers/ + rounds.py` — opprett/liste/hent/slett runde, legg til/fjern gjest- + deltaker, PATCH hull-for-hull-stats, fullfør-runde som kjører hele + Adjusted-Gross-Score→Score-Differential-kjeden). Fant og fikset et + reelt hull underveis: `round_participant` manglet rating-tall- + kolonner (kun tee-NAVN var snapshotet, ikke selve Course/Slope/Par) — + ny migrasjon `021_round_participant_rating_snapshot.sql`. 26 scratch- + sjekker + en egen, ekte teeoff-live-oppslag-test (Beslutning C + bekreftet: ingen `course`-rad skrives noe sted). Rullet ut mot ekte + `teecup_db`/`teecup_api` 2026-07-22, `/health`/`/dashboard` → 200, + `teeoff.no` upåvirket. `frontend/next.config.mjs` fikk `/rounds`/ + `/personal-courses` lagt til i `rewrites()` proaktivt (ikke deployet + ennå, ingen frontend bruker den før neste runde). **Frontend for + frittstående rundeføring er fortsatt HELT ubygget** — neste steg. + Se ADR-033 i ARCHITECTURE_DECISIONS.md for full detalj. + **Notat fra bruker, IKKE designet:** planer om å måle lengde på slag + + opplyse avstand til ulike punkter på banen (golf-GPS/rangefinder-type + funksjonalitet) — krever geografiske/GPS-data ingen kilde har i dag + (verken teeoff eller `personal_course`). Se FEATURE_BACKLOG.md for + full detalj. 4. **Del 1 (fri, ukrevd sekundær-e-post) er nå BYGGET OG LIVE** (2026-07-21, se status over). **Del 2 (ekte konto-sammenslåing) fortsatt IKKE designet:** hva skjer hvis den ønskede adressen ALLEREDE tilhører en diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index a96dd4f..53c99a2 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -1368,11 +1368,31 @@ tom-skjermens endelige form kan bestemmes. ## Frittstående rundeføring + detaljert statistikk (uten turnering/organisasjon) — 🔨 ADR-033 SKREVET OG KILDEBELAGT, BYGGING PÅBEGYNT 2026-07-22 -**Fremdrift 2026-07-22:** HCP-indeks-motor bygget+testet -(`handicap_engine.py`, 41/41 tester) OG full databasemigrasjon skrevet+ -scratch-verifisert (`020_personal_rounds.sql`, 9 sjekker). Se ADR-033 i -ARCHITECTURE_DECISIONS.md for full detalj. Gjenstår: kjøre migrasjonen mot -ekte `teecup_db` (venter på bekreftelse), API-lag, frontend. +**Fremdrift 2026-07-22:** HCP-indeks-motor (43/43 tester), databaseskjema +(`020`+`021`, sistnevnte en fiks for manglende rating-snapshot-kolonner), +OG et fullt API-lag (`app/routers/rounds.py`) er alle bygget, +scratch-verifisert og rullet ut mot ekte `teecup_db`/`teecup_api`. Se +ADR-033 i ARCHITECTURE_DECISIONS.md for full detalj. **Gjenstår: hele +frontend-en** — ingenting bygget ennå. + +**Notat fra bruker, IKKE designet/bygget ennå (fanget 2026-07-22):** +brukeren har tenkt å ha med (a) måling av lengde på slag, og (b) å kunne +få opplyst avstand til forskjellige steder på banen (typisk pin/hazard/ +layup-punkter, à la en golf-GPS/rangefinder). **Reell, ikke-triviell +avhengighet, verdt å notere nå:** dette krever faktiske GPS-/geografiske +koordinater for banens features (pin-plassering, hazarder osv.) — data +INGEN av dagens kilder har. Verken teeoff sitt API (kun par/stroke- +index/rating, ingen geometri) eller den nye `personal_course`-katalogen +(samme enkle skjema som org-scopet `course`/`hole`) inneholder noe slikt +i dag. "Lengde på slag" krever i tillegg selve GPS-posisjonering av +SPILLEREN i sanntid (nettleser-Geolocation API, ikke bare statiske +baneddata) — en annen klasse funksjonalitet enn resten av appen, som til +nå ikke har hatt noe geografisk/posisjonsbasert element i det hele tatt. +Ingen beslutning tatt om omfang, datakilde (manuelt kartlagt per bane? +en ekstern golf-GPS-database?) eller UI — kun fanget som en kjent, +fremtidig ambisjon som statistikk-modellen (Beslutning B) og +banedata-modellen (Beslutning C) bør ha i bakhodet, siden begge kan +trenge en utvidelse den dagen dette faktisk designes. **Se ADR-033 i ARCHITECTURE_DECISIONS.md for den fulle, besluttede arkitekturen** (eierskapsmønster, statistikk-datamodell, HCP-indeksmotor). diff --git a/__pycache__/handicap_engine.cpython-312.pyc b/__pycache__/handicap_engine.cpython-312.pyc index 461a4a0495444712f08dddd4c29e0e2379a6063b..a0355faea11d3628c56722f477844b4bace0cd00 100644 GIT binary patch delta 3229 zcmZ`)eN0=|6@S;*l#h@Ek~AS9;R5-<1@jqF49OTFG*BoXwjtC_0mHo*@dp!Uk-bcWx~3S_0L?}LI!4T<>wEwwNt772Z0k8{?zQg&`#0un3Fcg4(fma##%KWUH0lt&3nzJV|`gieNV_mW{G#Zut$@aeze7Qu(kzTDAixa+IcQ}Y%l0wA=Uw1EoWn_6V{6R?}3F` z7uYLRKf9p>53fopp?bwWc!B3z(1rc=Va5L4aHaZE0yfTirX2p76+`qwh(pOCRD?au zV0|Fx;UkYbBk^bLv10jo_J$Y*=Zo4?gpqKurU}PWT4LwbU}p;-)-e93Dyu4^z6fO@(+U}3i&KjWGpus~VMe(T3L!tEC@dqF&4!agG8k;;H;zLA;Yw2WyRg<#;?3eC$F3ncT1zif}3z& zX`*!G2xK+sIA^qjTeK@6N~y`|V1SK9fyM@cB4U~YlIA&LgRb!ii|Q;g%nfIm3Xm+u zooM8umLbs6*xud@rQNRq3x=xTnhFzY-G!wKf%x1A)ZEGvyZx6I2)$Ru1fUa91Mu!3h4$ufB=Wxjh z6)_b{1Yr7tr8F8al&aCmkPb4!5hE-(4CV4+PTZ96z#AmI2wMT1w)q^GzawzNiD4X$ zL=9^`>z>Ip@%xifu?d#>+*Sb;HlT>+0Q7xeG+?mMa9~1A0igoEmd!%gJfnR^wX_YV z$omiEUSCsiw)?#`JFlhfyq>xBY|eX`Mc3>_^6S#CH+hloQ2d)|nPM~SI$pDj<8pOb z%kC5Kq;x8~5+IJD4Z(vaAArr2cH3ritV(c>#gCWe+BW_WS>FRl7B5c7wK z^5&_;LwI0??7yxmCc6AbRY@vl7ca`c)Z~$%+*6%TUdEvDNc9Dha~6HC066Vp8u!O# zWzAM{LUz{dAd@m$(_fT0jy}A_r0$%-M}!Ia1+dR7NR1EfdD=!!%fHt4ljr0mmW_g`4a1q zr#%;8-g>>wj{kzERi5>>l9TeE-fF;YzUoF5RTY{l)?)z7Ae;zj6-!xDMd6~gd~tVD z{=m0A^%F&vZ~Ge7YVb0PDtx|c%@MhSB%^#DT*-jRnd_;1o=Tg=VE7y0|+j zFZZBYz*w@{XOW>a9$nliKjwuO?l)*vYS8*sx(>hdPlOEXNyu{*ed_G*?_miW>PSoS z|9R2>fu?Oq3Yef0Q40i!cw3(7-MsPh#kfidP8az8Bd_&7xbY79)OX%e6}94&ywiJ% zyd-~gs4zCQ$e6|5%Lr!>)N6|4tzJX%ChjgGTtavY;g<++BV0y!2LWG0aUDUGs$Mgc uByi5uqzD{}g%?9s_2p!wLj&MnGl%0HeXrZn{=GhjY`=Yg#J}=iBL4#usxtim delta 2101 zcmZuxYfRHu6#w7SmY2Mzh-gYFAe8{}5G?cFwIx9i`-VPtWu_k{u8zuPE*`oucY_%(!ig1w4{fhODQ@bOFy;U{T!W zklb?Q+Lv+Dm$LnkN_Qfj5Z;=`qe8&>M*G0ae48C*G*JE_& z@)|XT*Op^>zK=hL2b{4yPs47fKJBoBcV8_uI2uEXBSI;yf7sw=NgtSrD4>+lq8woKLq9^39kYe^9fV2yNU+uw(jwRozKV2q1^g9fqk|3#avaX6aUu~6 z!AVCR8;5I-W;UV4R8BF&XXw7D%~ZXsH%y~BsvX^$ua6+pY7kQ}v2&l{Tl_K&nKh@9 zzCf$a`ovAZWKB-g4EhDZ&+wqe#AfKIwfx$zb!+D-a+_e1;Cq5Q2q=CG*3|FPJz&sR zf5$vRfrLOHsD1LDC%8+eA3aAV!95128}iv4_*zo5nTC7JauK~&CX#5;iL1EGGV#Pj zjlxfjIV=&9oVhu&qwkOu=xClS*_K|&aj=m?c030MoaU7w`S6x=gW-2%Mrt>mml@t) zU$dDVgR{-~EDt_!b_8~njSSpws9jm)Of66`f-ER+DPRk@sats8m95o=ztCF@Bdt4( z@_>>c#+AoLAi`CISne_*c1V2Dm0|pWbVlH+%Mt%KnS}(8ocCR2$x=dgqZi#2I~7RS z+Ex`VWB({{rY!|EZ?};?;!vP0@^G?0CAw55@1K$0o`J#k9^YUmPQ(<&=F4e#Pa;Q*BQ5;33u5t+sFP_o|KRz#;Eor}&K$WigpZ z1A!S2DI@-ILW8&KuqrymA4EP%u(X#_@hXJ3zsAnPo9*dCGAMs7Ii=@#;|IL`$84-OmiiU0rr diff --git a/app/main.py b/app/main.py index 28c9674..3205d6d 100644 --- a/app/main.py +++ b/app/main.py @@ -23,6 +23,7 @@ from .routers import ( organizations, players, registration, + rounds, scoring, tournaments, ) @@ -52,6 +53,7 @@ app.include_router(registration.router) app.include_router(registration.org_router) app.include_router(messaging.router) app.include_router(messaging.public_router) +app.include_router(rounds.router) @app.get("/health") diff --git a/app/routers/rounds.py b/app/routers/rounds.py new file mode 100644 index 0000000..f5087ff --- /dev/null +++ b/app/routers/rounds.py @@ -0,0 +1,619 @@ +""" +Frittstående rundeføring med detaljert statistikk (ADR-033). + +Eid av en BRUKER (`app_user.id`), ikke en organisasjon -- INGEN RLS på +`round`/`round_participant`/`round_hole`/`personal_course*` (Beslutning A). +Samme mønster som personlig profil/HCP-historikk/sekundær e-post: kun +`plain_connection()`, autorisasjon håndheves eksplisitt her med +`WHERE owner_user_id = $1` (eller via round_id -> owner-kjeden). + +Banedata (Beslutning C): offisielle teeoff-baner slås opp LIVE ved hver +runde-opprettelse/deltaker-tilføyelse (ingen lokal kopi) via samme +`teeoff_client` som den org-scopede import-flyten i courses.py bruker -- +men her INGEN `course`/`hole`/`tee`-rader skrives, kun et navn-snapshot +på selve runden og et rating-snapshot per deltaker. + +v1-avgrensning (eksplisitt notert i ADR-033, ikke løst her): en deltaker +med `user_id` satt får IKKE egen tilgang til runden -- derfor støtter +DENNE runden av API-et kun GJEST-deltakere (fritekstnavn), ikke ekte +kontokobling. `round_participant.user_id`-kolonnen brukes fortsatt for +runde-EIEREN (alltid en ekte konto), bare ikke for andre i flighten ennå. + +HCP-indeks-oppdatering (app_user.handicap_index/handicap_history) skjer +IKKE automatisk her -- eksplisitt uavklart punkt i ADR-033. Et fullført, +tellende resultat lagres kun som `round_participant.score_differential`. +""" + +from __future__ import annotations + +from datetime import date +from typing import Literal + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field + +from .. import teeoff_client +from ..auth import CurrentUser, get_current_user +from ..db import plain_connection +from ..errors import app_error, translate_db_errors +from handicap_engine import ( + adjusted_gross_score, + allocate_strokes_by_index, + course_handicap, + round_counts_for_handicap, + score_differential, +) + +router = APIRouter(tags=["rounds"]) + + +# --------------------------------------------------------------------------- +# Global banekatalog for egendefinerte (ikke-teeoff) baner +# --------------------------------------------------------------------------- + +class PersonalCourseHoleIn(BaseModel): + hole_number: int = Field(ge=1, le=18) + par: int = Field(ge=3, le=6) + stroke_index: int = Field(ge=1, le=18) + + +class PersonalCourseTeeRatingIn(BaseModel): + gender: Literal["m", "f"] + course_rating: float + slope_rating: int = Field(ge=55, le=155) + par: int + + +class PersonalCourseTeeIn(BaseModel): + name: str + ratings: list[PersonalCourseTeeRatingIn] = Field(min_length=1, max_length=2) + + +class PersonalCourseCreate(BaseModel): + name: str + holes: list[PersonalCourseHoleIn] = Field(min_length=18, max_length=18) + tees: list[PersonalCourseTeeIn] = Field(min_length=1) + + +class PersonalCourseOut(BaseModel): + id: str + name: str + + +@router.get("/personal-courses", response_model=list[PersonalCourseOut]) +async def search_personal_courses( + q: str = "", + user: CurrentUser = Depends(get_current_user), +) -> list[PersonalCourseOut]: + async with plain_connection() as conn: + rows = await conn.fetch( + "SELECT id::text AS id, name FROM personal_course WHERE name ILIKE $1 ORDER BY name LIMIT 20", + f"%{q.strip()}%", + ) + return [PersonalCourseOut(**dict(r)) for r in rows] + + +@router.post("/personal-courses", response_model=PersonalCourseOut, status_code=201) +async def create_personal_course( + body: PersonalCourseCreate, + user: CurrentUser = Depends(get_current_user), +) -> PersonalCourseOut: + numbers = sorted(h.hole_number for h in body.holes) + indexes = sorted(h.stroke_index for h in body.holes) + if numbers != list(range(1, 19)) or indexes != list(range(1, 19)): + raise app_error(400, "VALIDATION_FAILED", "Alle 18 hullnumre og alle 18 stroke-indekser må være unike, 1-18.") + + async with plain_connection() as conn, translate_db_errors(): + async with conn.transaction(): + course_row = await conn.fetchrow( + "INSERT INTO personal_course (name, created_by_user_id) VALUES ($1, $2) RETURNING id::text AS id, name", + body.name.strip(), + user.user_id, + ) + for h in body.holes: + await conn.execute( + "INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)", + course_row["id"], + h.hole_number, + h.par, + h.stroke_index, + ) + for t in body.tees: + tee_row = await conn.fetchrow( + "INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id", + course_row["id"], + t.name.strip(), + ) + for r in t.ratings: + await conn.execute( + """ + INSERT INTO personal_course_tee_rating + (personal_course_tee_id, gender, course_rating, slope_rating, par) + VALUES ($1, $2, $3, $4, $5) + """, + tee_row["id"], + r.gender, + r.course_rating, + r.slope_rating, + r.par, + ) + return PersonalCourseOut(**dict(course_row)) + + +# --------------------------------------------------------------------------- +# Banedata-oppslag (delt mellom opprett-runde og legg-til-deltaker) +# --------------------------------------------------------------------------- +# +# Returnerer (par_per_hole, stroke_index_per_hole, tee_names) og en +# funksjon for å slå opp (course_rating, slope_rating, par) for en gitt +# tee+kjønn -- felles for BÅDE teeoff (live) og personal_course (lagret). + +class _ResolvedCourse: + def __init__(self, holes: list[tuple[int, int, int]], course_name: str): + # holes: liste av (hole_number, par, stroke_index), sortert 1..18 + self.holes = holes + self.course_name = course_name + self._ratings: dict[tuple[str, str], tuple[float, int, int]] = {} + + def add_rating(self, tee_name: str, gender: str, course_rating: float, slope_rating: int, par: int) -> None: + self._ratings[(tee_name, gender)] = (course_rating, slope_rating, par) + + def rating_for(self, tee_name: str, gender: str) -> tuple[float, int, int] | None: + return self._ratings.get((tee_name, gender)) + + +async def _resolve_teeoff_course(facility_slug: str, teeoff_course_id: int) -> _ResolvedCourse: + try: + facility = await teeoff_client.get_facility(facility_slug) + except teeoff_client.TeeoffNotFoundError: + raise app_error(404, "NOT_FOUND", "Anlegget finnes ikke i teeoff.") + except teeoff_client.TeeoffUnavailableError: + raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra teeoff akkurat nå.") + + course_data = next((c for c in facility.get("courses", []) if c.get("id") == teeoff_course_id), None) + if course_data is None: + raise app_error(404, "NOT_FOUND", "Banen finnes ikke på dette anlegget i teeoff.") + + raw_holes = course_data.get("holes") or [] + if len(raw_holes) != 18: + raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull i teeoff ennå.") + for h in raw_holes: + if h.get("par") is None or h.get("hcp_index") is None: + raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen mangler par eller HCP-index på ett eller flere hull.") + + course_name = f"{facility.get('name')} – {course_data.get('name') or 'Bane'}" + resolved = _ResolvedCourse( + holes=sorted((h["hole_number"], h["par"], h["hcp_index"]) for h in raw_holes), + course_name=course_name, + ) + for t in course_data.get("tees") or []: + name = t.get("name") or "Tee" + if t.get("cr_men") is not None and t.get("slope_men") is not None: + resolved.add_rating(name, "m", float(t["cr_men"]), int(t["slope_men"]), sum(h[1] for h in resolved.holes)) + if t.get("cr_women") is not None and t.get("slope_women") is not None: + resolved.add_rating(name, "f", float(t["cr_women"]), int(t["slope_women"]), sum(h[1] for h in resolved.holes)) + return resolved + + +async def _resolve_personal_course(personal_course_id: str) -> _ResolvedCourse: + async with plain_connection() as conn: + course_row = await conn.fetchrow("SELECT name FROM personal_course WHERE id = $1", personal_course_id) + if course_row is None: + raise app_error(404, "NOT_FOUND", "Den egendefinerte banen finnes ikke.") + hole_rows = await conn.fetch( + "SELECT hole_number, par, stroke_index FROM personal_course_hole WHERE personal_course_id = $1", + personal_course_id, + ) + tee_rows = await conn.fetch( + """ + SELECT t.name AS tee_name, r.gender, r.course_rating, r.slope_rating, r.par + FROM personal_course_tee t + JOIN personal_course_tee_rating r ON r.personal_course_tee_id = t.id + WHERE t.personal_course_id = $1 + """, + personal_course_id, + ) + resolved = _ResolvedCourse( + holes=sorted((h["hole_number"], h["par"], h["stroke_index"]) for h in hole_rows), + course_name=course_row["name"], + ) + for t in tee_rows: + resolved.add_rating(t["tee_name"], t["gender"], float(t["course_rating"]), t["slope_rating"], t["par"]) + return resolved + + +# --------------------------------------------------------------------------- +# Runder +# --------------------------------------------------------------------------- + +class RoundCreate(BaseModel): + course_source: Literal["teeoff", "custom"] + teeoff_facility_slug: str | None = None + teeoff_course_id: int | None = None + personal_course_id: str | None = None + tee_name: str + played_at: date + start_hole: int = Field(default=1, ge=1, le=18) + holes_planned: Literal[9, 18] = 18 + + +class RoundParticipantOut(BaseModel): + id: str + user_id: str | None + guest_name: str | None + is_owner: bool + gender: str + handicap_index_snapshot: float | None + course_handicap_snapshot: int | None + counts_for_handicap: bool + score_differential: float | None + + +class RoundOut(BaseModel): + id: str + course_source: str + course_name_snapshot: str + tee_name_snapshot: str + played_at: date + start_hole: int + holes_planned: int + completed_at: str | None + participants: list[RoundParticipantOut] + + +async def _load_round_out(conn, round_id: str) -> RoundOut: + round_row = await conn.fetchrow( + """ + SELECT id::text AS id, course_source, course_name_snapshot, tee_name_snapshot, + played_at, start_hole, holes_planned, completed_at + FROM round WHERE id = $1 + """, + round_id, + ) + participant_rows = await conn.fetch( + """ + SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender, + handicap_index_snapshot::float AS handicap_index_snapshot, + course_handicap_snapshot, counts_for_handicap, + score_differential::float AS score_differential + FROM round_participant WHERE round_id = $1 ORDER BY is_owner DESC, created_at + """, + round_id, + ) + return RoundOut( + id=round_row["id"], + course_source=round_row["course_source"], + course_name_snapshot=round_row["course_name_snapshot"], + tee_name_snapshot=round_row["tee_name_snapshot"], + played_at=round_row["played_at"], + start_hole=round_row["start_hole"], + holes_planned=round_row["holes_planned"], + completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None, + participants=[RoundParticipantOut(**dict(r)) for r in participant_rows], + ) + + +async def _create_participant( + conn, + round_id: str, + resolved: _ResolvedCourse, + tee_name: str, + *, + user_id: str | None, + guest_name: str | None, + is_owner: bool, + gender: str, + handicap_index: float | None, +) -> str: + rating = resolved.rating_for(tee_name, gender) + course_rating = slope_rating = tee_par = None + course_handicap_snapshot = None + if rating is not None: + course_rating, slope_rating, tee_par = rating + if handicap_index is not None: + course_handicap_snapshot = course_handicap(handicap_index, slope_rating, course_rating, tee_par) + + participant_row = await conn.fetchrow( + """ + INSERT INTO round_participant + (round_id, user_id, guest_name, is_owner, gender, handicap_index_snapshot, + course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id::text AS id + """, + round_id, + user_id, + guest_name, + is_owner, + gender, + handicap_index, + course_rating, + slope_rating, + tee_par, + course_handicap_snapshot, + ) + participant_id = participant_row["id"] + + for hole_number, par, stroke_index in resolved.holes: + await conn.execute( + "INSERT INTO round_hole (round_participant_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)", + participant_id, + hole_number, + par, + stroke_index, + ) + return participant_id + + +@router.post("/rounds", response_model=RoundOut, status_code=201) +async def create_round( + body: RoundCreate, + user: CurrentUser = Depends(get_current_user), +) -> RoundOut: + if body.course_source == "teeoff": + if not body.teeoff_facility_slug or not body.teeoff_course_id: + raise app_error(400, "VALIDATION_FAILED", "teeoff_facility_slug og teeoff_course_id er påkrevd.") + resolved = await _resolve_teeoff_course(body.teeoff_facility_slug, body.teeoff_course_id) + else: + if not body.personal_course_id: + raise app_error(400, "VALIDATION_FAILED", "personal_course_id er påkrevd.") + resolved = await _resolve_personal_course(body.personal_course_id) + + async with plain_connection() as conn: + owner_row = await conn.fetchrow( + "SELECT gender, handicap_index::float AS handicap_index FROM app_user WHERE id = $1", + user.user_id, + ) + if owner_row is None or owner_row["gender"] is None: + raise app_error(400, "VALIDATION_FAILED", "Fullfør profilen din (kjønn/HCP) før du registrerer en runde.") + + if resolved.rating_for(body.tee_name, owner_row["gender"]) is None: + raise app_error( + 400, "VALIDATION_FAILED", + "Valgt utslag har ingen rating for ditt registrerte kjønn på denne banen.", + ) + + async with conn.transaction(), translate_db_errors(): + round_row = await conn.fetchrow( + """ + INSERT INTO round + (owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id, + personal_course_id, course_name_snapshot, tee_name_snapshot, played_at, + start_hole, holes_planned) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id::text AS id + """, + user.user_id, + body.course_source, + body.teeoff_facility_slug, + str(body.teeoff_course_id) if body.teeoff_course_id is not None else None, + body.personal_course_id, + resolved.course_name, + body.tee_name, + body.played_at, + body.start_hole, + body.holes_planned, + ) + round_id = round_row["id"] + + await _create_participant( + conn, round_id, resolved, body.tee_name, + user_id=user.user_id, guest_name=None, is_owner=True, + gender=owner_row["gender"], handicap_index=owner_row["handicap_index"], + ) + + return await _load_round_out(conn, round_id) + + +@router.get("/rounds", response_model=list[RoundOut]) +async def list_rounds(user: CurrentUser = Depends(get_current_user)) -> list[RoundOut]: + async with plain_connection() as conn: + ids = await conn.fetch( + "SELECT id::text AS id FROM round WHERE owner_user_id = $1 ORDER BY played_at DESC, created_at DESC", + user.user_id, + ) + return [await _load_round_out(conn, r["id"]) for r in ids] + + +async def _get_owned_round_or_404(conn, round_id: str, user_id: str): + row = await conn.fetchrow("SELECT owner_user_id::text AS owner_user_id FROM round WHERE id = $1", round_id) + if row is None: + raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") + if row["owner_user_id"] != user_id: + raise app_error(403, "NOT_AUTHORIZED", "Du eier ikke denne runden.") + + +@router.get("/rounds/{round_id}", response_model=RoundOut) +async def get_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut: + async with plain_connection() as conn: + await _get_owned_round_or_404(conn, round_id, user.user_id) + return await _load_round_out(conn, round_id) + + +@router.delete("/rounds/{round_id}", status_code=204) +async def delete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> None: + async with plain_connection() as conn: + await _get_owned_round_or_404(conn, round_id, user.user_id) + await conn.execute("DELETE FROM round WHERE id = $1", round_id) + + +# --------------------------------------------------------------------------- +# Gjest-deltakere (flighten) -- se moduldoc om hvorfor kun gjester i v1 +# --------------------------------------------------------------------------- + +class GuestParticipantCreate(BaseModel): + guest_name: str = Field(min_length=1, max_length=100) + gender: Literal["m", "f", "x"] + handicap_index: float | None = Field(default=None, ge=-10, le=54) + + +@router.post("/rounds/{round_id}/participants", response_model=RoundParticipantOut, status_code=201) +async def add_guest_participant( + round_id: str, + body: GuestParticipantCreate, + user: CurrentUser = Depends(get_current_user), +) -> RoundParticipantOut: + async with plain_connection() as conn: + await _get_owned_round_or_404(conn, round_id, user.user_id) + round_row = await conn.fetchrow( + "SELECT course_source, teeoff_facility_slug, teeoff_course_id, personal_course_id, tee_name_snapshot FROM round WHERE id = $1", + round_id, + ) + if round_row["course_source"] == "teeoff": + resolved = await _resolve_teeoff_course(round_row["teeoff_facility_slug"], int(round_row["teeoff_course_id"])) + else: + resolved = await _resolve_personal_course(round_row["personal_course_id"]) + + if resolved.rating_for(round_row["tee_name_snapshot"], body.gender) is None and body.handicap_index is not None: + raise app_error( + 400, "VALIDATION_FAILED", + "Rundens utslag har ingen rating for dette kjønnet -- HCP-sporing er ikke mulig for denne deltakeren.", + ) + + async with conn.transaction(), translate_db_errors(): + participant_id = await _create_participant( + conn, round_id, resolved, round_row["tee_name_snapshot"], + user_id=None, guest_name=body.guest_name.strip(), is_owner=False, + gender=body.gender, handicap_index=body.handicap_index, + ) + row = await conn.fetchrow( + """ + SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender, + handicap_index_snapshot::float AS handicap_index_snapshot, + course_handicap_snapshot, counts_for_handicap, + score_differential::float AS score_differential + FROM round_participant WHERE id = $1 + """, + participant_id, + ) + return RoundParticipantOut(**dict(row)) + + +@router.delete("/rounds/{round_id}/participants/{participant_id}", status_code=204) +async def remove_guest_participant( + round_id: str, + participant_id: str, + user: CurrentUser = Depends(get_current_user), +) -> None: + async with plain_connection() as conn: + await _get_owned_round_or_404(conn, round_id, user.user_id) + row = await conn.fetchrow( + "SELECT is_owner FROM round_participant WHERE id = $1 AND round_id = $2", + participant_id, round_id, + ) + if row is None: + raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.") + if row["is_owner"]: + raise app_error(400, "VALIDATION_FAILED", "Kan ikke fjerne runde-eieren.") + await conn.execute("DELETE FROM round_participant WHERE id = $1", participant_id) + + +# --------------------------------------------------------------------------- +# Hull-for-hull-registrering +# --------------------------------------------------------------------------- + +class HoleUpdate(BaseModel): + played: bool = True + score: int | None = Field(default=None, ge=1, le=20) + putts: int | None = Field(default=None, ge=0, le=10) + club_off_tee: str | None = Field(default=None, max_length=50) + tee_shot_result: Literal["fairway", "left", "right"] | None = None + approach_result: Literal["hit", "long", "short", "left", "right"] | None = None + chip_count: int | None = Field(default=None, ge=0) + bunker_shot_count: int | None = Field(default=None, ge=0) + penalty_strokes: int | None = Field(default=None, ge=0) + first_putt_distance_m: float | None = Field(default=None, ge=0) + + +@router.patch("/rounds/{round_id}/participants/{participant_id}/holes/{hole_number}") +async def update_hole( + round_id: str, + participant_id: str, + hole_number: int, + body: HoleUpdate, + user: CurrentUser = Depends(get_current_user), +) -> dict: + async with plain_connection() as conn: + await _get_owned_round_or_404(conn, round_id, user.user_id) + owner_check = await conn.fetchval( + "SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2", + participant_id, round_id, + ) + if owner_check is None: + raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.") + + async with translate_db_errors(): + row = await conn.fetchrow( + """ + UPDATE round_hole SET + played = $3, score = $4, putts = $5, club_off_tee = $6, + tee_shot_result = $7, approach_result = $8, chip_count = $9, + bunker_shot_count = $10, penalty_strokes = $11, first_putt_distance_m = $12 + WHERE round_participant_id = $1 AND hole_number = $2 + RETURNING id + """, + participant_id, hole_number, + body.played, body.score, body.putts, body.club_off_tee, + body.tee_shot_result, body.approach_result, body.chip_count, + body.bunker_shot_count, body.penalty_strokes, body.first_putt_distance_m, + ) + if row is None: + raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.") + return {"ok": True} + + +# --------------------------------------------------------------------------- +# Fullføring -- Adjusted Gross Score / Score Differential (ADR-033 Beslutning G) +# --------------------------------------------------------------------------- + +@router.post("/rounds/{round_id}/complete", response_model=RoundOut) +async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut: + async with plain_connection() as conn: + await _get_owned_round_or_404(conn, round_id, user.user_id) + round_row = await conn.fetchrow("SELECT holes_planned FROM round WHERE id = $1", round_id) + + participants = await conn.fetch( + """ + SELECT id::text AS id, handicap_index_snapshot::float AS handicap_index_snapshot, + course_rating_snapshot::float AS course_rating_snapshot, + slope_rating_snapshot, tee_par_snapshot + FROM round_participant WHERE round_id = $1 + """, + round_id, + ) + + for p in participants: + holes = await conn.fetch( + """ + SELECT hole_number, par, stroke_index, played, score + FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number + """, + p["id"], + ) + played_count = sum(1 for h in holes if h["played"]) + counts = round_counts_for_handicap(played_count, round_row["holes_planned"]) + differential = None + + if counts and p["handicap_index_snapshot"] is not None and p["course_rating_snapshot"] is not None: + pars = [h["par"] for h in holes] + strokes_received = allocate_strokes_by_index( + _course_handicap_from_row(p), + [h["stroke_index"] for h in holes], + ) + scores = [h["score"] if h["played"] else None for h in holes] + ags = adjusted_gross_score(scores, pars, strokes_received) + differential = score_differential(ags, p["course_rating_snapshot"], p["slope_rating_snapshot"]) + + await conn.execute( + "UPDATE round_participant SET counts_for_handicap = $2, score_differential = $3 WHERE id = $1", + p["id"], counts, differential, + ) + + await conn.execute("UPDATE round SET completed_at = now() WHERE id = $1", round_id) + return await _load_round_out(conn, round_id) + + +def _course_handicap_from_row(p) -> int: + return course_handicap( + p["handicap_index_snapshot"], p["slope_rating_snapshot"], p["course_rating_snapshot"], p["tee_par_snapshot"] + ) diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 714eea0..c59f9fd 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -23,6 +23,10 @@ const nextConfig = { { source: "/orgs/:path*", destination: `${API_ORIGIN}/orgs/:path*` }, { source: "/public/:path*", destination: `${API_ORIGIN}/public/:path*` }, { source: "/superadmin/:path*", destination: `${API_ORIGIN}/superadmin/:path*` }, + { source: "/rounds/:path*", destination: `${API_ORIGIN}/rounds/:path*` }, + { source: "/rounds", destination: `${API_ORIGIN}/rounds` }, + { source: "/personal-courses/:path*", destination: `${API_ORIGIN}/personal-courses/:path*` }, + { source: "/personal-courses", destination: `${API_ORIGIN}/personal-courses` }, { source: "/health", destination: `${API_ORIGIN}/health` }, ] }, diff --git a/handicap_engine.py b/handicap_engine.py index 693e435..74452a4 100644 --- a/handicap_engine.py +++ b/handicap_engine.py @@ -301,6 +301,27 @@ def course_handicap_9( return round_half_up(course_handicap_9_raw(handicap_index, slope_rating_9, course_rating_9, par_9)) +def round_counts_for_handicap(played_holes_count: int, holes_planned: int) -> bool: + """Er en runde HCP-tellende ut fra hvor mange av 18 hull som ble spilt? + + Kilde: Rule 2.2. To distinkte terskler avhengig av spillerens ERKLÆRTE + INTENSJON (`holes_planned` — 9 eller 18, ADR-033 Beslutning E): + + - Intensjon 18 hull (Rule 2.2a): minst 10 av 18 må være spilt (resten + fylles med Net Par, se `adjusted_gross_score`-moduldocen). + - Intensjon 9 hull (Rule 2.2b): ALLE 9 må være spilt — ingen "minst 9", + færre enn 9 gjør scoren helt ugyldig for HCP-formål. + + Merk: Rule 2.2b sitt krav om at de 9 hullene må tilhøre et faktisk + RATET 9-hulls-sett (front/back) er ikke lenger relevant her — TeeCups + Net-Par-tilnærming (Beslutning G) bruker alltid banens fulle 18-hulls + rating, uansett hvilke konkrete hull som ble spilt. + """ + if holes_planned == 9: + return played_holes_count == 9 + return played_holes_count >= 10 + + # --------------------------------------------------------------------------- # Allowance-strategier (konfigurasjon per format) # --------------------------------------------------------------------------- diff --git a/test_handicap_engine.py b/test_handicap_engine.py index 96c22fd..257607f 100644 --- a/test_handicap_engine.py +++ b/test_handicap_engine.py @@ -34,6 +34,7 @@ from handicap_engine import ( match_play_strokes, max_hole_score_for_handicap, net_par, + round_counts_for_handicap, round_half_up, round_half_up_decimal, score_differential, @@ -471,6 +472,21 @@ def test_apply_index_caps_no_lower_limit(): assert apply_index_caps(new_index=5.0, low_handicap_index_value=20.0) == 5.0 +def test_round_counts_for_handicap_18_hole_intent(): + # Rule 2.2a: intensjon 18 hull, minst 10 av 18 må være spilt. + assert round_counts_for_handicap(18, holes_planned=18) is True + assert round_counts_for_handicap(10, holes_planned=18) is True + assert round_counts_for_handicap(9, holes_planned=18) is False + + +def test_round_counts_for_handicap_9_hole_intent(): + # Rule 2.2b: intensjon 9 hull, ALLE 9 må spilles -- ikke "minst 9". + assert round_counts_for_handicap(9, holes_planned=9) is True + assert round_counts_for_handicap(8, holes_planned=9) is False + # Færre enn 9 er alltid ugyldig, uansett intensjon. + assert round_counts_for_handicap(7, holes_planned=18) is False + + def test_course_handicap_9_halves_index_rule_6_1b(): # Rule 6.1b: indeksen HALVERES før den ganges med 9-hulls slope/113 -- # dette AVVIKER fra 18-hulls-formelen (course_handicap_raw), som IKKE