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>
321 lines
13 KiB
Python
321 lines
13 KiB
Python
"""
|
|
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)
|