Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer). Før jeg ruller ut mot ekte teecup_db, her er planen: Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres. Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12). Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden. Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
This commit is contained in:
parent
4d568fc957
commit
47bebc46a2
9 changed files with 1026 additions and 11 deletions
|
|
@ -3344,11 +3344,18 @@ prinsipp som ADR-005) + én ny CHECK-verdi, ikke en skjemaendring.
|
|||
(en "sesong"/"serie", org-scopet, RLS som ellers) som grupperer flere
|
||||
`tournament`-rader og akkumulerer poeng — IKKE designet i denne
|
||||
runden.
|
||||
- **Migrasjon/kode** — denne ADR-en er ren struktur-beslutning, ingen
|
||||
migrasjon er skrevet ennå. Neste steg er å legge frem et konkret
|
||||
migrasjonsutkast (nye tabeller + `tournament.format_type`-kolonne) til
|
||||
gjennomgang før noe kjøres, samme "vis planen FØR noe skrives"-mønster
|
||||
som ellers i prosjektet.
|
||||
- **Migrasjon/kode — ✅ BYGGET OG SCRATCH-VERIFISERT 2026-07-30, IKKE ENNÅ
|
||||
RULLET UT.** Migrasjon `040_individual_tournaments.sql` (alle fem nye
|
||||
tabeller + `tournament.format_type`/`scoring_method`), nye motorfunksjoner
|
||||
i `handicap_engine.py` (`stroke_play_gross_total`/`stroke_play_net_total`/
|
||||
`stableford_points_for_hole`/`stableford_total`, 8 nye tester), og et
|
||||
fullt CRUD-API (`app/routers/individual_tournaments.py`: runder/
|
||||
turnering-deltakere/rundedeltakere/hull-for-hull-scoring/leaderboard).
|
||||
Se FEATURE_BACKLOG.md ("Utvidelse 2026-07-26"-seksjonens oppdatering
|
||||
2026-07-30) for full detalj om design, funn under bygging og
|
||||
verifisering (52 API-sjekker + 63 motor-tester). Frontend er bevisst
|
||||
IKKE bygget i denne runden — neste steg i samme "motor → skjema → API →
|
||||
frontend"-rekkefølge som ADR-033/038/039.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
77
CLAUDE.md
77
CLAUDE.md
|
|
@ -5512,6 +5512,83 @@ Ferdig og verifisert:
|
|||
turneringer siden i dag) -- se ARCHITECTURE_DECISIONS.md sitt åpne
|
||||
spørsmål 4 (Scramble-grensesnitt), som dermed er helt avsluttet.
|
||||
|
||||
- **ADR-037 (individuelle/flerrunde-turneringer): migrasjon + motor + API
|
||||
BYGGET OG SCRATCH-VERIFISERT, IKKE ENNÅ RULLET UT (2026-07-30):** bruker
|
||||
ba om å gå videre med anbefalingen fra en dypdykk-gjennomgang av
|
||||
.md-filene -- det eneste gjenværende punktet med en ferdig, load-bærende
|
||||
strukturbeslutning (ADR-037, 2026-07-26) uten kode. Bygget i tre lag,
|
||||
samme "test i isolasjon FØR resten"-rekkefølge som ADR-005/033/038/039.
|
||||
**Migrasjon `040_individual_tournaments.sql`:** `tournament.format_type`
|
||||
(`team`/`individual`, default `team` -- alle eksisterende rader uendret)
|
||||
+ `tournament.scoring_method` (`stroke_gross`/`stroke_net`/`stableford`,
|
||||
nullable). Fem nye, RLS-beskyttede tabeller: `tournament_round` (samme
|
||||
rolle som `session`, peker til org-ens EGEN bane -- ingen snapshot,
|
||||
ulikt ADR-033), `tournament_participant` (samme rolle som `team_roster`,
|
||||
fryser `handicap_index_snapshot`), `tournament_round_participant`
|
||||
(tee + cachet course/playing-handicap PER runde), `tournament_round_hole`
|
||||
(rå brutto slag, kilde-sannhet), `tournament_round_score` (ferdig
|
||||
utregnet brutto/netto/Stableford-total PER deltaker PER runde, cachet --
|
||||
samme mønster som `match.status_text`/`points_side_a/b`; sammenlagt over
|
||||
flere runder summeres VED LESING i leaderboardet, ingen egen tredje
|
||||
cache-tabell, Beslutning C).
|
||||
**Scratch-verifisert alene FØR API-et ble bygget:** alle 40 migrasjoner
|
||||
kjørte rent i rekkefølge, `test_isolation.sql` fortsatt 12/12, 10 egne
|
||||
funksjonelle sjekker (kryss-org-isolasjon på BÅDE lesing og skriving,
|
||||
`format_type`-CHECK+default, unik-constraints, `gross_strokes`-CHECK,
|
||||
kaskade-sletting).
|
||||
**Motor** (`handicap_engine.py`, ny seksjon rett etter
|
||||
`allocate_over_played_holes`): `stroke_play_gross_total`/
|
||||
`stroke_play_net_total`/`stableford_points_for_hole`/`stableford_total`
|
||||
-- rene funksjoner, ingen ny slagfordeling (bruker samme
|
||||
`allocate_over_played_holes`-output som resten av motoren). 8 nye
|
||||
tester, alle 63 (55 eksisterende + 8 nye) bestått i
|
||||
`test_handicap_engine.py`.
|
||||
**API** (nytt `app/routers/individual_tournaments.py`, registrert i
|
||||
`main.py`): CRUD for runder/turnering-deltakere/rundedeltakere (med
|
||||
handicap-beregning ved tilføyelse -- v1 har INGEN allowance-prosent for
|
||||
individuelle turneringer, `playing_handicap` er alltid identisk med
|
||||
avrundet `course_handicap`, ulikt lagturneringenes komplekse relative
|
||||
`AllowanceStrategy`-familie, som er bygget for et to-siders oppgjør og
|
||||
ikke gir mening for et flatt felt), hull-for-hull-scoring
|
||||
(`PATCH .../holes/{n}`, cacher totalen på nytt ved hver innsending),
|
||||
leaderboard som summerer på tvers av runder ved lesing.
|
||||
`tournaments.py` sin `TournamentCreate`/`TournamentUpdate`/`Tournament`
|
||||
utvidet med `format_type`/`scoring_method` (samme `exclude_unset`-PATCH-
|
||||
mønster som resten av filen).
|
||||
**To reelle funn, begge fikset FØR utrulling:**
|
||||
1. Leaderboard-endepunktet kunne IKKE hete
|
||||
`/orgs/{id}/tournaments/{id}/leaderboard` -- den stien er allerede
|
||||
`tournaments.py` sitt LAG-leaderboard, og siden `tournaments.router`
|
||||
registreres FØR `individual_tournaments.router` i `main.py`, ville
|
||||
det stille skygget for det nye endepunktet (funnet presist ved en
|
||||
ekte API-test som krasjet på feil responsform). Løst med et eget
|
||||
navn, `/individual-leaderboard` -- samme kollisjonsklasse som
|
||||
`/rounds` vs. `/my-rounds` tidligere, denne gangen unngått fra start.
|
||||
2. `list_rounds`/`list_tournament_participants` manglet en eksplisitt
|
||||
"finnes turneringen"-sjekk (samme mønster `list_sessions` allerede
|
||||
har) -- ga stille en tom liste under RLS for en fremmed
|
||||
turnering-id i stedet for 404 (ingen sikkerhetslekkasje, RLS
|
||||
blokkerte fortsatt all faktisk data, men inkonsistent med resten av
|
||||
API-et). Rettet til å matche `list_sessions` presist.
|
||||
**Scratch-API-verifisert grundig, 52/52 sjekker** (isolert
|
||||
`teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs
|
||||
API-container, ekte HTTP via `requests`, ekte magic-link-innlogging via
|
||||
dev-log): full happy path fra org til leaderboard, hånd-utregnet netto-
|
||||
kryssjekk for to spillere (course handicap 11/20, stemte eksakt),
|
||||
`front_9`-runde avviser hull utenfor omfang, kryss-org-isolasjon
|
||||
(bekreftet BÅDE lesing og en FK-basert skrivesperre), slette-vern (runde
|
||||
MED deltakere avvist 409, turnering-deltaker fortsatt referert av en
|
||||
rundedeltaker avvist 400 RESTRICT-FK, løst opp igjen etter fjerning).
|
||||
`test_isolation.sql` 12/12 uendret (additiv migrasjon).
|
||||
**IKKE bygget i denne runden, bevisst neste steg:** frontend (ingen
|
||||
skjerm ennå -- samme lagdelings-rekkefølge som ADR-033/038/039).
|
||||
Autorisasjon er bredt org-medlemskap for ALT i denne runden, inkl. selve
|
||||
scoreregistreringen -- en senere innstramming (analogt ADR-023s
|
||||
kaptein-only) er en naturlig, separat oppfølger. De fem konkrete
|
||||
formatene (Københavner m.fl.) og Order of Merit fortsatt ikke designet.
|
||||
**IKKE rullet ut mot ekte `teecup_db` ennå** -- venter på brukerens
|
||||
eksplisitte bekreftelse.
|
||||
|
||||
Neste steg:
|
||||
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
||||
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen
|
||||
|
|
|
|||
|
|
@ -665,9 +665,86 @@ lenger ned i denne filen ("Frittstående runder: flere flighter...").
|
|||
|
||||
**Fortsatt IKKE avgjort:** de fem konkrete formatenes egne poengregler
|
||||
(punkt utenfor denne strukturrunden), og (3) Order of Merit — bekreftet
|
||||
som naturlig SISTE steg, ikke designet. **Ingen migrasjon skrevet** —
|
||||
ADR-037 er ren struktur-beslutning, neste steg er et konkret
|
||||
migrasjonsutkast til gjennomgang.
|
||||
som naturlig SISTE steg, ikke designet.
|
||||
|
||||
### Oppdatering 2026-07-30: migrasjon + motor + API BYGGET OG SCRATCH-VERIFISERT, IKKE ENNÅ RULLET UT
|
||||
|
||||
Migrasjon `040_individual_tournaments.sql` skrevet nøyaktig etter ADR-037s
|
||||
fire beslutninger (`tournament.format_type`/`scoring_method`,
|
||||
`tournament_round`, `tournament_participant`, `tournament_round_participant`,
|
||||
`tournament_round_hole`, `tournament_round_score` — full RLS org-isolasjon
|
||||
på alle fem nye tabellene). Scratch-verifisert alene FØR API-et ble bygget
|
||||
(alle 40 migrasjoner kjørte rent i rekkefølge, `test_isolation.sql` 12/12,
|
||||
10 egne funksjonelle sjekker: kryss-org-isolasjon på lesing OG skriving,
|
||||
`format_type`-CHECK+default, unik-constraints, `gross_strokes`-CHECK,
|
||||
kaskade-sletting).
|
||||
|
||||
**Motor** (`handicap_engine.py`, ny seksjon rett etter
|
||||
`allocate_over_played_holes`): `stroke_play_gross_total`/
|
||||
`stroke_play_net_total`/`stableford_points_for_hole`/`stableford_total` —
|
||||
rene funksjoner, ingen ny fordelingslogikk (bruker samme
|
||||
`allocate_over_played_holes`-output som resten av motoren). 8 nye tester i
|
||||
`test_handicap_engine.py`, alle 63 (55 eksisterende + 8 nye) bestått.
|
||||
|
||||
**API** (nytt `app/routers/individual_tournaments.py`, registrert i
|
||||
`main.py`): CRUD for runder/turnering-deltakere/rundedeltakere (med
|
||||
handicap-beregning ved tilføyelse -- v1 har INGEN allowance-prosent for
|
||||
individuelle turneringer, `playing_handicap` er alltid identisk med
|
||||
avrundet `course_handicap`, ulikt lagturneringenes
|
||||
`AllowanceStrategy`-familie som er bygget for et relativt to-siders
|
||||
oppgjør), hull-for-hull-scoring (`PATCH .../holes/{n}`, cacher
|
||||
brutto/netto/Stableford-total på nytt ved hver innsending -- samme mønster
|
||||
som `recompute_and_cache_match_state`), og et leaderboard som summerer
|
||||
`tournament_round_score` PÅ TVERS AV RUNDER ved lesing (Beslutning C).
|
||||
`tournaments.py` sin `TournamentCreate`/`TournamentUpdate`/`Tournament`
|
||||
utvidet med `format_type`/`scoring_method` (samme `exclude_unset`-PATCH-
|
||||
mønster som resten av filen).
|
||||
|
||||
**Reelt funn under bygging, ikke antatt riktig:** leaderboard-endepunktet
|
||||
kunne IKKE hete `/orgs/{id}/tournaments/{id}/leaderboard` -- den stien er
|
||||
allerede `tournaments.py` sitt LAG-leaderboard (points_side_a/b, forventer
|
||||
nøyaktig to lag), og siden `tournaments.router` registreres FØR
|
||||
`individual_tournaments.router` i `main.py`, ville det eksisterende
|
||||
endepunktet stille skygget for det nye (funnet presist ved en ekte
|
||||
API-test som krasjet på `points_side_a`-formen den ikke fikk). Løst ved å
|
||||
gi det et eget navn, `/individual-leaderboard` -- samme kollisjonsklasse
|
||||
som `/rounds` vs. `/my-rounds` tidligere i prosjektet, denne gangen unngått
|
||||
fra start i stedet for oppdaget i produksjon.
|
||||
|
||||
**Reelt funn under selve scratch-API-testingen, fikset FØR utrulling:**
|
||||
`list_rounds`/`list_tournament_participants` manglet en eksplisitt
|
||||
"finnes turneringen"-sjekk (samme mønster `list_sessions` allerede har for
|
||||
lagturneringer) -- ga stille en tom liste under RLS for en fremmed
|
||||
turnering-id i stedet for 404. Ingen sikkerhetslekkasje (RLS blokkerte
|
||||
fortsatt all faktisk data), men inkonsistent med resten av API-et. Begge
|
||||
rettet til å matche `list_sessions` sin konvensjon presist.
|
||||
|
||||
**Scratch-API-verifisert grundig, 52/52 sjekker** (isolert
|
||||
`teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs
|
||||
API-container, ekte HTTP via `requests`, ekte magic-link-innlogging via
|
||||
dev-log): full happy path (org→spillere→bane→18 hull→tee→individuell
|
||||
turnering→runde→to turnering-deltakere→to rundedeltakere med riktig
|
||||
beregnet course handicap→hull-for-hull-scoring→leaderboard), hånd-
|
||||
utregnet netto-kryssjekk for begge spillere (course handicap 11/20,
|
||||
stemte eksakt), `front_9`-runde avviser hull utenfor omfang, kryss-org-
|
||||
isolasjon (bekreftet BÅDE at en fremmed org ikke ser dataene OG at en
|
||||
FK-basert skriving på tvers av org blokkeres), slette-vern (runde MED
|
||||
deltakere avvist 409, turnering-deltaker fortsatt referert av en
|
||||
rundedeltaker avvist 400 RESTRICT-FK). `test_isolation.sql` 12/12
|
||||
uendret (additiv migrasjon).
|
||||
|
||||
**IKKE bygget i denne runden, bevisst neste steg:** frontend (ingen
|
||||
skjerm for å opprette individuelle turneringer/runder/deltakere/scoring
|
||||
ennå) -- samme lagdelings-rekkefølge som ADR-033/ADR-038/ADR-039 (motor →
|
||||
skjema → API → frontend). Autorisasjon er i denne runden bredt
|
||||
org-medlemskap for ALT inkl. selve scoreregistreringen -- en senere
|
||||
innstramming (analogt ADR-023s kaptein-only for lagturneringer) er en
|
||||
naturlig, men separat oppfølger. De fem konkrete formatene (Københavner
|
||||
m.fl.) og Order of Merit fortsatt ikke designet.
|
||||
|
||||
**IKKE rullet ut mot ekte `teecup_db` ennå** -- venter på brukerens
|
||||
eksplisitte bekreftelse (migrasjon 040 + redeploy av `teecup_api`), per
|
||||
CLAUDE.md sin ufravikelige regel.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -19,6 +19,7 @@ from .routers import (
|
|||
auth,
|
||||
courses,
|
||||
friends,
|
||||
individual_tournaments,
|
||||
matches,
|
||||
messaging,
|
||||
notifications,
|
||||
|
|
@ -49,6 +50,7 @@ app.include_router(organizations.router)
|
|||
app.include_router(players.router)
|
||||
app.include_router(courses.router)
|
||||
app.include_router(tournaments.router)
|
||||
app.include_router(individual_tournaments.router)
|
||||
app.include_router(matches.router)
|
||||
app.include_router(scoring.router)
|
||||
app.include_router(registration.router)
|
||||
|
|
|
|||
753
app/routers/individual_tournaments.py
Normal file
753
app/routers/individual_tournaments.py
Normal file
|
|
@ -0,0 +1,753 @@
|
|||
"""
|
||||
Individuelle/flerrunde-turneringer (ADR-037): grunnstruktur oppå
|
||||
`040_individual_tournaments.sql`. Kun for `tournament.format_type =
|
||||
'individual'` -- lagturneringer (`format_type = 'team'`, standard) er
|
||||
uendret, bygget/vedlikeholdt i tournaments.py/matches.py/scoring.py.
|
||||
|
||||
Ett flatt felt av spillere (`tournament_participant`), over én eller flere
|
||||
runder (`tournament_round`, samme rolle som `session` for lagturneringer).
|
||||
Rå bruttoslag lagres per hull (`tournament_round_hole`, kilde-sannhet); et
|
||||
ferdig utregnet resultat caches per deltaker per runde
|
||||
(`tournament_round_score`) -- samme "regn på nytt ved hver innsending,
|
||||
SQL summerer bare det cachede ved lesing"-mønster som `match.status_text`/
|
||||
`points_side_a/b`. Sammenlagt over flere runder summeres VED LESING i
|
||||
leaderboardet, ikke i en egen tredje cache-tabell (ADR-037 Beslutning C).
|
||||
|
||||
Autorisasjon i denne runden: org-medlemskap (`get_authorized_org`) for ALT,
|
||||
inkl. selve score-registreringen -- samme brede tillitsnivå lagturneringer
|
||||
hadde FØR ADR-023 (kaptein-only) ble bygget som en egen, senere runde. En
|
||||
tilsvarende innstramming for individuelle turneringer er en naturlig
|
||||
oppfølger, ikke del av denne grunnstrukturen.
|
||||
|
||||
Bevisst UTENFOR omfang (se ADR-037): Københavner/High-low-high/Robbins/
|
||||
Try all/Flaggturnering (krever paring PÅ TVERS av spillere per hull, ikke
|
||||
bare uavhengige rene slagspill-/Stableford-poeng) og Order of Merit
|
||||
(sesong-sammenlagt på tvers av FLERE turneringer).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from handicap_engine import (
|
||||
allocate_over_played_holes,
|
||||
course_handicap_raw,
|
||||
round_half_up,
|
||||
stableford_total,
|
||||
stroke_play_gross_total,
|
||||
stroke_play_net_total,
|
||||
)
|
||||
|
||||
from ..auth import get_authorized_org
|
||||
from ..db import org_connection
|
||||
from ..errors import app_error, translate_db_errors
|
||||
from .scoring import played_hole_numbers
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_SCORING_METHODS_REQUIRING_HANDICAP = {"stroke_net", "stableford"}
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Runder (tournament_round)
|
||||
# =====================================================================
|
||||
|
||||
class TournamentRoundCreate(BaseModel):
|
||||
sequence: int = Field(ge=1)
|
||||
name: str | None = None
|
||||
hole_config: str = Field(default="full_18", pattern="^(full_18|front_9|back_9)$")
|
||||
course_id: str
|
||||
scheduled_at: str | None = None # ISO 8601, tolkes av asyncpg/Pydantic ved behov
|
||||
tee_interval_minutes: int | None = Field(default=None, gt=0)
|
||||
start_hole: int = Field(default=1, ge=1, le=18)
|
||||
|
||||
|
||||
class TournamentRound(BaseModel):
|
||||
id: str
|
||||
tournament_id: str
|
||||
sequence: int
|
||||
name: str | None
|
||||
hole_config: str
|
||||
course_id: str
|
||||
course_name: str
|
||||
scheduled_at: str | None
|
||||
tee_interval_minutes: int | None
|
||||
start_hole: int
|
||||
|
||||
|
||||
_ROUND_COLUMNS = """
|
||||
tr.id::text, tr.tournament_id::text, tr.sequence, tr.name,
|
||||
tr.hole_config::text, tr.course_id::text, c.name AS course_name,
|
||||
tr.scheduled_at::text, tr.tee_interval_minutes, tr.start_hole
|
||||
"""
|
||||
|
||||
|
||||
@router.post(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds",
|
||||
response_model=TournamentRound,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_round(
|
||||
tournament_id: str,
|
||||
body: TournamentRoundCreate,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> TournamentRound:
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
tournament = await conn.fetchrow(
|
||||
"SELECT format_type FROM tournament WHERE id = $1", tournament_id
|
||||
)
|
||||
if tournament is None:
|
||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||
if tournament["format_type"] != "individual":
|
||||
raise app_error(
|
||||
400, "VALIDATION_FAILED", "Runder kan kun opprettes for individuelle turneringer."
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
WITH inserted AS (
|
||||
INSERT INTO tournament_round
|
||||
(organization_id, tournament_id, sequence, name, hole_config,
|
||||
course_id, scheduled_at, tee_interval_minutes, start_hole)
|
||||
VALUES ($1, $2, $3, $4, $5::hole_scope, $6, $7, $8, $9)
|
||||
RETURNING *
|
||||
)
|
||||
SELECT {_ROUND_COLUMNS} FROM inserted tr JOIN course c ON c.id = tr.course_id
|
||||
""",
|
||||
organization_id,
|
||||
tournament_id,
|
||||
body.sequence,
|
||||
body.name,
|
||||
body.hole_config,
|
||||
body.course_id,
|
||||
body.scheduled_at,
|
||||
body.tee_interval_minutes,
|
||||
body.start_hole,
|
||||
)
|
||||
return TournamentRound(**dict(row))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds",
|
||||
response_model=list[TournamentRound],
|
||||
)
|
||||
async def list_rounds(
|
||||
tournament_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> list[TournamentRound]:
|
||||
async with org_connection(organization_id) as conn:
|
||||
exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
|
||||
if exists is None:
|
||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT {_ROUND_COLUMNS} FROM tournament_round tr
|
||||
JOIN course c ON c.id = tr.course_id
|
||||
WHERE tr.tournament_id = $1
|
||||
ORDER BY tr.sequence
|
||||
""",
|
||||
tournament_id,
|
||||
)
|
||||
return [TournamentRound(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
@router.delete("/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}", status_code=204)
|
||||
async def delete_round(
|
||||
tournament_id: str,
|
||||
round_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> None:
|
||||
async with org_connection(organization_id) as conn:
|
||||
has_participants = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM tournament_round_participant WHERE tournament_round_id = $1)",
|
||||
round_id,
|
||||
)
|
||||
if has_participants:
|
||||
raise app_error(
|
||||
409,
|
||||
"NOT_EMPTY",
|
||||
"Runden har allerede deltakere -- fjern dem først, eller la runden stå.",
|
||||
)
|
||||
deleted = await conn.fetchval(
|
||||
"DELETE FROM tournament_round WHERE id = $1 AND tournament_id = $2 RETURNING id",
|
||||
round_id,
|
||||
tournament_id,
|
||||
)
|
||||
if deleted is None:
|
||||
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Turnering-deltakere (tournament_participant -- flatt felt, intet lag)
|
||||
# =====================================================================
|
||||
|
||||
class TournamentParticipantCreate(BaseModel):
|
||||
player_id: str
|
||||
|
||||
|
||||
class TournamentParticipantOut(BaseModel):
|
||||
id: str
|
||||
player_id: str
|
||||
player_name: str
|
||||
handicap_index_snapshot: float | None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/participants",
|
||||
response_model=TournamentParticipantOut,
|
||||
status_code=201,
|
||||
)
|
||||
async def add_tournament_participant(
|
||||
tournament_id: str,
|
||||
body: TournamentParticipantCreate,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> TournamentParticipantOut:
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
player = await conn.fetchrow(
|
||||
"SELECT display_name, handicap_index FROM player WHERE id = $1", body.player_id
|
||||
)
|
||||
if player is None:
|
||||
raise app_error(404, "NOT_FOUND", "Spilleren finnes ikke.")
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO tournament_participant (organization_id, tournament_id, player_id, handicap_index_snapshot)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id::text
|
||||
""",
|
||||
organization_id,
|
||||
tournament_id,
|
||||
body.player_id,
|
||||
player["handicap_index"],
|
||||
)
|
||||
return TournamentParticipantOut(
|
||||
id=row["id"],
|
||||
player_id=body.player_id,
|
||||
player_name=player["display_name"],
|
||||
handicap_index_snapshot=player["handicap_index"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/participants",
|
||||
response_model=list[TournamentParticipantOut],
|
||||
)
|
||||
async def list_tournament_participants(
|
||||
tournament_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> list[TournamentParticipantOut]:
|
||||
async with org_connection(organization_id) as conn:
|
||||
exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
|
||||
if exists is None:
|
||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT tp.id::text AS id, tp.player_id::text AS player_id,
|
||||
p.display_name AS player_name,
|
||||
tp.handicap_index_snapshot::float AS handicap_index_snapshot
|
||||
FROM tournament_participant tp
|
||||
JOIN player p ON p.id = tp.player_id
|
||||
WHERE tp.tournament_id = $1
|
||||
ORDER BY p.display_name
|
||||
""",
|
||||
tournament_id,
|
||||
)
|
||||
return [TournamentParticipantOut(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/participants/{participant_id}",
|
||||
status_code=204,
|
||||
)
|
||||
async def remove_tournament_participant(
|
||||
tournament_id: str,
|
||||
participant_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> None:
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
deleted = await conn.fetchval(
|
||||
"DELETE FROM tournament_participant WHERE id = $1 AND tournament_id = $2 RETURNING id",
|
||||
participant_id,
|
||||
tournament_id,
|
||||
)
|
||||
if deleted is None:
|
||||
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.")
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Deltaker PÅ ÉN RUNDE (tee + cachet handicap for akkurat den runden)
|
||||
# =====================================================================
|
||||
|
||||
class RoundParticipantCreate(BaseModel):
|
||||
tournament_participant_id: str
|
||||
tee_id: str
|
||||
|
||||
|
||||
class RoundParticipantOut(BaseModel):
|
||||
id: str
|
||||
tournament_participant_id: str
|
||||
player_name: str
|
||||
tee_id: str
|
||||
tee_name: str
|
||||
course_handicap: int | None
|
||||
playing_handicap: int | None
|
||||
|
||||
|
||||
async def _compute_round_participant_handicap(conn, round_participant_id: str) -> None:
|
||||
"""Regner course_handicap/playing_handicap for ÉN rundedeltaker.
|
||||
|
||||
v1 har ingen allowance-prosent for individuelle turneringer (ulikt
|
||||
lagturneringenes AllowanceStrategy-familie, som er bygget for et
|
||||
relativt to-siders oppgjør -- ikke relevant her) -- playing_handicap er
|
||||
derfor alltid identisk med (avrundet) course_handicap. Setter INGENTING
|
||||
hvis spilleren mangler handicap_index_snapshot eller en matchende
|
||||
tee_rating -- kalleren har allerede validert dette FØR innsetting når
|
||||
scoring_method krever det (se add_round_participant).
|
||||
"""
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT tp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||
rating.course_rating::float AS course_rating,
|
||||
rating.slope_rating::float AS slope_rating,
|
||||
rating.par AS par
|
||||
FROM tournament_round_participant trp
|
||||
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
||||
JOIN player p ON p.id = tp.player_id
|
||||
JOIN tee_rating rating ON rating.tee_id = trp.tee_id
|
||||
AND rating.scope = 'full_18'
|
||||
AND rating.gender = p.gender
|
||||
WHERE trp.id = $1
|
||||
""",
|
||||
round_participant_id,
|
||||
)
|
||||
if row is None or row["handicap_index_snapshot"] is None:
|
||||
return
|
||||
ch = round_half_up(
|
||||
course_handicap_raw(
|
||||
row["handicap_index_snapshot"], row["slope_rating"], row["course_rating"], row["par"]
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
"UPDATE tournament_round_participant SET course_handicap = $1, playing_handicap = $1 WHERE id = $2",
|
||||
ch,
|
||||
round_participant_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants",
|
||||
response_model=RoundParticipantOut,
|
||||
status_code=201,
|
||||
)
|
||||
async def add_round_participant(
|
||||
tournament_id: str,
|
||||
round_id: str,
|
||||
body: RoundParticipantCreate,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> RoundParticipantOut:
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
round_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT tr.course_id::text AS course_id, t.scoring_method
|
||||
FROM tournament_round tr
|
||||
JOIN tournament t ON t.id = tr.tournament_id
|
||||
WHERE tr.id = $1 AND tr.tournament_id = $2
|
||||
""",
|
||||
round_id,
|
||||
tournament_id,
|
||||
)
|
||||
if round_row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
||||
|
||||
tee_course_id = await conn.fetchval("SELECT course_id::text FROM tee WHERE id = $1", body.tee_id)
|
||||
if tee_course_id is None or tee_course_id != round_row["course_id"]:
|
||||
raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke rundens bane.")
|
||||
|
||||
requires_handicap = round_row["scoring_method"] in _SCORING_METHODS_REQUIRING_HANDICAP
|
||||
if requires_handicap:
|
||||
player_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT tp.handicap_index_snapshot, p.gender
|
||||
FROM tournament_participant tp
|
||||
JOIN player p ON p.id = tp.player_id
|
||||
WHERE tp.id = $1
|
||||
""",
|
||||
body.tournament_participant_id,
|
||||
)
|
||||
if player_row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Turnering-deltakeren finnes ikke.")
|
||||
if player_row["handicap_index_snapshot"] is None:
|
||||
raise app_error(
|
||||
400,
|
||||
"VALIDATION_FAILED",
|
||||
"Denne spilleren mangler handicap-indeks -- påkrevd for "
|
||||
"nettoslagspill/Stableford.",
|
||||
)
|
||||
if player_row["gender"] is None:
|
||||
raise app_error(
|
||||
400,
|
||||
"VALIDATION_FAILED",
|
||||
"Denne spilleren mangler registrert kjønn -- trengs for å finne riktig tee-rating.",
|
||||
)
|
||||
has_rating = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM tee_rating WHERE tee_id = $1 AND scope = 'full_18' AND gender = $2)",
|
||||
body.tee_id,
|
||||
player_row["gender"],
|
||||
)
|
||||
if not has_rating:
|
||||
kjonn_tekst = "dame" if player_row["gender"] == "f" else "herre"
|
||||
raise app_error(
|
||||
400, "VALIDATION_FAILED", f"Dette utslaget har ingen {kjonn_tekst}-rating -- velg et annet."
|
||||
)
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
WITH inserted AS (
|
||||
INSERT INTO tournament_round_participant
|
||||
(organization_id, tournament_round_id, tournament_participant_id, tee_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id
|
||||
)
|
||||
SELECT inserted.id::text AS id
|
||||
FROM inserted
|
||||
""",
|
||||
organization_id,
|
||||
round_id,
|
||||
body.tournament_participant_id,
|
||||
body.tee_id,
|
||||
)
|
||||
round_participant_id = row["id"]
|
||||
await _compute_round_participant_handicap(conn, round_participant_id)
|
||||
|
||||
out = await conn.fetchrow(
|
||||
"""
|
||||
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
|
||||
p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
|
||||
trp.course_handicap, trp.playing_handicap
|
||||
FROM tournament_round_participant trp
|
||||
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
||||
JOIN player p ON p.id = tp.player_id
|
||||
JOIN tee ON tee.id = trp.tee_id
|
||||
WHERE trp.id = $1
|
||||
""",
|
||||
round_participant_id,
|
||||
)
|
||||
return RoundParticipantOut(**dict(out))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants",
|
||||
response_model=list[RoundParticipantOut],
|
||||
)
|
||||
async def list_round_participants(
|
||||
tournament_id: str,
|
||||
round_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> list[RoundParticipantOut]:
|
||||
async with org_connection(organization_id) as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
|
||||
p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
|
||||
trp.course_handicap, trp.playing_handicap
|
||||
FROM tournament_round_participant trp
|
||||
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
||||
JOIN player p ON p.id = tp.player_id
|
||||
JOIN tee ON tee.id = trp.tee_id
|
||||
WHERE trp.tournament_round_id = $1
|
||||
ORDER BY p.display_name
|
||||
""",
|
||||
round_id,
|
||||
)
|
||||
return [RoundParticipantOut(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants/{round_participant_id}",
|
||||
status_code=204,
|
||||
)
|
||||
async def remove_round_participant(
|
||||
tournament_id: str,
|
||||
round_id: str,
|
||||
round_participant_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> None:
|
||||
async with org_connection(organization_id) as conn:
|
||||
deleted = await conn.fetchval(
|
||||
"DELETE FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2 RETURNING id",
|
||||
round_participant_id,
|
||||
round_id,
|
||||
)
|
||||
if deleted is None:
|
||||
raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Hull-for-hull-score (tournament_round_hole -- kilde-sannhet)
|
||||
# =====================================================================
|
||||
|
||||
class HoleUpdate(BaseModel):
|
||||
gross_strokes: int = Field(ge=1, le=20)
|
||||
|
||||
|
||||
class RoundHoleOut(BaseModel):
|
||||
hole_number: int
|
||||
par: int
|
||||
stroke_index: int
|
||||
gross_strokes: int | None
|
||||
strokes_received: int | None
|
||||
|
||||
|
||||
async def _recompute_round_score(conn, round_id: str, round_participant_id: str, organization_id: str) -> None:
|
||||
"""Regner ferdig totalsum (brutto/netto/Stableford) på nytt for ÉN
|
||||
deltaker i ÉN runde, og cacher resultatet -- kalt etter HVER
|
||||
hull-innsending (samme mønster som recompute_and_cache_match_state).
|
||||
"""
|
||||
ctx = await conn.fetchrow(
|
||||
"""
|
||||
SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config,
|
||||
trp.tournament_participant_id::text AS tournament_participant_id,
|
||||
trp.playing_handicap
|
||||
FROM tournament_round_participant trp
|
||||
JOIN tournament_round tr ON tr.id = trp.tournament_round_id
|
||||
WHERE trp.id = $1
|
||||
""",
|
||||
round_participant_id,
|
||||
)
|
||||
holes = await conn.fetch(
|
||||
"""
|
||||
SELECT h.hole_number, h.par, h.stroke_index, trh.gross_strokes
|
||||
FROM hole h
|
||||
LEFT JOIN tournament_round_hole trh
|
||||
ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
|
||||
WHERE h.course_id = $2
|
||||
ORDER BY h.hole_number
|
||||
""",
|
||||
round_participant_id,
|
||||
ctx["course_id"],
|
||||
)
|
||||
played = [h for h in holes if h["gross_strokes"] is not None]
|
||||
holes_played = len(played)
|
||||
gross_total = stroke_play_gross_total([h["gross_strokes"] for h in played]) if played else None
|
||||
|
||||
net_total = None
|
||||
stableford_points = None
|
||||
if played and ctx["playing_handicap"] is not None:
|
||||
all_18_si = [h["stroke_index"] for h in holes]
|
||||
played_numbers = [h["hole_number"] for h in played]
|
||||
received = allocate_over_played_holes(ctx["playing_handicap"], all_18_si, played_numbers)
|
||||
gross_list = [h["gross_strokes"] for h in played]
|
||||
pars = [h["par"] for h in played]
|
||||
net_total = stroke_play_net_total(gross_list, received)
|
||||
stableford_points = stableford_total(pars, gross_list, received)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO tournament_round_score
|
||||
(organization_id, tournament_round_id, tournament_participant_id,
|
||||
holes_played, gross_total, net_total, stableford_points, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
||||
ON CONFLICT (tournament_round_id, tournament_participant_id) DO UPDATE SET
|
||||
holes_played = $4, gross_total = $5, net_total = $6,
|
||||
stableford_points = $7, updated_at = now()
|
||||
""",
|
||||
organization_id,
|
||||
round_id,
|
||||
ctx["tournament_participant_id"],
|
||||
holes_played,
|
||||
gross_total,
|
||||
net_total,
|
||||
stableford_points,
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
||||
"/participants/{round_participant_id}/holes/{hole_number}",
|
||||
response_model=RoundHoleOut,
|
||||
)
|
||||
async def update_hole(
|
||||
tournament_id: str,
|
||||
round_id: str,
|
||||
round_participant_id: str,
|
||||
hole_number: int,
|
||||
body: HoleUpdate,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> RoundHoleOut:
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
round_row = await conn.fetchrow(
|
||||
"SELECT course_id::text AS course_id, hole_config::text AS hole_config FROM tournament_round WHERE id = $1",
|
||||
round_id,
|
||||
)
|
||||
if round_row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
||||
if hole_number not in played_hole_numbers(round_row["hole_config"]):
|
||||
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor rundens hullomfang.")
|
||||
|
||||
rp = await conn.fetchval(
|
||||
"SELECT id FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2",
|
||||
round_participant_id,
|
||||
round_id,
|
||||
)
|
||||
if rp is None:
|
||||
raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
|
||||
|
||||
hole = await conn.fetchrow(
|
||||
"SELECT par, stroke_index FROM hole WHERE course_id = $1 AND hole_number = $2",
|
||||
round_row["course_id"],
|
||||
hole_number,
|
||||
)
|
||||
if hole is None:
|
||||
raise app_error(
|
||||
400, "VALIDATION_FAILED", "Banen mangler hull-data for dette hullnummeret."
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO tournament_round_hole
|
||||
(organization_id, tournament_round_participant_id, hole_number, gross_strokes, updated_at)
|
||||
VALUES ($1, $2, $3, $4, now())
|
||||
ON CONFLICT (tournament_round_participant_id, hole_number) DO UPDATE SET
|
||||
gross_strokes = $4, updated_at = now()
|
||||
""",
|
||||
organization_id,
|
||||
round_participant_id,
|
||||
hole_number,
|
||||
body.gross_strokes,
|
||||
)
|
||||
await _recompute_round_score(conn, round_id, round_participant_id, organization_id)
|
||||
|
||||
strokes_received = None
|
||||
playing_handicap = await conn.fetchval(
|
||||
"SELECT playing_handicap FROM tournament_round_participant WHERE id = $1", round_participant_id
|
||||
)
|
||||
if playing_handicap is not None:
|
||||
all_18 = await conn.fetch(
|
||||
"SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
|
||||
round_row["course_id"],
|
||||
)
|
||||
allocation = allocate_over_played_holes(
|
||||
playing_handicap, [h["stroke_index"] for h in all_18], [hole_number]
|
||||
)
|
||||
strokes_received = allocation[0]
|
||||
|
||||
return RoundHoleOut(
|
||||
hole_number=hole_number,
|
||||
par=hole["par"],
|
||||
stroke_index=hole["stroke_index"],
|
||||
gross_strokes=body.gross_strokes,
|
||||
strokes_received=strokes_received,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
||||
"/participants/{round_participant_id}/holes",
|
||||
response_model=list[RoundHoleOut],
|
||||
)
|
||||
async def list_round_participant_holes(
|
||||
tournament_id: str,
|
||||
round_id: str,
|
||||
round_participant_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> list[RoundHoleOut]:
|
||||
async with org_connection(organization_id) as conn:
|
||||
round_row = await conn.fetchrow(
|
||||
"SELECT course_id::text AS course_id FROM tournament_round WHERE id = $1", round_id
|
||||
)
|
||||
if round_row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
||||
playing_handicap = await conn.fetchval(
|
||||
"SELECT playing_handicap FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2",
|
||||
round_participant_id,
|
||||
round_id,
|
||||
)
|
||||
holes = await conn.fetch(
|
||||
"""
|
||||
SELECT h.hole_number, h.par, h.stroke_index, trh.gross_strokes
|
||||
FROM hole h
|
||||
LEFT JOIN tournament_round_hole trh
|
||||
ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
|
||||
WHERE h.course_id = $2
|
||||
ORDER BY h.hole_number
|
||||
""",
|
||||
round_participant_id,
|
||||
round_row["course_id"],
|
||||
)
|
||||
allocation = (
|
||||
allocate_over_played_holes(
|
||||
playing_handicap, [h["stroke_index"] for h in holes], [h["hole_number"] for h in holes]
|
||||
)
|
||||
if playing_handicap is not None
|
||||
else [None] * len(holes)
|
||||
)
|
||||
return [
|
||||
RoundHoleOut(
|
||||
hole_number=h["hole_number"],
|
||||
par=h["par"],
|
||||
stroke_index=h["stroke_index"],
|
||||
gross_strokes=h["gross_strokes"],
|
||||
strokes_received=a,
|
||||
)
|
||||
for h, a in zip(holes, allocation)
|
||||
]
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Leaderboard (summert VED LESING på tvers av alle runder, Beslutning C)
|
||||
# =====================================================================
|
||||
|
||||
class LeaderboardEntry(BaseModel):
|
||||
tournament_participant_id: str
|
||||
player_name: str
|
||||
rounds_played: int
|
||||
gross_total: int | None
|
||||
net_total: int | None
|
||||
stableford_total: int | None
|
||||
|
||||
|
||||
@router.get(
|
||||
# IKKE /leaderboard -- den stien er allerede tournaments.py sitt
|
||||
# LAG-leaderboard (points_side_a/b, forutsetter nøyaktig to lag). Samme
|
||||
# klasse feil som /rounds vs. /my-rounds tidligere i prosjektet -- unngått
|
||||
# her ved å gi den et eget, ikke-overlappende navn fra start.
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/individual-leaderboard",
|
||||
response_model=list[LeaderboardEntry],
|
||||
)
|
||||
async def individual_leaderboard(
|
||||
tournament_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> list[LeaderboardEntry]:
|
||||
async with org_connection(organization_id) as conn:
|
||||
tournament = await conn.fetchrow(
|
||||
"SELECT format_type, scoring_method FROM tournament WHERE id = $1", tournament_id
|
||||
)
|
||||
if tournament is None:
|
||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||
if tournament["format_type"] != "individual":
|
||||
raise app_error(400, "VALIDATION_FAILED", "Kun individuelle turneringer har dette leaderboardet.")
|
||||
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT tp.id::text AS tournament_participant_id, p.display_name AS player_name,
|
||||
count(trs.id) FILTER (WHERE trs.holes_played > 0) AS rounds_played,
|
||||
SUM(trs.gross_total)::int AS gross_total,
|
||||
SUM(trs.net_total)::int AS net_total,
|
||||
SUM(trs.stableford_points)::int AS stableford_total
|
||||
FROM tournament_participant tp
|
||||
JOIN player p ON p.id = tp.player_id
|
||||
LEFT JOIN tournament_round_score trs ON trs.tournament_participant_id = tp.id
|
||||
WHERE tp.tournament_id = $1
|
||||
GROUP BY tp.id, p.display_name
|
||||
""",
|
||||
tournament_id,
|
||||
)
|
||||
entries = [LeaderboardEntry(**dict(r)) for r in rows]
|
||||
|
||||
method = tournament["scoring_method"]
|
||||
if method == "stableford":
|
||||
entries.sort(key=lambda e: (e.stableford_total is None, -(e.stableford_total or 0)))
|
||||
elif method == "stroke_net":
|
||||
entries.sort(key=lambda e: (e.net_total is None, e.net_total or 0))
|
||||
else:
|
||||
entries.sort(key=lambda e: (e.gross_total is None, e.gross_total or 0))
|
||||
return entries
|
||||
|
|
@ -41,13 +41,19 @@ class TournamentCreate(BaseModel):
|
|||
name: str = Field(min_length=1, max_length=200)
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
# ADR-037: 'team' (dagens Ryder Cup-modell, uendret default) eller
|
||||
# 'individual' (flatt felt, egen tabellstruktur -- se
|
||||
# 040_individual_tournaments.sql). scoring_method er kun meningsfullt
|
||||
# for individual, håndhevet i app-laget når runder/score bygges på den.
|
||||
format_type: str = Field(default="team", pattern="^(team|individual)$")
|
||||
scoring_method: str | None = Field(default=None, pattern="^(stroke_gross|stroke_net|stableford)$")
|
||||
|
||||
|
||||
_TOURNAMENT_COLUMNS = """
|
||||
id::text, name, status::text, start_date, end_date,
|
||||
visibility, description, registration_deadline, registration_capacity,
|
||||
registration_overflow_policy, registration_requires_approval, hero_image_key,
|
||||
join_code
|
||||
join_code, format_type, scoring_method
|
||||
"""
|
||||
|
||||
# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A).
|
||||
|
|
@ -72,6 +78,8 @@ class Tournament(BaseModel):
|
|||
registration_requires_approval: bool
|
||||
hero_image_key: str | None
|
||||
join_code: str
|
||||
format_type: str
|
||||
scoring_method: str | None
|
||||
|
||||
|
||||
@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
|
||||
|
|
@ -124,8 +132,8 @@ async def create_tournament(
|
|||
try:
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
INSERT INTO tournament (organization_id, name, start_date, end_date, join_code)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO tournament (organization_id, name, start_date, end_date, join_code, format_type, scoring_method)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING {_TOURNAMENT_COLUMNS}
|
||||
""",
|
||||
organization_id,
|
||||
|
|
@ -133,6 +141,8 @@ async def create_tournament(
|
|||
body.start_date,
|
||||
body.end_date,
|
||||
_generate_join_code(),
|
||||
body.format_type,
|
||||
body.scoring_method,
|
||||
)
|
||||
break
|
||||
except asyncpg.UniqueViolationError:
|
||||
|
|
@ -148,6 +158,8 @@ class TournamentUpdate(BaseModel):
|
|||
|
||||
status: str | None = Field(default=None, pattern="^(draft|active|completed|archived)$")
|
||||
visibility: str | None = Field(default=None, pattern="^(public|org|participants)$")
|
||||
format_type: str | None = Field(default=None, pattern="^(team|individual)$")
|
||||
scoring_method: str | None = Field(default=None, pattern="^(stroke_gross|stroke_net|stableford)$")
|
||||
description: str | None = None
|
||||
registration_deadline: datetime | None = None
|
||||
registration_capacity: int | None = Field(default=None, gt=0)
|
||||
|
|
|
|||
|
|
@ -502,6 +502,40 @@ def allocate_over_played_holes(
|
|||
return [full[hole - 1] for hole in played_hole_numbers]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Individuelle scoringsmetoder (ADR-037): bruttoslagspill/nettoslagspill/
|
||||
# Stableford for et flatt felt av spillere, ingen motpart/side involvert.
|
||||
# `strokes_received` her kommer ALLTID fra allocate_strokes_by_index/
|
||||
# allocate_over_played_holes over -- disse funksjonene fordeler ingen slag
|
||||
# selv, de bare summerer et allerede fordelt resultat.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def stroke_play_gross_total(gross_strokes: Sequence[int]) -> int:
|
||||
"""Sum av rå bruttoslag over de spilte hullene. Ingen handicap involvert."""
|
||||
return sum(gross_strokes)
|
||||
|
||||
|
||||
def stroke_play_net_total(gross_strokes: Sequence[int], strokes_received: Sequence[int]) -> int:
|
||||
"""Sum av netto slag (brutto minus mottatte slag) over de spilte hullene."""
|
||||
return sum(g - s for g, s in zip(gross_strokes, strokes_received))
|
||||
|
||||
|
||||
def stableford_points_for_hole(par: int, gross_strokes: int, strokes_received: int) -> int:
|
||||
"""Stableford-poeng for ETT hull: 2 poeng for netto par, +/-1 poeng per
|
||||
slag avvik, gulvet på 0 (netto dobbel bogey eller dårligere gir 0 poeng).
|
||||
"""
|
||||
net = gross_strokes - strokes_received
|
||||
return max(0, par - net + 2)
|
||||
|
||||
|
||||
def stableford_total(pars: Sequence[int], gross_strokes: Sequence[int], strokes_received: Sequence[int]) -> int:
|
||||
"""Sum av Stableford-poeng over de spilte hullene."""
|
||||
return sum(
|
||||
stableford_points_for_hole(par, g, s)
|
||||
for par, g, s in zip(pars, gross_strokes, strokes_received)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Match-status ("2 UP", "dormie", "3&2", "AS")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ from handicap_engine import (
|
|||
round_half_up,
|
||||
round_half_up_decimal,
|
||||
score_differential,
|
||||
stableford_points_for_hole,
|
||||
stableford_total,
|
||||
stroke_play_gross_total,
|
||||
stroke_play_net_total,
|
||||
unit_playing_handicap,
|
||||
)
|
||||
|
||||
|
|
@ -618,6 +622,55 @@ def test_skins_detail_empty_hole_logged_but_inert():
|
|||
assert log[1].awarded == {"a": 1.0}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Individuelle scoringsmetoder (ADR-037): bruttoslagspill/nettoslagspill/
|
||||
# Stableford
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_stroke_play_gross_total_sums_raw_strokes():
|
||||
assert stroke_play_gross_total([5, 4, 6]) == 15
|
||||
|
||||
|
||||
def test_stroke_play_gross_total_empty_is_zero():
|
||||
assert stroke_play_gross_total([]) == 0
|
||||
|
||||
|
||||
def test_stroke_play_net_total_subtracts_received_strokes():
|
||||
# 5-1, 4-0, 6-1 = 4, 4, 5 -> 13
|
||||
assert stroke_play_net_total([5, 4, 6], [1, 0, 1]) == 13
|
||||
|
||||
|
||||
def test_stableford_points_for_hole_net_par_is_two():
|
||||
# Par 4, brutto 4, ingen mottatte slag -> netto par -> 2 poeng.
|
||||
assert stableford_points_for_hole(par=4, gross_strokes=4, strokes_received=0) == 2
|
||||
|
||||
|
||||
def test_stableford_points_for_hole_net_birdie_is_three():
|
||||
# Par 4, brutto 3 -> netto birdie -> 3 poeng.
|
||||
assert stableford_points_for_hole(par=4, gross_strokes=3, strokes_received=0) == 3
|
||||
|
||||
|
||||
def test_stableford_points_for_hole_with_received_stroke():
|
||||
# Par 4, brutto 6, 1 mottatt slag -> netto 5 (bogey) -> 1 poeng.
|
||||
assert stableford_points_for_hole(par=4, gross_strokes=6, strokes_received=1) == 1
|
||||
|
||||
|
||||
def test_stableford_points_for_hole_floors_at_zero():
|
||||
# Par 5, brutto 9, 1 mottatt slag -> netto 8 -> 5-8+2 = -1 -> gulvet på 0.
|
||||
assert stableford_points_for_hole(par=5, gross_strokes=9, strokes_received=1) == 0
|
||||
|
||||
|
||||
def test_stableford_total_matches_hand_computed_sum():
|
||||
pars = [4, 4, 5]
|
||||
gross = [4, 6, 9]
|
||||
received = [0, 1, 1]
|
||||
# Hull for hull: 2 (net par) + 1 (net bogey) + 0 (gulvet) = 3.
|
||||
assert stableford_total(pars, gross, received) == 3
|
||||
assert stableford_total(pars, gross, received) == sum(
|
||||
stableford_points_for_hole(p, g, r) for p, g, r in zip(pars, gross, received)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enkel selvsjekk uten pytest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue