teecup/app/hole_history.py

348 lines
14 KiB
Python
Raw Permalink Normal View History

"""
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, tvers av BÅDE frittstående
runder OG org-turneringer ( 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
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 ser ).
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 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).
`CourseKey`/de tre `resolve_*_course_key()`-funksjonene gjenbrukes også av
`app/target_points.py` (migrasjon 079, rangefinder for offisielle baner)
-- samme bane-bro, annet formål (koordinater, ikke historikk). Tredje
søster `resolve_match_course_key()` (lag-matchplay: match->session->
course) har INGEN egen hole-history-bruker ennå (kun ADR-072s to
flyter), men følger identisk mønster for symmetriens skyld.
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"])
async def resolve_match_course_key(conn: Connection, match_id: str) -> CourseKey | None:
row = await conn.fetchrow(
"""
SELECT c.source, c.external_course_ref
FROM match m
JOIN session s ON s.id = m.session_id
JOIN course c ON c.id = s.course_id
WHERE m.id = $1
""",
match_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
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)