Spillerens per-hull-historikk på tvers av alle runder/turneringer (ADR-072)
Ny delt app/hole_history.py bygger broen mellom frittstående runders og org-turneringers to ulike bane-identitetssystemer (teeoff facility+course-id vs. org-scopet course.external_course_ref, og tilsvarende for GolfAPI) -- custom/håndlagde baner ekskluderes bevisst, ordinære baner telles med fra begge kilder og slås sammen. Turneringssiden håndterer at spilleren kan ha spilt i flere organisasjoner via samme N+1-per-org-mønster /auth/me allerede bruker (player_organizations_for_user, migrasjon 015). To nye GET .../history-endepunkt (rounds.py og individual_tournaments.py) returnerer samme kombinerte historikk uansett hvilken side som spør. Nytt HoleHistoryPanel i hole-stat-inputs.tsx viser et ekspanderbart sammendrag (antall ganger, snitt slag, GIR%, putter) + enkeltinstanser, lagt til i ScoringWizard og det nye HoleStatsSheet (ADR-071). Ingen migrasjon -- ren lesefunksjon oppå migrasjon 075. Se ADR-072/ CHANGELOG for full detalj og verifisering (73 backend-tester, inkl. integrasjonstest som beviser selve bane-broen på tvers av organisasjoner). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
60ee6a7d16
commit
bb087b4437
9 changed files with 975 additions and 2 deletions
|
|
@ -7111,6 +7111,110 @@ rent.
|
|||
|
||||
---
|
||||
|
||||
## ADR-072: Spillerens per-hull-historikk på tvers av alle runder/turneringer ("Steg 2") (2026-08-15)
|
||||
|
||||
Foranledning: se ADR-071 -- dette er selve historikk-funksjonen bruker ba
|
||||
om, bygget oppå statistikkdybde-pariteten ADR-071 leverte. Bekreftet med
|
||||
bruker (AskUserQuestion): custom/håndlagde baner ekskluderes helt (ingen
|
||||
pålitelig felles identitet); ordinære (teeoff-/GolfAPI-importerte) baner
|
||||
telles med fra BÅDE frittstående runder og org-turneringer, slått sammen
|
||||
til én historikk når det faktisk er samme fysiske bane.
|
||||
|
||||
**Ny delt modul `app/hole_history.py`** (mønster: `app/blind_draw.py`/
|
||||
`app/team_authz.py`, ikke db-fri som `handicap_engine.py` siden dette er
|
||||
datainnhenting, ikke handicap-regler). Kjernen er bane-broen mellom to
|
||||
helt forskjellige identitetssystemer:
|
||||
- Teeoff: `round.teeoff_facility_slug`+`teeoff_course_id` (frittstående)
|
||||
`<->` `course.external_course_ref = f"{facility_slug}:{teeoff_course_id}"`
|
||||
når `course.source='official'` (org-turnering) -- eksakt samme
|
||||
streng-format som `import_official_course()` i `courses.py` allerede
|
||||
bygger den med, verifisert mot kilden før bruk.
|
||||
- GolfAPI: `personal_course.external_golfapi_course_id` `<->`
|
||||
`course.external_course_ref` når `course.source='international'` --
|
||||
begge lagrer samme rå GolfAPI-ID, uten prefiks.
|
||||
- Custom (`round.course_source='custom'` med `personal_course.external_
|
||||
golfapi_course_id IS NULL`, eller `course.source='custom'`) gir
|
||||
bevisst `None` fra begge resolve-funksjonene -- kalleren skjuler da
|
||||
historikk-panelet stille, ingen feilmelding.
|
||||
|
||||
`fetch_personal_hole_history` går via `round_participant.user_id`
|
||||
(samme presedens som `/rounds/stats/summary`). `fetch_tournament_hole_
|
||||
history` må derimot håndtere at spilleren kan ha spilt turnering i
|
||||
FLERE organisasjoner -- `tournament_round_hole`/`course`/`tournament_
|
||||
participant` er alle org-scopet/RLS-beskyttet, så ett enkelt `org_
|
||||
connection()`-kall kan aldri dekke alle. Løst med SAMME N+1-per-org-
|
||||
mønster som `/auth/me` allerede bruker: `player_organizations_for_user()`
|
||||
(migrasjon 015, en smal `SECURITY DEFINER`-bro som kun eksponerer en
|
||||
org-ID-liste) gir org-listen trygt via `plain_connection()`, deretter ett
|
||||
`org_connection(org_id)`-kall per org. Ingen superuser-/bypass-RLS-
|
||||
snarvei (ufravikelig regel, CLAUDE.md).
|
||||
|
||||
GIR-/fairway-formlene speiler den ETABLERTE `score - putts <= par - 2`
|
||||
(rounds.py/round-stats.tsx), IKKE skjema-kommentarens avvikende formel
|
||||
(samme presisering som ADR-071). `summarize_hole_history()` er en ren
|
||||
funksjon (ingen db) -- regner GIR%/fairway%/snitt-putter KUN over
|
||||
instanser som faktisk har putts/utslagsretning registrert (en
|
||||
`strokes_only`-historisk rad bidrar til snitt-slag, men trekkes ikke inn
|
||||
i GIR%-nevneren), sorterer mest-nylig-først med udaterte turneringsrunder
|
||||
(`tournament_round.scheduled_at` er nullbar) sist.
|
||||
|
||||
**To tynne endepunkt**, ett i hver router, begge kaller inn i samme
|
||||
`hole_history_for_user()` og returnerer SAMME kombinerte historikk
|
||||
uansett hvilken side spørringen kom fra:
|
||||
- `GET /rounds/{id}/participants/{pid}/holes/{n}/history` (`rounds.py`)
|
||||
- `GET /orgs/{org}/tournaments/{tid}/rounds/{rid}/participants/{pid}/holes/{n}/history`
|
||||
(`individual_tournaments.py`)
|
||||
|
||||
Historikken er for DELTAKEREN (`participant_id`), ikke nødvendigvis den
|
||||
innloggede brukeren -- en lenket medspiller kan se en annens historikk
|
||||
mens de fører score for flighten, samme tilgang som selve scoringen.
|
||||
Gjester (`round_participant.user_id`/`player.user_id` er `NULL`) gir
|
||||
`None` fra endepunktet -- samme "skjul panelet stille"-kontrakt som en
|
||||
custom bane, ingen feil.
|
||||
|
||||
**Frontend**: nytt `HoleHistoryPanel` i den delte `hole-stat-inputs.tsx`
|
||||
(egen unntak fra filens ellers rene "ingen datahenting"-regel, siden
|
||||
begge scoringsflytene trenger nøyaktig samme henting/visning). Henter
|
||||
selv (`GET .../history`), viser INGENTING når responsen er `null`
|
||||
(laster ELLER ingen historikk -- ingen synlig forskjell, unngår et
|
||||
flimrende spinner-perifert-panel). Ekspanderbar: lukket viser ett
|
||||
sammendrag ("Spilt N ganger før (X frittstående, Y turnering) -- snitt A
|
||||
slag (+/-par), GIR B%, C putter i snitt"), åpen viser hver enkeltinstans
|
||||
(dato, kilde, resultat). Lagt til i `ScoringWizard` sitt strokes-steg
|
||||
(`round-detail.tsx`) og i det nye `HoleStatsSheet` (`individual-
|
||||
tournament-detail.tsx`, `ADR-071`) -- samme komponent, ulik URL.
|
||||
|
||||
**Verifisert:** `python3 -m py_compile` + full
|
||||
`./scripts/run_backend_tests.sh` (73/73 -- 12 nye tester i
|
||||
`test_hole_history.py`: 6 rene enhetstester av `summarize_hole_history`
|
||||
(GIR%/fairway%/snitt-eksklusjon, sortering), pluss integrasjonstester som
|
||||
BEVISER selve bane-broen -- samme spiller, samme teeoff-bane spilt både
|
||||
frittstående OG i en turnering i en ANNEN organisasjon enn spilleren
|
||||
selv eier noe i, kombinert historikk viser begge; en custom personal_
|
||||
course (ingen GolfAPI-kobling) resolve-r til `None`; en gjeste-
|
||||
turneringsdeltaker (`player.user_id IS NULL`) gir `None`-historikk).
|
||||
`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch
|
||||
`teecup_api`-container (port 18006, live-montert kode) + lokal `next
|
||||
dev` (port 13006): samme spiller, samme teeoff-bane (facility/course-ID
|
||||
matchende), én frittstående runde-hull + én turneringsrunde-hull i en
|
||||
ANNEN org -- bekreftet at BEGGE kontekster (ScoringWizard OG
|
||||
HoleStatsSheet) viser identisk kombinert historikk ("Spilt 2 ganger før
|
||||
(1 frittstående, 1 turnering) -- snitt 4.5 slag (+0.5 til par), GIR 50%,
|
||||
1.5 putter i snitt" -- håndregnet og bekreftet korrekt), ekspandert
|
||||
instansliste viser begge kildene med riktig dato/kilde-merking (udatert
|
||||
turneringsrunde viste korrekt "Ukjent dato"), og et upsilt hull viste
|
||||
INGEN panel (stille skjult, ikke en feil). Lys+mørk bekreftet. Scratch-
|
||||
stacken fullstendig revet ned -- ekte `teecup_db`/`teecup_api`/
|
||||
`teecup_frontend` urørt.
|
||||
|
||||
**Ingen migrasjon** -- rent lesefunksjon oppå migrasjon 075 sitt skjema.
|
||||
|
||||
**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
|
||||
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
|
||||
bruker. Begge containere startet rent.
|
||||
|
||||
---
|
||||
|
||||
## Utviklingsplan (rekkefølge)
|
||||
|
||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||
|
|
|
|||
66
CHANGELOG.md
66
CHANGELOG.md
|
|
@ -11419,3 +11419,69 @@ Neste steg:
|
|||
som `teeoff_admin`, etter eksplisitt bekreftelse fra bruker.
|
||||
`docker compose build teecup_api teecup_frontend && up -d` -- begge
|
||||
containere startet rent.
|
||||
|
||||
88. **Spillerens per-hull-historikk på tvers av alle runder/turneringer,
|
||||
"Steg 2" (ADR-072) — 2026-08-15.** Selve historikk-funksjonen bruker
|
||||
opprinnelig ba om (se #87/ADR-071 for statistikkdybde-forutsetningen).
|
||||
Bekreftet med bruker: custom baner ekskluderes helt, ordinære
|
||||
(teeoff-/GolfAPI-importerte) baner telles med fra BÅDE frittstående
|
||||
runder og org-turneringer, slått sammen.
|
||||
|
||||
Ny delt modul `app/hole_history.py` -- kjernen er bane-broen mellom
|
||||
to identitetssystemer: teeoff (`round.teeoff_facility_slug`+
|
||||
`teeoff_course_id` mot `course.external_course_ref = "facility:
|
||||
course_id"` når `source='official'`, eksakt samme format som
|
||||
`import_official_course()` bygger) og GolfAPI (`personal_course.
|
||||
external_golfapi_course_id` mot samme rå ID i `course.external_
|
||||
course_ref` når `source='international'`). Custom gir bevisst `None`
|
||||
fra begge resolve-funksjonene -- historikk-panelet skjules stille.
|
||||
|
||||
Turneringssiden må håndtere at spilleren kan ha spilt i FLERE
|
||||
organisasjoner -- `tournament_round_hole` er org-scopet/RLS-
|
||||
beskyttet. Løst med samme N+1-per-org-mønster som `/auth/me`
|
||||
allerede bruker: `player_organizations_for_user()` (migrasjon 015,
|
||||
smal SECURITY DEFINER-bro) gir org-listen trygt, ett `org_
|
||||
connection()`-kall per org deretter. Ingen bypass-RLS-snarvei.
|
||||
|
||||
GIR/fairway-formlene speiler den etablerte `score - putts <= par -
|
||||
2` (samme presisering som ADR-071, IKKE skjema-kommentarens avvikende
|
||||
formel). `summarize_hole_history()` er en ren funksjon -- regner
|
||||
GIR%/fairway%/snitt-putter kun over instanser MED faktisk registrert
|
||||
data, sorterer mest-nylig-først.
|
||||
|
||||
To tynne endepunkt (ett i `rounds.py`, ett i `individual_
|
||||
tournaments.py`) kaller begge inn i samme `hole_history_for_user()`
|
||||
og returnerer SAMME kombinerte historikk uansett hvilken side
|
||||
spørringen kom fra. Historikken er for DELTAKEREN, ikke nødvendigvis
|
||||
innlogget bruker (samme tilgang som selve scoringen) -- gjester
|
||||
(`user_id IS NULL`) gir `None`, samme kontrakt som en custom bane.
|
||||
|
||||
Frontend: nytt `HoleHistoryPanel` i `hole-stat-inputs.tsx` (henter
|
||||
selv, viser ingenting ved `null`-respons). Ekspanderbar: lukket
|
||||
viser ett sammendrag, åpen viser hver enkeltinstans. Lagt til i
|
||||
`ScoringWizard` (frittstående) og det nye `HoleStatsSheet`
|
||||
(turnering, ADR-071) -- samme komponent, ulik URL.
|
||||
|
||||
**Verifisert:** `python3 -m py_compile` + full
|
||||
`./scripts/run_backend_tests.sh` (73/73 -- 12 nye tester i
|
||||
`test_hole_history.py`: 6 rene enhetstester av aggregeringsformlene,
|
||||
pluss integrasjonstester som BEVISER selve bane-broen -- samme
|
||||
spiller/samme teeoff-bane spilt frittstående OG i en turnering i en
|
||||
ANNEN organisasjon, kombinert historikk viser begge; custom bane og
|
||||
gjeste-deltaker gir korrekt `None`). `tsc --noEmit` rent + 45/45
|
||||
vitest. Scratch-database + scratch `teecup_api` (port 18006, live-
|
||||
montert kode) + lokal `next dev` (port 13006): samme spiller/samme
|
||||
teeoff-bane, ett frittstående hull + ett turneringshull i en ANNEN
|
||||
org -- bekreftet BEGGE kontekster viser identisk, håndregnet-korrekt
|
||||
kombinert historikk ("2 ganger, 1 frittstående/1 turnering, snitt 4.5
|
||||
slag, GIR 50%, 1.5 putter"), ekspandert instansliste riktig merket,
|
||||
et uspilt hull viste ingen panel. Lys+mørk bekreftet. Scratch-stacken
|
||||
fullstendig revet ned -- ekte `teecup_db`/`teecup_api`/
|
||||
`teecup_frontend` urørt.
|
||||
|
||||
**Ingen migrasjon** -- rent lesefunksjon oppå migrasjon 075 sitt
|
||||
skjema.
|
||||
|
||||
**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
|
||||
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
|
||||
bruker. Begge containere startet rent.
|
||||
|
|
|
|||
321
app/hole_history.py
Normal file
321
app/hole_history.py
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
"""
|
||||
Spillerens per-hull-historikk (ADR-072, migrasjon 075/ADR-071-tillegg):
|
||||
"hvordan spiller jeg egentlig dette hullet?" -- aggregerer ALLE tidligere
|
||||
ganger en spiller har spilt et gitt hull, på tvers av BÅDE frittstående
|
||||
runder OG org-turneringer (på tvers av ALLE organisasjoner spilleren har
|
||||
spilt turnering i), begrenset til ordinære (teeoff-/GolfAPI-importerte)
|
||||
baner. Custom/håndlagde baner har ingen pålitelig felles identitet på
|
||||
tvers av de to systemene og telles BEVISST ikke med (bekreftet med
|
||||
bruker via AskUserQuestion).
|
||||
|
||||
Delt mellom rounds.py (frittstående kontekst) og individual_tournaments.py
|
||||
(turnering-kontekst) -- begge kaller inn hit og får samme KOMBINERTE
|
||||
historikk uansett hvilken side spørringen kom fra ("alle gangene" betyr
|
||||
alle gangene, ikke bare de av typen man akkurat nå ser på).
|
||||
|
||||
Bane-bro mellom de to identitetssystemene (se ADR-072 for utledningen):
|
||||
- Teeoff: round.teeoff_facility_slug + round.teeoff_course_id <->
|
||||
course.external_course_ref = f"{facility_slug}:{teeoff_course_id}"
|
||||
(course.source = 'official') -- eksakt samme streng-format som
|
||||
import_official_course() i courses.py bygger den med.
|
||||
- GolfAPI: personal_course.external_golfapi_course_id <->
|
||||
course.external_course_ref (course.source = 'international') --
|
||||
begge lagrer samme rå golfapi_course_id, uten prefiks.
|
||||
Custom personal_course (external_golfapi_course_id IS NULL) og custom
|
||||
course (source = 'custom') har INGEN bro her -- ekskluderes (resolve-
|
||||
funksjonene returnerer None, kallerne skjuler da historikk-panelet).
|
||||
|
||||
GIR/fairway-formlene speiler round-stats.tsx/rounds.py sin ETABLERTE
|
||||
`score - putts <= par - 2`-definisjon (IKKE skjema-kommentaren i
|
||||
020_personal_rounds.sql, som beskriver en avvikende, ubrukt formel --
|
||||
se ADR-071/ADR-033 Beslutning B for hvorfor avviket er bevisst).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
from asyncpg import Connection
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .db import org_connection, plain_connection
|
||||
|
||||
CourseKey = tuple[Literal["teeoff"], str, str] | tuple[Literal["golfapi"], str]
|
||||
|
||||
|
||||
async def resolve_personal_round_course_key(conn: Connection, round_id: str) -> CourseKey | None:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT course_source, teeoff_facility_slug, teeoff_course_id, personal_course_id::text AS personal_course_id
|
||||
FROM round WHERE id = $1
|
||||
""",
|
||||
round_id,
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
if row["course_source"] == "teeoff":
|
||||
return ("teeoff", row["teeoff_facility_slug"], row["teeoff_course_id"])
|
||||
golfapi_id = await conn.fetchval(
|
||||
"SELECT external_golfapi_course_id FROM personal_course WHERE id = $1",
|
||||
row["personal_course_id"],
|
||||
)
|
||||
if golfapi_id is None:
|
||||
return None
|
||||
return ("golfapi", golfapi_id)
|
||||
|
||||
|
||||
async def resolve_tournament_round_course_key(conn: Connection, tournament_round_id: str) -> CourseKey | None:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT c.source, c.external_course_ref
|
||||
FROM tournament_round tr
|
||||
JOIN course c ON c.id = tr.course_id
|
||||
WHERE tr.id = $1
|
||||
""",
|
||||
tournament_round_id,
|
||||
)
|
||||
if row is None or row["source"] == "custom" or row["external_course_ref"] is None:
|
||||
return None
|
||||
if row["source"] == "official":
|
||||
facility_slug, _, teeoff_course_id = row["external_course_ref"].partition(":")
|
||||
return ("teeoff", facility_slug, teeoff_course_id)
|
||||
return ("golfapi", row["external_course_ref"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class HoleInstance:
|
||||
source: Literal["personal", "tournament"]
|
||||
played_at: date | None
|
||||
label: str
|
||||
score: int
|
||||
par: int
|
||||
putts: int | None
|
||||
tee_shot_result: str | None
|
||||
approach_result: str | None
|
||||
chip_count: int | None
|
||||
bunker_shot_count: int | None
|
||||
penalty_strokes: int | None
|
||||
first_putt_distance_bucket: str | None
|
||||
|
||||
|
||||
async def fetch_personal_hole_history(user_id: str, key: CourseKey, hole_number: int) -> list[HoleInstance]:
|
||||
async with plain_connection() as conn:
|
||||
if key[0] == "teeoff":
|
||||
_, facility_slug, teeoff_course_id = key
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT r.played_at, r.name, rh.score, rh.par, rh.putts, rh.tee_shot_result,
|
||||
rh.approach_result, rh.chip_count, rh.bunker_shot_count,
|
||||
rh.penalty_strokes, rh.first_putt_distance_bucket
|
||||
FROM round_hole rh
|
||||
JOIN round_participant rp ON rp.id = rh.round_participant_id
|
||||
JOIN round r ON r.id = rp.round_id
|
||||
WHERE rp.user_id = $1 AND rh.hole_number = $2 AND rh.played = true
|
||||
AND rh.score IS NOT NULL
|
||||
AND r.course_source = 'teeoff'
|
||||
AND r.teeoff_facility_slug = $3 AND r.teeoff_course_id = $4
|
||||
ORDER BY r.played_at DESC
|
||||
""",
|
||||
user_id, hole_number, facility_slug, teeoff_course_id,
|
||||
)
|
||||
else:
|
||||
_, golfapi_id = key
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT r.played_at, r.name, rh.score, rh.par, rh.putts, rh.tee_shot_result,
|
||||
rh.approach_result, rh.chip_count, rh.bunker_shot_count,
|
||||
rh.penalty_strokes, rh.first_putt_distance_bucket
|
||||
FROM round_hole rh
|
||||
JOIN round_participant rp ON rp.id = rh.round_participant_id
|
||||
JOIN round r ON r.id = rp.round_id
|
||||
JOIN personal_course pc ON pc.id = r.personal_course_id
|
||||
WHERE rp.user_id = $1 AND rh.hole_number = $2 AND rh.played = true
|
||||
AND rh.score IS NOT NULL
|
||||
AND r.course_source = 'custom' AND pc.external_golfapi_course_id = $3
|
||||
ORDER BY r.played_at DESC
|
||||
""",
|
||||
user_id, hole_number, golfapi_id,
|
||||
)
|
||||
return [
|
||||
HoleInstance(
|
||||
source="personal",
|
||||
played_at=r["played_at"],
|
||||
label=r["name"] or "Runde",
|
||||
score=r["score"],
|
||||
par=r["par"],
|
||||
putts=r["putts"],
|
||||
tee_shot_result=r["tee_shot_result"],
|
||||
approach_result=r["approach_result"],
|
||||
chip_count=r["chip_count"],
|
||||
bunker_shot_count=r["bunker_shot_count"],
|
||||
penalty_strokes=r["penalty_strokes"],
|
||||
first_putt_distance_bucket=r["first_putt_distance_bucket"],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def fetch_tournament_hole_history(user_id: str, key: CourseKey, hole_number: int) -> list[HoleInstance]:
|
||||
"""N+1-per-org (samme mønster som /auth/me): tournament_round_hole er
|
||||
org-scopet/RLS-beskyttet, og spilleren kan ha spilt turnering i flere
|
||||
organisasjoner -- ett enkelt org_connection()-kall kan derfor aldri
|
||||
dekke alle. `player_organizations_for_user()` (migrasjon 015, SAMME
|
||||
SECURITY DEFINER-bro /auth/me allerede bruker) gir org-listen trygt
|
||||
uten en RLS-omgåelse i selve spørringen."""
|
||||
if key[0] == "teeoff":
|
||||
_, facility_slug, teeoff_course_id = key
|
||||
source_filter = "official"
|
||||
external_ref = f"{facility_slug}:{teeoff_course_id}"
|
||||
else:
|
||||
_, golfapi_id = key
|
||||
source_filter = "international"
|
||||
external_ref = golfapi_id
|
||||
|
||||
async with plain_connection() as conn:
|
||||
org_rows = await conn.fetch(
|
||||
"SELECT organization_id::text AS organization_id FROM player_organizations_for_user($1)",
|
||||
user_id,
|
||||
)
|
||||
|
||||
instances: list[HoleInstance] = []
|
||||
for org_row in org_rows:
|
||||
async with org_connection(org_row["organization_id"]) as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT t.name AS tournament_name, tr.scheduled_at,
|
||||
trh.gross_strokes AS score, h.par, trh.putts, trh.tee_shot_result,
|
||||
trh.approach_result, trh.chip_count, trh.bunker_shot_count,
|
||||
trh.penalty_strokes, trh.first_putt_distance_bucket
|
||||
FROM tournament_round_hole trh
|
||||
JOIN tournament_round_participant trp ON trp.id = trh.tournament_round_participant_id
|
||||
JOIN tournament_round tr ON tr.id = trp.tournament_round_id
|
||||
JOIN tournament t ON t.id = tr.tournament_id
|
||||
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
||||
JOIN player p ON p.id = tp.player_id
|
||||
JOIN course c ON c.id = tr.course_id
|
||||
JOIN hole h ON h.course_id = tr.course_id AND h.hole_number = trh.hole_number
|
||||
WHERE p.user_id = $1 AND trh.hole_number = $2
|
||||
AND c.source = $3 AND c.external_course_ref = $4
|
||||
ORDER BY tr.scheduled_at DESC NULLS LAST
|
||||
""",
|
||||
user_id, hole_number, source_filter, external_ref,
|
||||
)
|
||||
for r in rows:
|
||||
instances.append(
|
||||
HoleInstance(
|
||||
source="tournament",
|
||||
played_at=r["scheduled_at"].date() if r["scheduled_at"] is not None else None,
|
||||
label=r["tournament_name"] or "Turnering",
|
||||
score=r["score"],
|
||||
par=r["par"],
|
||||
putts=r["putts"],
|
||||
tee_shot_result=r["tee_shot_result"],
|
||||
approach_result=r["approach_result"],
|
||||
chip_count=r["chip_count"],
|
||||
bunker_shot_count=r["bunker_shot_count"],
|
||||
penalty_strokes=r["penalty_strokes"],
|
||||
first_putt_distance_bucket=r["first_putt_distance_bucket"],
|
||||
)
|
||||
)
|
||||
return instances
|
||||
|
||||
|
||||
class HoleHistoryInstanceOut(BaseModel):
|
||||
source: Literal["personal", "tournament"]
|
||||
played_at: date | None
|
||||
label: str
|
||||
score: int
|
||||
par: int
|
||||
putts: int | None
|
||||
tee_shot_result: str | None
|
||||
approach_result: str | None
|
||||
chip_count: int | None
|
||||
bunker_shot_count: int | None
|
||||
penalty_strokes: int | None
|
||||
first_putt_distance_bucket: str | None
|
||||
|
||||
|
||||
class HoleHistoryOut(BaseModel):
|
||||
times_played: int
|
||||
times_personal: int
|
||||
times_tournament: int
|
||||
average_score: float
|
||||
average_score_vs_par: float
|
||||
# None = ingen av instansene har putter registrert (rene strokes_only-
|
||||
# historikk) -- IKKE det samme som 0%.
|
||||
gir_percent: float | None
|
||||
# Kun meningsfullt for par 4/5 -- None hvis ingen slike instanser har
|
||||
# utslagsretning registrert.
|
||||
fairway_hit_percent: float | None
|
||||
average_putts: float | None
|
||||
best_score: int
|
||||
worst_score: int
|
||||
instances: list[HoleHistoryInstanceOut]
|
||||
|
||||
|
||||
def summarize_hole_history(instances: list[HoleInstance]) -> HoleHistoryOut | None:
|
||||
if not instances:
|
||||
return None
|
||||
|
||||
ordered = sorted(instances, key=lambda i: i.played_at or date.min, reverse=True)
|
||||
times_played = len(ordered)
|
||||
times_personal = sum(1 for i in ordered if i.source == "personal")
|
||||
scores = [i.score for i in ordered]
|
||||
|
||||
gir_rows = [i for i in ordered if i.putts is not None]
|
||||
fairway_rows = [i for i in ordered if i.par != 3 and i.tee_shot_result is not None]
|
||||
putts_values = [i.putts for i in ordered if i.putts is not None]
|
||||
|
||||
return HoleHistoryOut(
|
||||
times_played=times_played,
|
||||
times_personal=times_personal,
|
||||
times_tournament=times_played - times_personal,
|
||||
average_score=sum(scores) / times_played,
|
||||
average_score_vs_par=sum(i.score - i.par for i in ordered) / times_played,
|
||||
gir_percent=(
|
||||
100 * sum(1 for i in gir_rows if i.score - i.putts <= i.par - 2) / len(gir_rows)
|
||||
if gir_rows
|
||||
else None
|
||||
),
|
||||
fairway_hit_percent=(
|
||||
100 * sum(1 for i in fairway_rows if i.tee_shot_result == "fairway") / len(fairway_rows)
|
||||
if fairway_rows
|
||||
else None
|
||||
),
|
||||
average_putts=sum(putts_values) / len(putts_values) if putts_values else None,
|
||||
best_score=min(scores),
|
||||
worst_score=max(scores),
|
||||
instances=[
|
||||
HoleHistoryInstanceOut(
|
||||
source=i.source,
|
||||
played_at=i.played_at,
|
||||
label=i.label,
|
||||
score=i.score,
|
||||
par=i.par,
|
||||
putts=i.putts,
|
||||
tee_shot_result=i.tee_shot_result,
|
||||
approach_result=i.approach_result,
|
||||
chip_count=i.chip_count,
|
||||
bunker_shot_count=i.bunker_shot_count,
|
||||
penalty_strokes=i.penalty_strokes,
|
||||
first_putt_distance_bucket=i.first_putt_distance_bucket,
|
||||
)
|
||||
for i in ordered
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def hole_history_for_user(user_id: str, key: CourseKey | None, hole_number: int) -> HoleHistoryOut | None:
|
||||
"""Toppnivå-inngang brukt av begge routerne: løser INGENTING selv (nøkkel
|
||||
må allerede være resolvet av kalleren, som kjenner sin egen kontekst) --
|
||||
henter fra begge kilder og slår sammen. Returnerer None både når nøkkelen
|
||||
er None (custom bane) OG når spilleren ikke har noen historikk ennå --
|
||||
kalleren skiller ikke mellom disse to, begge betyr "ikke vis panelet".
|
||||
"""
|
||||
if key is None:
|
||||
return None
|
||||
personal = await fetch_personal_hole_history(user_id, key, hole_number)
|
||||
tournament = await fetch_tournament_hole_history(user_id, key, hole_number)
|
||||
return summarize_hole_history(personal + tournament)
|
||||
|
|
@ -66,6 +66,11 @@ from handicap_engine import (
|
|||
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
||||
from ..db import org_connection
|
||||
from ..errors import app_error, translate_db_errors
|
||||
from ..hole_history import (
|
||||
HoleHistoryOut,
|
||||
hole_history_for_user,
|
||||
resolve_tournament_round_course_key,
|
||||
)
|
||||
from ..team_authz import user_is_own_tournament_participant
|
||||
from .scoring import played_hole_numbers
|
||||
|
||||
|
|
@ -1059,6 +1064,49 @@ async def update_hole(
|
|||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spillerens per-hull-historikk (ADR-072) -- se app/hole_history.py for hele
|
||||
# bane-bro-utledningen mellom frittstående runder og org-turneringer. Samme
|
||||
# lesetilgang som list_round_participant_holes under (ethvert org-medlem,
|
||||
# ikke self-only slik selve SCORINGEN er) -- aggregerte historikktall
|
||||
# (snitt/GIR%) er ikke mer sensitivt enn selve scorekortet.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
||||
"/participants/{round_participant_id}/holes/{hole_number}/history",
|
||||
response_model=HoleHistoryOut | None,
|
||||
)
|
||||
async def get_round_participant_hole_history(
|
||||
tournament_id: str,
|
||||
round_id: str,
|
||||
round_participant_id: str,
|
||||
hole_number: int,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> HoleHistoryOut | None:
|
||||
async with org_connection(organization_id) as conn:
|
||||
exists = await conn.fetchval(
|
||||
"SELECT id FROM tournament_round WHERE id = $1 AND tournament_id = $2", round_id, tournament_id
|
||||
)
|
||||
if exists is None:
|
||||
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
||||
target_user_id = await conn.fetchval(
|
||||
"""
|
||||
SELECT p.user_id::text
|
||||
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
|
||||
WHERE trp.id = $1 AND trp.tournament_round_id = $2
|
||||
""",
|
||||
round_participant_id, round_id,
|
||||
)
|
||||
if target_user_id is None:
|
||||
# Gjest (ingen app_user-kobling) -- ingen historikk å finne.
|
||||
return None
|
||||
key = await resolve_tournament_round_course_key(conn, round_id)
|
||||
return await hole_history_for_user(target_user_id, key, hole_number)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
||||
"/participants/{round_participant_id}/holes",
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ from ..db import plain_connection
|
|||
from ..email import RoundSummaryHole, send_round_summary_email
|
||||
from ..errors import app_error, translate_db_errors
|
||||
from ..handicap import SIDE_IS_UNIT, parse_allowance_config
|
||||
from ..hole_history import (
|
||||
HoleHistoryOut,
|
||||
hole_history_for_user,
|
||||
resolve_personal_round_course_key,
|
||||
)
|
||||
from ..realtime import broadcast_round_update, live_sockets_for_round
|
||||
from .auth import _hash_secret
|
||||
from .notifications import create_notification
|
||||
|
|
@ -5520,6 +5525,38 @@ async def update_hole(
|
|||
return RoundHoleOut(**dict(row), strokes_received=strokes_received)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spillerens per-hull-historikk (ADR-072) -- "hvordan spiller jeg egentlig
|
||||
# dette hullet?", aggregert på tvers av ALLE tidligere runder/turneringer
|
||||
# (se app/hole_history.py for hele bane-bro-utledningen). Historikken er
|
||||
# for DELTAKEREN som scores (participant_id), ikke nødvendigvis den
|
||||
# innloggede brukeren -- en lenket medspiller kan se på en annens historikk
|
||||
# mens de fører score for flighten (samme tilgang som selve scoringen).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/rounds/{round_id}/participants/{participant_id}/holes/{hole_number}/history",
|
||||
response_model=HoleHistoryOut | None,
|
||||
)
|
||||
async def get_hole_history(
|
||||
round_id: str,
|
||||
participant_id: str,
|
||||
hole_number: int,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> HoleHistoryOut | None:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
target_user_id = await conn.fetchval(
|
||||
"SELECT user_id::text FROM round_participant WHERE id = $1 AND round_id = $2",
|
||||
participant_id, round_id,
|
||||
)
|
||||
if target_user_id is None:
|
||||
# Gjest (ingen app_user-kobling) -- ingen historikk å finne.
|
||||
return None
|
||||
key = await resolve_personal_round_course_key(conn, round_id)
|
||||
return await hole_history_for_user(target_user_id, key, hole_number)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flaggturnering: GPS-flaggplanting + runde 2+ (migrasjon 069, 2026-08-14).
|
||||
# Spilleren markerer nøyaktig posisjon der slagbudsjettet ble brukt opp.
|
||||
|
|
|
|||
|
|
@ -5,9 +5,13 @@
|
|||
// sin ScoringWizard (frittstående runder) 2026-08-15 (ADR-071) for gjenbruk
|
||||
// i org-turneringers nye HoleStatsSheet, som nå har samme statistikkdybde
|
||||
// (migrasjon 075). Ren UI, ingen datahenting/state utover det som sendes inn.
|
||||
//
|
||||
// HoleHistoryPanel (ADR-072) er unntaket -- den HENTER selv (GET .../history),
|
||||
// siden begge scoringsflytene bruker nøyaktig samme henting/visning og det
|
||||
// ville vært ren duplisering å bygge den to ganger.
|
||||
|
||||
import { useState, type ReactNode } from "react"
|
||||
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Flag, Minus, Plus, Target } from "lucide-react"
|
||||
import { useEffect, useState, type ReactNode } from "react"
|
||||
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, ChevronDown, Flag, Minus, Plus, Target } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function golfTermForScore(n: number, par: number): string | null {
|
||||
|
|
@ -348,3 +352,113 @@ export function WizardSection({ title, children }: { title: string; children: Re
|
|||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Spillerens per-hull-historikk (ADR-072) --------------------------------
|
||||
// "Hvordan spiller jeg egentlig dette hullet?" -- aggregert på tvers av ALLE
|
||||
// tidligere frittstående runder OG turneringsrunder (custom/håndlagde baner
|
||||
// og gjestespillere er allerede filtrert bort server-side, se
|
||||
// app/hole_history.py). Panelet skjuler seg selv (viser ingenting) når
|
||||
// endepunktet svarer null -- ikke en feil, bare "ingen historikk å vise
|
||||
// ennå/denne banen støtter det ikke".
|
||||
|
||||
type HoleHistoryInstance = {
|
||||
source: "personal" | "tournament"
|
||||
played_at: string | null
|
||||
label: string
|
||||
score: number
|
||||
par: number
|
||||
putts: number | null
|
||||
tee_shot_result: string | null
|
||||
approach_result: string | null
|
||||
chip_count: number | null
|
||||
bunker_shot_count: number | null
|
||||
penalty_strokes: number | null
|
||||
first_putt_distance_bucket: string | null
|
||||
}
|
||||
|
||||
type HoleHistory = {
|
||||
times_played: number
|
||||
times_personal: number
|
||||
times_tournament: number
|
||||
average_score: number
|
||||
average_score_vs_par: number
|
||||
gir_percent: number | null
|
||||
fairway_hit_percent: number | null
|
||||
average_putts: number | null
|
||||
best_score: number
|
||||
worst_score: number
|
||||
instances: HoleHistoryInstance[]
|
||||
}
|
||||
|
||||
export function HoleHistoryPanel({ url }: { url: string }) {
|
||||
// undefined = laster ennå, null = ingen historikk å vise (custom bane,
|
||||
// gjest, eller genuint aldri spilt før).
|
||||
const [history, setHistory] = useState<HoleHistory | null | undefined>(undefined)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setHistory(undefined)
|
||||
setExpanded(false)
|
||||
fetch(url, { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (!cancelled) setHistory(data)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHistory(null)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [url])
|
||||
|
||||
if (!history) return null
|
||||
|
||||
const parSign = history.average_score_vs_par >= 0 ? "+" : ""
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
aria-expanded={expanded}
|
||||
className="flex items-center justify-between gap-3 text-left"
|
||||
>
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="text-sm font-bold text-foreground">Din historikk på dette hullet</span>
|
||||
<span className="text-xs leading-relaxed text-muted-foreground text-pretty">
|
||||
Spilt {history.times_played} {history.times_played === 1 ? "gang" : "ganger"} før
|
||||
{" "}({history.times_personal} frittstående, {history.times_tournament} turnering) -- snitt{" "}
|
||||
{history.average_score.toFixed(1)} slag ({parSign}
|
||||
{history.average_score_vs_par.toFixed(1)} til par)
|
||||
{history.gir_percent !== null && `, GIR ${Math.round(history.gir_percent)}%`}
|
||||
{history.average_putts !== null && `, ${history.average_putts.toFixed(1)} putter i snitt`}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn("size-4 shrink-0 text-muted-foreground transition-transform", expanded && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
{expanded && (
|
||||
<ul className="flex flex-col divide-y divide-border border-t border-border pt-2">
|
||||
{history.instances.map((inst, i) => (
|
||||
<li key={i} className="flex items-center justify-between gap-3 py-2 text-sm">
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-semibold text-foreground">{inst.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{inst.played_at ?? "Ukjent dato"} · {inst.source === "personal" ? "Frittstående" : "Turnering"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-extrabold tabular-nums text-foreground">
|
||||
{inst.score} slag
|
||||
{inst.putts !== null ? `, ${inst.putts} putt${inst.putts === 1 ? "" : "er"}` : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
import {
|
||||
ChoiceRow,
|
||||
DirectionCross,
|
||||
HoleHistoryPanel,
|
||||
NumberPicker,
|
||||
Stepper,
|
||||
WizardSection,
|
||||
|
|
@ -2280,6 +2281,9 @@ function ScoreTab({
|
|||
|
||||
{round && holes && (
|
||||
<HoleGrid
|
||||
base={base}
|
||||
roundId={roundId}
|
||||
participantId={participantId}
|
||||
holes={holes}
|
||||
holeConfig={round.hole_config}
|
||||
statLevel={roundParticipants.find((rp) => rp.id === participantId)?.stat_level ?? "strokes_only"}
|
||||
|
|
@ -3238,6 +3242,7 @@ function HoleStatsSheet({
|
|||
statLevel,
|
||||
strokesReceived,
|
||||
ownBagClubs,
|
||||
historyUrl,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
|
|
@ -3246,6 +3251,7 @@ function HoleStatsSheet({
|
|||
statLevel: "strokes_and_putts" | "full"
|
||||
strokesReceived: number | null
|
||||
ownBagClubs: string[]
|
||||
historyUrl: string
|
||||
onSave: (patch: HolePatch) => Promise<void>
|
||||
onClose: () => void
|
||||
}) {
|
||||
|
|
@ -3325,6 +3331,11 @@ function HoleStatsSheet({
|
|||
readOnly={false}
|
||||
/>
|
||||
|
||||
{/* Spillerens per-hull-historikk (ADR-072) -- skjuler seg selv når
|
||||
banen er custom/håndlaget eller spilleren ikke har historikk
|
||||
ennå (se HoleHistoryPanel/app/hole_history.py). */}
|
||||
<HoleHistoryPanel url={historyUrl} />
|
||||
|
||||
<NumberPicker
|
||||
label="Putter"
|
||||
value={putts}
|
||||
|
|
@ -3413,6 +3424,9 @@ function HoleStatsSheet({
|
|||
}
|
||||
|
||||
function HoleGrid({
|
||||
base,
|
||||
roundId,
|
||||
participantId,
|
||||
holes,
|
||||
holeConfig,
|
||||
statLevel,
|
||||
|
|
@ -3422,6 +3436,9 @@ function HoleGrid({
|
|||
onUpdate,
|
||||
onUpdateFull,
|
||||
}: {
|
||||
base: string
|
||||
roundId: string
|
||||
participantId: string
|
||||
holes: ApiHole[]
|
||||
holeConfig: ApiRound["hole_config"]
|
||||
statLevel: "strokes_only" | "strokes_and_putts" | "full"
|
||||
|
|
@ -3551,6 +3568,7 @@ function HoleGrid({
|
|||
statLevel={statLevel}
|
||||
strokesReceived={strokesReceived(sheetHole)}
|
||||
ownBagClubs={ownBagClubs}
|
||||
historyUrl={`${base}/rounds/${roundId}/participants/${participantId}/holes/${sheetHole}/history`}
|
||||
onSave={(patch) => onUpdateFull(sheetHole, patch)}
|
||||
onClose={() => setSheetHole(null)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
ChoiceRow,
|
||||
DirectionCross,
|
||||
golfTermForScore,
|
||||
HoleHistoryPanel,
|
||||
NumberPicker,
|
||||
Stepper,
|
||||
WizardSection,
|
||||
|
|
@ -2099,6 +2100,14 @@ function ScoringWizard({
|
|||
onChange={(v) => onUpdate({ strokes: v, played: true, pickedUp: false })}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
{/* Spillerens per-hull-historikk (ADR-072) -- skjuler seg selv
|
||||
når banen er custom/håndlaget eller spilleren ikke har noen
|
||||
historikk ennå (se HoleHistoryPanel/app/hole_history.py). */}
|
||||
<HoleHistoryPanel
|
||||
url={`/rounds/${roundId}/participants/${player.id}/holes/${hole.holeNumber}/history`}
|
||||
/>
|
||||
|
||||
{/* "Plukket opp" (migrasjon 038, Stableford, 2026-07-29) --
|
||||
kun for slagspill/Stableford, og kun når serveren faktisk
|
||||
kan regne ut Net Double Bogey (krever kjent strokes_received). */}
|
||||
|
|
|
|||
256
tests/test_hole_history.py
Normal file
256
tests/test_hole_history.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"""
|
||||
Spillerens per-hull-historikk (ADR-072). To lag med tester:
|
||||
|
||||
1. Rene enhetstester av summarize_hole_history() -- ingen db, bevis at
|
||||
GIR%/fairway%/gjennomsnitt regnes riktig og hopper over rader uten data.
|
||||
2. Integrasjonstester mot ekte router-funksjoner (samme mønster som
|
||||
test_tournament_hole_stats.py/test_concurrency_version_check.py) --
|
||||
beviser selve bane-broen: samme fysiske (teeoff-)bane spilt både som
|
||||
frittstående runde OG i en org-turnering i en ANNEN organisasjon enn
|
||||
spilleren selv eier noe i, skal telles sammen. Custom-baner og
|
||||
gjestespillere skal BEVISST ekskluderes (bruker-bekreftet scope).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from app.auth import CurrentUser
|
||||
from app.hole_history import (
|
||||
HoleInstance,
|
||||
hole_history_for_user,
|
||||
resolve_personal_round_course_key,
|
||||
summarize_hole_history,
|
||||
)
|
||||
from app.routers.individual_tournaments import (
|
||||
HoleUpdate as TournamentHoleUpdate,
|
||||
update_hole as tournament_update_hole,
|
||||
)
|
||||
from app.routers.rounds import HoleUpdate as RoundHoleUpdate, get_hole_history, update_hole as round_update_hole
|
||||
from app.routers.individual_tournaments import get_round_participant_hole_history
|
||||
|
||||
from tests.conftest import (
|
||||
add_membership,
|
||||
create_hole,
|
||||
create_org,
|
||||
create_org_hole,
|
||||
create_course,
|
||||
create_participant,
|
||||
create_player,
|
||||
create_round,
|
||||
create_tee,
|
||||
create_tournament,
|
||||
create_tournament_participant,
|
||||
create_tournament_round,
|
||||
create_tournament_round_participant,
|
||||
create_user,
|
||||
)
|
||||
|
||||
import app.db as app_db
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. summarize_hole_history() -- ren funksjon, ingen db.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _instance(**overrides) -> HoleInstance:
|
||||
defaults = dict(
|
||||
source="personal",
|
||||
played_at=date(2026, 1, 1),
|
||||
label="Runde",
|
||||
score=4,
|
||||
par=4,
|
||||
putts=None,
|
||||
tee_shot_result=None,
|
||||
approach_result=None,
|
||||
chip_count=None,
|
||||
bunker_shot_count=None,
|
||||
penalty_strokes=None,
|
||||
first_putt_distance_bucket=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return HoleInstance(**defaults)
|
||||
|
||||
|
||||
def test_summarize_empty_returns_none():
|
||||
assert summarize_hole_history([]) is None
|
||||
|
||||
|
||||
def test_summarize_counts_and_averages():
|
||||
instances = [
|
||||
_instance(source="personal", score=4, par=4),
|
||||
_instance(source="tournament", score=6, par=4),
|
||||
]
|
||||
out = summarize_hole_history(instances)
|
||||
assert out.times_played == 2
|
||||
assert out.times_personal == 1
|
||||
assert out.times_tournament == 1
|
||||
assert out.average_score == 5.0
|
||||
assert out.average_score_vs_par == 1.0
|
||||
assert out.best_score == 4
|
||||
assert out.worst_score == 6
|
||||
|
||||
|
||||
def test_summarize_gir_percent_only_over_rows_with_putts():
|
||||
instances = [
|
||||
_instance(score=4, par=4, putts=2), # 4-2=2 <= 4-2=2 -> GIR
|
||||
_instance(score=5, par=4, putts=3), # 5-3=2 <= 2 -> GIR
|
||||
_instance(score=6, par=4, putts=None), # ingen putter registrert -- ekskludert fra GIR%
|
||||
]
|
||||
out = summarize_hole_history(instances)
|
||||
assert out.gir_percent == 100.0
|
||||
assert out.average_putts == 2.5 # snitt kun over de to MED putter registrert
|
||||
|
||||
|
||||
def test_summarize_gir_percent_none_when_no_putts_data_anywhere():
|
||||
instances = [_instance(score=4, par=4, putts=None)]
|
||||
out = summarize_hole_history(instances)
|
||||
assert out.gir_percent is None
|
||||
assert out.average_putts is None
|
||||
|
||||
|
||||
def test_summarize_fairway_percent_excludes_par_3_and_missing_data():
|
||||
instances = [
|
||||
_instance(par=4, tee_shot_result="fairway"),
|
||||
_instance(par=4, tee_shot_result="left"),
|
||||
_instance(par=3, tee_shot_result="fairway"), # par 3 -- ekskludert
|
||||
_instance(par=5, tee_shot_result=None), # ingen utslagsdata -- ekskludert
|
||||
]
|
||||
out = summarize_hole_history(instances)
|
||||
assert out.fairway_hit_percent == 50.0 # 1 av 2 par-4/5-rader MED data
|
||||
|
||||
|
||||
def test_summarize_orders_most_recent_first_and_undated_last():
|
||||
instances = [
|
||||
_instance(played_at=date(2026, 1, 1), score=4),
|
||||
_instance(played_at=date(2026, 6, 1), score=5),
|
||||
_instance(played_at=None, score=6),
|
||||
]
|
||||
out = summarize_hole_history(instances)
|
||||
assert [i.score for i in out.instances] == [5, 4, 6]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Integrasjon: bane-bro + eksklusjoner.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _score_personal_hole(round_id, participant_id, user_id, hole_number, score, putts=None):
|
||||
user = CurrentUser(user_id=user_id)
|
||||
await round_update_hole(
|
||||
round_id, participant_id, hole_number,
|
||||
RoundHoleUpdate(score=score, putts=putts, expected_version=None),
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
async def _score_tournament_hole(tournament_id, round_id, rp_id, org_id, user_id, hole_number, score, putts=None):
|
||||
user = CurrentUser(user_id=user_id)
|
||||
await tournament_update_hole(
|
||||
tournament_id, round_id, rp_id, hole_number,
|
||||
TournamentHoleUpdate(gross_strokes=score, putts=putts, expected_version=None),
|
||||
organization_id=org_id, user=user,
|
||||
)
|
||||
|
||||
|
||||
async def test_teeoff_bridge_combines_personal_and_tournament_history(pool):
|
||||
"""Samme spiller, samme fysiske (teeoff-)bane -- én frittstående runde
|
||||
OG én turneringsrunde i en organisasjon spilleren IKKE eier noe i selv
|
||||
(kun har en player-rad koblet til app_user der). Historikken må vise
|
||||
BEGGE, uansett hvilken side spørringen kommer fra."""
|
||||
user_id = await create_user()
|
||||
|
||||
# Frittstående runde -- create_round() sin faste teeoff-bane-fixture
|
||||
# (facility='test-facility', course='test-course').
|
||||
async with app_db.plain_connection() as conn:
|
||||
round_id = await create_round(conn, user_id)
|
||||
participant_id = await create_participant(conn, round_id, user_id=user_id, is_owner=True)
|
||||
await create_hole(conn, participant_id, hole_number=7, par=4, stroke_index=9)
|
||||
await _score_personal_hole(round_id, participant_id, user_id, hole_number=7, score=4, putts=2)
|
||||
|
||||
# Org-turnering på en bane importert med SAMME teeoff-identitet.
|
||||
org_id = await create_org()
|
||||
await add_membership(org_id, user_id, role="member")
|
||||
tournament_id = await create_tournament(org_id)
|
||||
course_id = await create_course(org_id)
|
||||
async with app_db.org_connection(org_id) as conn:
|
||||
await conn.execute(
|
||||
"UPDATE course SET source = 'official', external_course_ref = 'test-facility:test-course' WHERE id = $1",
|
||||
course_id,
|
||||
)
|
||||
for n in range(1, 19):
|
||||
await create_org_hole(org_id, course_id, hole_number=n, par=4 if n != 7 else 4, stroke_index=n)
|
||||
tee_id = await create_tee(org_id, course_id)
|
||||
tournament_round_id = await create_tournament_round(org_id, tournament_id, course_id)
|
||||
player_id = await create_player(org_id, user_id=user_id)
|
||||
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
|
||||
rp_id = await create_tournament_round_participant(org_id, tournament_round_id, tp_id, tee_id)
|
||||
await _score_tournament_hole(tournament_id, tournament_round_id, rp_id, org_id, user_id, hole_number=7, score=5, putts=1)
|
||||
|
||||
key = ("teeoff", "test-facility", "test-course")
|
||||
combined = await hole_history_for_user(user_id, key, 7)
|
||||
assert combined.times_played == 2
|
||||
assert combined.times_personal == 1
|
||||
assert combined.times_tournament == 1
|
||||
assert sorted(i.score for i in combined.instances) == [4, 5]
|
||||
|
||||
# Begge endepunktene skal returnere SAMME kombinerte historikk.
|
||||
from_personal_endpoint = await get_hole_history(
|
||||
round_id, participant_id, 7, user=CurrentUser(user_id=user_id)
|
||||
)
|
||||
assert from_personal_endpoint.times_played == 2
|
||||
|
||||
from_tournament_endpoint = await get_round_participant_hole_history(
|
||||
tournament_id, tournament_round_id, rp_id, 7, organization_id=org_id
|
||||
)
|
||||
assert from_tournament_endpoint.times_played == 2
|
||||
|
||||
|
||||
async def test_custom_personal_course_is_excluded(pool):
|
||||
"""Håndlaget bane (course_source='custom', ingen GolfAPI-kobling) --
|
||||
resolve_personal_round_course_key skal returnere None, IKKE en snublende
|
||||
match mot noe annet."""
|
||||
user_id = await create_user()
|
||||
async with app_db.plain_connection() as conn:
|
||||
personal_course_id = str(uuid.uuid4())
|
||||
await conn.execute(
|
||||
"INSERT INTO personal_course (id, created_by_user_id, name) VALUES ($1, $2, 'Hjemmebanen')",
|
||||
personal_course_id, user_id,
|
||||
)
|
||||
for n in range(1, 19):
|
||||
await conn.execute(
|
||||
"INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index) "
|
||||
"VALUES ($1, $2, 4, $3)",
|
||||
personal_course_id, n, n,
|
||||
)
|
||||
round_id = str(uuid.uuid4())
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO round (id, owner_user_id, course_source, personal_course_id,
|
||||
course_name_snapshot, tee_name_snapshot, played_at)
|
||||
VALUES ($1, $2, 'custom', $3, 'Hjemmebanen', 'Gul', CURRENT_DATE)
|
||||
""",
|
||||
round_id, user_id, personal_course_id,
|
||||
)
|
||||
key = await resolve_personal_round_course_key(conn, round_id)
|
||||
assert key is None
|
||||
|
||||
|
||||
async def test_guest_tournament_participant_has_no_history(pool):
|
||||
"""player.user_id IS NULL (gjest, aldri registrert/lenket) -- historikk-
|
||||
endepunktet skal svare None (ikke feile), samme "skjul panelet"-
|
||||
kontrakt som en custom bane."""
|
||||
org_id = await create_org()
|
||||
tournament_id = await create_tournament(org_id)
|
||||
course_id = await create_course(org_id)
|
||||
for n in range(1, 19):
|
||||
await create_org_hole(org_id, course_id, hole_number=n, par=4, stroke_index=n)
|
||||
tee_id = await create_tee(org_id, course_id)
|
||||
tournament_round_id = await create_tournament_round(org_id, tournament_id, course_id)
|
||||
# user_id utelates bevisst -- create_player() sin default er NULL.
|
||||
player_id = await create_player(org_id)
|
||||
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
|
||||
rp_id = await create_tournament_round_participant(org_id, tournament_round_id, tp_id, tee_id)
|
||||
|
||||
out = await get_round_participant_hole_history(
|
||||
tournament_id, tournament_round_id, rp_id, 1, organization_id=org_id
|
||||
)
|
||||
assert out is None
|
||||
Loading…
Reference in a new issue