Migrasjonen er kjørt, og jeg har fortsatt helt til et komplett, verifisert API-lag.
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.
This commit is contained in:
parent
e3008be9bf
commit
3270d044de
11 changed files with 800 additions and 20 deletions
|
|
@ -348,7 +348,9 @@
|
||||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teeoff.no/)",
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teeoff.no/)",
|
||||||
"Bash(sudo apt-get install -y poppler-utils)",
|
"Bash(sudo apt-get install -y poppler-utils)",
|
||||||
"Bash(sudo -n 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": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
21
021_round_participant_rating_snapshot.sql
Normal file
21
021_round_participant_rating_snapshot.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -1849,9 +1849,60 @@ CHECK), to markerte eiere på samme runde avvist, duplikat hullnummer per
|
||||||
deltaker avvist, ugyldig approach_result-verdi avvist, kaskade-sletting
|
deltaker avvist, ugyldig approach_result-verdi avvist, kaskade-sletting
|
||||||
av en runde fjerner alle dens deltakere+hull men lar ANDRE runder stå
|
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å
|
urørt. `test_isolation.sql` fortsatt 12/12 (ingen RLS-regresjon på
|
||||||
eksisterende tabeller). **Gjenstår:** API-lag og frontend — ingen av
|
eksisterende tabeller). **Rullet ut mot ekte `teecup_db` 2026-07-22,**
|
||||||
disse er startet, og migrasjonen er IKKE kjørt mot ekte `teecup_db` ennå
|
bruker bekreftet eksplisitt: alle sju tabeller bekreftet opprettet,
|
||||||
(venter på egen bekreftelse, som all annen skriving mot ekte database).
|
`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,**
|
**Motor-komponenten (punkt 1-11) er ✅ BYGGET OG TESTET 2026-07-22,**
|
||||||
som første, isolerte byggesteg (ren Python, ingen DB/API/frontend ennå —
|
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
|
regel, full HCP-indeksformel) er enten eksplisitt bekreftet med bruker
|
||||||
(Beslutning C) eller presist kildebelagt fra den offisielle WHS Rules of
|
(Beslutning C) eller presist kildebelagt fra den offisielle WHS Rules of
|
||||||
Handicapping 2024 (Beslutning F/G) — ikke lenger antatt eller tilnærmet.
|
Handicapping 2024 (Beslutning F/G) — ikke lenger antatt eller tilnærmet.
|
||||||
**To byggesteg ferdig samme dag:** (1) hele HCP-indeks-motor-komponenten
|
**Tre byggesteg ferdig samme dag, alle rullet ut mot ekte systemer:**
|
||||||
(Beslutning G, punkt 1-11) bygget og testet i `handicap_engine.py`
|
(1) hele HCP-indeks-motor-komponenten (Beslutning G, punkt 1-11) bygget
|
||||||
(41/41 tester), (2) full databasemigrasjon (`020_personal_rounds.sql`,
|
og testet i `handicap_engine.py` (43/43 tester), (2) full
|
||||||
sju tabeller) skrevet og scratch-verifisert (9 sjekker, se detalj under
|
databasemigrasjon (`020_personal_rounds.sql` + rettefiksen `021_round_
|
||||||
Beslutning G/skjema-avsnittet). Gjenstår: API-lag, frontend, OG selve
|
participant_rating_snapshot.sql`, åtte tabeller/utvidelser) kjørt mot
|
||||||
kjøringen av migrasjonen mot ekte `teecup_db` (venter på egen
|
ekte `teecup_db`, (3) fullt API-lag (`app/routers/rounds.py`) bygget,
|
||||||
bekreftelse) — samme inkrementelle, scratch-verifiserte rytme som resten
|
scratch-verifisert (26 sjekker + egen teeoff-live-oppslag-test) og
|
||||||
av prosjektet.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
27
CLAUDE.md
27
CLAUDE.md
|
|
@ -2330,9 +2330,30 @@ Neste steg:
|
||||||
`teecup_app_scratch`, ikke superbruker — CHECK-constraints, XOR
|
`teecup_app_scratch`, ikke superbruker — CHECK-constraints, XOR
|
||||||
user_id/guest_name, kaskade-sletting, GIR-derivering bekreftet mot
|
user_id/guest_name, kaskade-sletting, GIR-derivering bekreftet mot
|
||||||
ekte data). INGEN RLS på disse tabellene (Beslutning A). `test_
|
ekte data). INGEN RLS på disse tabellene (Beslutning A). `test_
|
||||||
isolation.sql` fortsatt 12/12. **IKKE kjørt mot ekte `teecup_db`
|
isolation.sql` fortsatt 12/12.
|
||||||
ennå** — venter på egen bekreftelse. API-lag og frontend heller ikke
|
**Rullet ut mot ekte `teecup_db` 2026-07-22,** bruker bekreftet
|
||||||
startet. Se ADR-033 i ARCHITECTURE_DECISIONS.md for full detalj.
|
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,
|
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
|
se status over). **Del 2 (ekte konto-sammenslåing) fortsatt IKKE
|
||||||
designet:** hva skjer hvis den ønskede adressen ALLEREDE tilhører en
|
designet:** hva skjer hvis den ønskede adressen ALLEREDE tilhører en
|
||||||
|
|
|
||||||
|
|
@ -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
|
## 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
|
**Fremdrift 2026-07-22:** HCP-indeks-motor (43/43 tester), databaseskjema
|
||||||
(`handicap_engine.py`, 41/41 tester) OG full databasemigrasjon skrevet+
|
(`020`+`021`, sistnevnte en fiks for manglende rating-snapshot-kolonner),
|
||||||
scratch-verifisert (`020_personal_rounds.sql`, 9 sjekker). Se ADR-033 i
|
OG et fullt API-lag (`app/routers/rounds.py`) er alle bygget,
|
||||||
ARCHITECTURE_DECISIONS.md for full detalj. Gjenstår: kjøre migrasjonen mot
|
scratch-verifisert og rullet ut mot ekte `teecup_db`/`teecup_api`. Se
|
||||||
ekte `teecup_db` (venter på bekreftelse), API-lag, frontend.
|
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
|
**Se ADR-033 i ARCHITECTURE_DECISIONS.md for den fulle, besluttede
|
||||||
arkitekturen** (eierskapsmønster, statistikk-datamodell, HCP-indeksmotor).
|
arkitekturen** (eierskapsmønster, statistikk-datamodell, HCP-indeksmotor).
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -23,6 +23,7 @@ from .routers import (
|
||||||
organizations,
|
organizations,
|
||||||
players,
|
players,
|
||||||
registration,
|
registration,
|
||||||
|
rounds,
|
||||||
scoring,
|
scoring,
|
||||||
tournaments,
|
tournaments,
|
||||||
)
|
)
|
||||||
|
|
@ -52,6 +53,7 @@ app.include_router(registration.router)
|
||||||
app.include_router(registration.org_router)
|
app.include_router(registration.org_router)
|
||||||
app.include_router(messaging.router)
|
app.include_router(messaging.router)
|
||||||
app.include_router(messaging.public_router)
|
app.include_router(messaging.public_router)
|
||||||
|
app.include_router(rounds.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
|
||||||
619
app/routers/rounds.py
Normal file
619
app/routers/rounds.py
Normal file
|
|
@ -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"]
|
||||||
|
)
|
||||||
|
|
@ -23,6 +23,10 @@ const nextConfig = {
|
||||||
{ source: "/orgs/:path*", destination: `${API_ORIGIN}/orgs/:path*` },
|
{ source: "/orgs/:path*", destination: `${API_ORIGIN}/orgs/:path*` },
|
||||||
{ source: "/public/:path*", destination: `${API_ORIGIN}/public/:path*` },
|
{ source: "/public/:path*", destination: `${API_ORIGIN}/public/:path*` },
|
||||||
{ source: "/superadmin/:path*", destination: `${API_ORIGIN}/superadmin/: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` },
|
{ source: "/health", destination: `${API_ORIGIN}/health` },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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))
|
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)
|
# Allowance-strategier (konfigurasjon per format)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ from handicap_engine import (
|
||||||
match_play_strokes,
|
match_play_strokes,
|
||||||
max_hole_score_for_handicap,
|
max_hole_score_for_handicap,
|
||||||
net_par,
|
net_par,
|
||||||
|
round_counts_for_handicap,
|
||||||
round_half_up,
|
round_half_up,
|
||||||
round_half_up_decimal,
|
round_half_up_decimal,
|
||||||
score_differential,
|
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
|
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():
|
def test_course_handicap_9_halves_index_rule_6_1b():
|
||||||
# Rule 6.1b: indeksen HALVERES før den ganges med 9-hulls slope/113 --
|
# 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
|
# dette AVVIKER fra 18-hulls-formelen (course_handicap_raw), som IKKE
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue