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:
Erol Haagenrud 2026-07-22 11:31:08 +02:00
parent e3008be9bf
commit 3270d044de
11 changed files with 800 additions and 20 deletions

View file

@ -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",

View 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;

View file

@ -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.
---

View file

@ -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

View file

@ -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).

View file

@ -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")

619
app/routers/rounds.py Normal file
View 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
`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
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"]
)

View file

@ -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` },
]
},

View file

@ -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 være spilt (resten
fylles med Net Par, se `adjusted_gross_score`-moduldocen).
- Intensjon 9 hull (Rule 2.2b): ALLE 9 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 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)
# ---------------------------------------------------------------------------

View file

@ -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