teecup/app/routers/rounds.py
Erol Haagenrud 926a1a5275 All three changes are implemented and scratch-verified (22/22 checks passed, test_isolation.sql still 12/12, frontend typechecks cleanly). Summary of what's ready to ship:
Enkeltbane-anlegg (Tjøme m.fl.): banenavnet droppes nå når anlegget bare har én bane — "Tjøme Golfklubb" i stedet for "Tjøme Golfklubb – Hovedbanen". Anlegg med flere baner (f.eks. Ålesund) beholder fortsatt kombinert navn. Gjelder både turnering-import og frittstående runder.
Navngi runder: nytt valgfritt name-felt på round (migrasjon 024_round_name.sql), settbart ved opprettelse og redigerbart/fjernbart senere via "Rediger runde". Vises på tvers av rundeliste, rundeside, scorekort og statistikk (faller tilbake til banenavn når ikke satt).
Land før hjemmeklubb: "Land" er nå en nedtrekksliste (kun "Norge" foreløpig, klargjort for flere), og "Hjemmeklubb" er en søkbar liste mot teeoffs ekte klubbregister (gjenbruker det eksisterende /rounds/official-search-endepunktet — ingen ny backend-kode). Gjelder både profil-fullføring og kontoinnstillinger.
2026-07-25 06:29:09 +02:00

1118 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Frittstående rundeføring med detaljert statistikk (ADR-033).
Eid av en BRUKER (`app_user.id`), ikke en organisasjon -- INGEN RLS på
`round`/`round_participant`/`round_hole`/`personal_course*` (Beslutning A).
Samme mønster som personlig profil/HCP-historikk/sekundær e-post: kun
`plain_connection()`, autorisasjon håndheves eksplisitt her med
`WHERE owner_user_id = $1` (eller via round_id -> owner-kjeden).
Banedata (Beslutning C): offisielle teeoff-baner slås opp LIVE ved hver
runde-opprettelse/deltaker-tilføyelse (ingen lokal kopi) via samme
`teeoff_client` som den org-scopede import-flyten i courses.py bruker --
men her INGEN `course`/`hole`/`tee`-rader skrives, kun et navn-snapshot
på selve runden og et rating-snapshot per deltaker.
v1-avgrensning (eksplisitt notert i ADR-033, ikke løst her): en deltaker
med `user_id` satt får IKKE egen tilgang til runden -- derfor støtter
DENNE runden av API-et kun GJEST-deltakere (fritekstnavn), ikke ekte
kontokobling. `round_participant.user_id`-kolonnen brukes fortsatt for
runde-EIEREN (alltid en ekte konto), bare ikke for andre i flighten ennå.
HCP-indeks-oppdatering (app_user.handicap_index/handicap_history) skjer
IKKE automatisk her -- eksplisitt uavklart punkt i ADR-033. Et fullført,
tellende resultat lagres kun som `round_participant.score_differential`.
"""
from __future__ import annotations
import math
from datetime import date, datetime
from typing import Literal
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from .. import teeoff_client
from ..auth import CurrentUser, get_current_user
from ..db import plain_connection
from ..errors import app_error, translate_db_errors
from handicap_engine import (
adjusted_gross_score,
allocate_strokes_by_index,
course_handicap,
round_counts_for_handicap,
score_differential,
)
router = APIRouter(tags=["rounds"])
# ---------------------------------------------------------------------------
# Global banekatalog for egendefinerte (ikke-teeoff) baner
# ---------------------------------------------------------------------------
class PersonalCourseHoleIn(BaseModel):
hole_number: int = Field(ge=1, le=18)
par: int = Field(ge=3, le=6)
stroke_index: int = Field(ge=1, le=18)
class PersonalCourseTeeRatingIn(BaseModel):
gender: Literal["m", "f"]
course_rating: float
slope_rating: int = Field(ge=55, le=155)
par: int
class PersonalCourseTeeIn(BaseModel):
name: str
ratings: list[PersonalCourseTeeRatingIn] = Field(min_length=1, max_length=2)
class PersonalCourseCreate(BaseModel):
name: str
holes: list[PersonalCourseHoleIn] = Field(min_length=18, max_length=18)
tees: list[PersonalCourseTeeIn] = Field(min_length=1)
class PersonalCourseOut(BaseModel):
id: str
name: str
class TeeOption(BaseModel):
name: str
genders: list[Literal["m", "f"]]
class PersonalCourseDetail(BaseModel):
id: str
name: str
tees: list[TeeOption]
@router.get("/personal-courses", response_model=list[PersonalCourseOut])
async def search_personal_courses(
q: str = "",
user: CurrentUser = Depends(get_current_user),
) -> list[PersonalCourseOut]:
async with plain_connection() as conn:
rows = await conn.fetch(
"SELECT id::text AS id, name FROM personal_course WHERE name ILIKE $1 ORDER BY name LIMIT 20",
f"%{q.strip()}%",
)
return [PersonalCourseOut(**dict(r)) for r in rows]
@router.get("/personal-courses/{personal_course_id}", response_model=PersonalCourseDetail)
async def get_personal_course(
personal_course_id: str,
user: CurrentUser = Depends(get_current_user),
) -> PersonalCourseDetail:
async with plain_connection() as conn:
course_row = await conn.fetchrow("SELECT id::text AS id, name FROM personal_course WHERE id = $1", personal_course_id)
if course_row is None:
raise app_error(404, "NOT_FOUND", "Den egendefinerte banen finnes ikke.")
tee_rows = await conn.fetch(
"""
SELECT t.name AS tee_name, r.gender
FROM personal_course_tee t
JOIN personal_course_tee_rating r ON r.personal_course_tee_id = t.id
WHERE t.personal_course_id = $1
ORDER BY t.name
""",
personal_course_id,
)
tees: dict[str, list[str]] = {}
for r in tee_rows:
tees.setdefault(r["tee_name"], []).append(r["gender"])
return PersonalCourseDetail(
id=course_row["id"],
name=course_row["name"],
tees=[TeeOption(name=name, genders=genders) for name, genders in tees.items()],
)
@router.post("/personal-courses", response_model=PersonalCourseOut, status_code=201)
async def create_personal_course(
body: PersonalCourseCreate,
user: CurrentUser = Depends(get_current_user),
) -> PersonalCourseOut:
numbers = sorted(h.hole_number for h in body.holes)
indexes = sorted(h.stroke_index for h in body.holes)
if numbers != list(range(1, 19)) or indexes != list(range(1, 19)):
raise app_error(400, "VALIDATION_FAILED", "Alle 18 hullnumre og alle 18 stroke-indekser må være unike, 1-18.")
async with plain_connection() as conn, translate_db_errors():
async with conn.transaction():
course_row = await conn.fetchrow(
"INSERT INTO personal_course (name, created_by_user_id) VALUES ($1, $2) RETURNING id::text AS id, name",
body.name.strip(),
user.user_id,
)
for h in body.holes:
await conn.execute(
"INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
course_row["id"],
h.hole_number,
h.par,
h.stroke_index,
)
for t in body.tees:
tee_row = await conn.fetchrow(
"INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id",
course_row["id"],
t.name.strip(),
)
for r in t.ratings:
await conn.execute(
"""
INSERT INTO personal_course_tee_rating
(personal_course_tee_id, gender, course_rating, slope_rating, par)
VALUES ($1, $2, $3, $4, $5)
""",
tee_row["id"],
r.gender,
r.course_rating,
r.slope_rating,
r.par,
)
return PersonalCourseOut(**dict(course_row))
# ---------------------------------------------------------------------------
# Offisiell bane fra teeoff (ADR-019/ADR-033 Beslutning C) -- IKKE org-scopet,
# ulikt courses.py sine tilsvarende endepunkter (frittstående runder har
# ingen organisasjon). Rent lese-søk, ingen import/persistering av banen.
# ---------------------------------------------------------------------------
class OfficialFacility(BaseModel):
slug: str
name: str
city: str | None
county: str | None
class OfficialCourseOption(BaseModel):
teeoff_course_id: int
name: str
is_main_course: bool
tees: list[TeeOption]
class OfficialFacilityDetail(BaseModel):
slug: str
name: str
courses: list[OfficialCourseOption]
@router.get("/rounds/official-search", response_model=list[OfficialFacility])
async def search_official_courses_for_round(
q: str = "",
user: CurrentUser = Depends(get_current_user),
) -> list[OfficialFacility]:
try:
facilities = await teeoff_client.search_facilities(q)
except teeoff_client.TeeoffUnavailableError:
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra teeoff akkurat nå.")
return [
OfficialFacility(slug=f["slug"], name=f["name"], city=f.get("city"), county=f.get("county"))
for f in facilities
]
class NearbyFacility(OfficialFacility):
distance_km: float
def _haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
r = 6371.0
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lng2 - lng1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
# MERK: MÅ registreres FØR /rounds/official-search/{slug} under, ellers
# tolker FastAPI "nearby" som en ugyldig slug-verdi til den ruten (samme
# lærdom som ADR-020s "by-code" måtte registreres før {tournament_id}).
@router.get("/rounds/official-search/nearby", response_model=list[NearbyFacility])
async def nearby_official_courses_for_round(
lat: float = Query(...),
lng: float = Query(...),
limit: int = Query(default=5, ge=1, le=20),
user: CurrentUser = Depends(get_current_user),
) -> list[NearbyFacility]:
try:
facilities = await teeoff_client.search_facilities("")
except teeoff_client.TeeoffUnavailableError:
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra teeoff akkurat nå.")
with_coords = [f for f in facilities if f.get("lat") is not None and f.get("lng") is not None]
scored = sorted(with_coords, key=lambda f: _haversine_km(lat, lng, f["lat"], f["lng"]))
return [
NearbyFacility(
slug=f["slug"], name=f["name"], city=f.get("city"), county=f.get("county"),
distance_km=round(_haversine_km(lat, lng, f["lat"], f["lng"]), 1),
)
for f in scored[:limit]
]
@router.get("/rounds/official-search/{slug}", response_model=OfficialFacilityDetail)
async def get_official_facility_for_round(
slug: str,
user: CurrentUser = Depends(get_current_user),
) -> OfficialFacilityDetail:
try:
facility = await teeoff_client.get_facility(slug)
except teeoff_client.TeeoffNotFoundError:
raise app_error(404, "NOT_FOUND", "Anlegget finnes ikke i teeoff.")
except teeoff_client.TeeoffUnavailableError:
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra teeoff akkurat nå.")
courses: list[OfficialCourseOption] = []
for c in facility.get("courses", []):
if len(c.get("holes") or []) != 18:
continue
tees: list[TeeOption] = []
for t in c.get("tees") or []:
genders: list[str] = []
if t.get("cr_men") is not None and t.get("slope_men") is not None:
genders.append("m")
if t.get("cr_women") is not None and t.get("slope_women") is not None:
genders.append("f")
if genders:
tees.append(TeeOption(name=t.get("name") or "Tee", genders=genders))
courses.append(
OfficialCourseOption(
teeoff_course_id=c["id"],
name=c["name"],
is_main_course=bool(c.get("is_main_course")),
tees=tees,
)
)
return OfficialFacilityDetail(slug=facility["slug"], name=facility["name"], courses=courses)
# ---------------------------------------------------------------------------
# Banedata-oppslag (delt mellom opprett-runde og legg-til-deltaker)
# ---------------------------------------------------------------------------
#
# Returnerer (par_per_hole, stroke_index_per_hole, tee_names) og en
# funksjon for å slå opp (course_rating, slope_rating, par) for en gitt
# tee+kjønn -- felles for BÅDE teeoff (live) og personal_course (lagret).
class _ResolvedCourse:
def __init__(self, holes: list[tuple[int, int, int]], course_name: str):
# holes: liste av (hole_number, par, stroke_index), sortert 1..18
self.holes = holes
self.course_name = course_name
self._ratings: dict[tuple[str, str], tuple[float, int, int]] = {}
def add_rating(self, tee_name: str, gender: str, course_rating: float, slope_rating: int, par: int) -> None:
self._ratings[(tee_name, gender)] = (course_rating, slope_rating, par)
def rating_for(self, tee_name: str, gender: str) -> tuple[float, int, int] | None:
return self._ratings.get((tee_name, gender))
async def _resolve_teeoff_course(facility_slug: str, teeoff_course_id: int) -> _ResolvedCourse:
try:
facility = await teeoff_client.get_facility(facility_slug)
except teeoff_client.TeeoffNotFoundError:
raise app_error(404, "NOT_FOUND", "Anlegget finnes ikke i teeoff.")
except teeoff_client.TeeoffUnavailableError:
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra teeoff akkurat nå.")
course_data = next((c for c in facility.get("courses", []) if c.get("id") == teeoff_course_id), None)
if course_data is None:
raise app_error(404, "NOT_FOUND", "Banen finnes ikke på dette anlegget i teeoff.")
raw_holes = course_data.get("holes") or []
if len(raw_holes) != 18:
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull i teeoff ennå.")
for h in raw_holes:
if h.get("par") is None or h.get("hcp_index") is None:
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen mangler par eller HCP-index på ett eller flere hull.")
# Samme "kun ett banenavn -- hopp over duplikat anleggsnavn"-regel som
# `courses.py` sin `import_official_course` (2026-07-25).
if len(facility.get("courses", [])) == 1:
course_name = facility.get("name") or "Bane"
else:
course_name = f"{facility.get('name')} {course_data.get('name') or 'Bane'}"
resolved = _ResolvedCourse(
holes=sorted((h["hole_number"], h["par"], h["hcp_index"]) for h in raw_holes),
course_name=course_name,
)
for t in course_data.get("tees") or []:
name = t.get("name") or "Tee"
if t.get("cr_men") is not None and t.get("slope_men") is not None:
resolved.add_rating(name, "m", float(t["cr_men"]), int(t["slope_men"]), sum(h[1] for h in resolved.holes))
if t.get("cr_women") is not None and t.get("slope_women") is not None:
resolved.add_rating(name, "f", float(t["cr_women"]), int(t["slope_women"]), sum(h[1] for h in resolved.holes))
return resolved
async def _resolve_personal_course(personal_course_id: str) -> _ResolvedCourse:
async with plain_connection() as conn:
course_row = await conn.fetchrow("SELECT name FROM personal_course WHERE id = $1", personal_course_id)
if course_row is None:
raise app_error(404, "NOT_FOUND", "Den egendefinerte banen finnes ikke.")
hole_rows = await conn.fetch(
"SELECT hole_number, par, stroke_index FROM personal_course_hole WHERE personal_course_id = $1",
personal_course_id,
)
tee_rows = await conn.fetch(
"""
SELECT t.name AS tee_name, r.gender, r.course_rating, r.slope_rating, r.par
FROM personal_course_tee t
JOIN personal_course_tee_rating r ON r.personal_course_tee_id = t.id
WHERE t.personal_course_id = $1
""",
personal_course_id,
)
resolved = _ResolvedCourse(
holes=sorted((h["hole_number"], h["par"], h["stroke_index"]) for h in hole_rows),
course_name=course_row["name"],
)
for t in tee_rows:
resolved.add_rating(t["tee_name"], t["gender"], float(t["course_rating"]), t["slope_rating"], t["par"])
return resolved
# ---------------------------------------------------------------------------
# Runder
# ---------------------------------------------------------------------------
# Statistikknivå per deltaker (2026-07-24): kun slag er strengt tatt
# nødvendig for resultat/HCP -- putter og "flere detaljer" er valgfritt,
# av som default. GIR krever putts og vises derfor ikke i strokes_only.
StatLevel = Literal["strokes_only", "strokes_and_putts", "full"]
class RoundCreate(BaseModel):
course_source: Literal["teeoff", "custom"]
teeoff_facility_slug: str | None = None
teeoff_course_id: int | None = None
personal_course_id: str | None = None
tee_name: str
# Valgfritt eget navn på runden (2026-07-25) -- mangler det, faller
# visningen tilbake til course_name_snapshot, som alltid er satt.
name: str | None = Field(default=None, max_length=200)
played_at: date
start_hole: int = Field(default=1, ge=1, le=18)
holes_planned: Literal[9, 18] = 18
stat_level: StatLevel = "strokes_only"
# Utslagstidspunkt (2026-07-24) -- valgfritt, brukt til å beregne
# tidsbruk (completed_at - started_at) når runden fullføres.
started_at: datetime | None = None
class RoundParticipantOut(BaseModel):
id: str
user_id: str | None
guest_name: str | None
is_owner: bool
gender: str
handicap_index_snapshot: float | None
course_handicap_snapshot: int | None
counts_for_handicap: bool
score_differential: float | None
stat_level: StatLevel
class RoundOut(BaseModel):
id: str
course_source: str
name: str | None
course_name_snapshot: str
tee_name_snapshot: str
played_at: date
start_hole: int
holes_planned: int
started_at: str | None
completed_at: str | None
participants: list[RoundParticipantOut]
# Eierens egen fremdrift/score, utledet fra round_hole (aldri lagret) --
# brukt av rundelisten (round-card.tsx) som i dag manglet ethvert tall i
# det hele tatt (kun tee/hull/dato/spillerantall), rapportert av bruker
# 2026-07-25. `owner_score_to_par` er None helt til minst ett hull er
# registrert.
owner_holes_played: int
owner_total_score: int | None
owner_score_to_par: int | None
async def _load_round_out(conn, round_id: str) -> RoundOut:
round_row = await conn.fetchrow(
"""
SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot,
played_at, start_hole, holes_planned, started_at, completed_at
FROM round WHERE id = $1
""",
round_id,
)
participant_rows = await conn.fetch(
"""
SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
handicap_index_snapshot::float AS handicap_index_snapshot,
course_handicap_snapshot, counts_for_handicap,
score_differential::float AS score_differential, stat_level
FROM round_participant WHERE round_id = $1 ORDER BY is_owner DESC, created_at
""",
round_id,
)
owner_id = next((r["id"] for r in participant_rows if r["is_owner"]), None)
owner_agg = await conn.fetchrow(
"""
SELECT COUNT(*) FILTER (WHERE played) AS played_count,
COALESCE(SUM(score) FILTER (WHERE played), 0) AS total_score,
COALESCE(SUM(par) FILTER (WHERE played), 0) AS total_par
FROM round_hole WHERE round_participant_id = $1
""",
owner_id,
)
owner_holes_played = owner_agg["played_count"] if owner_agg else 0
owner_total_score = owner_agg["total_score"] if owner_holes_played > 0 else None
owner_score_to_par = (
owner_agg["total_score"] - owner_agg["total_par"] if owner_holes_played > 0 else None
)
return RoundOut(
id=round_row["id"],
course_source=round_row["course_source"],
name=round_row["name"],
course_name_snapshot=round_row["course_name_snapshot"],
tee_name_snapshot=round_row["tee_name_snapshot"],
played_at=round_row["played_at"],
start_hole=round_row["start_hole"],
holes_planned=round_row["holes_planned"],
started_at=round_row["started_at"].isoformat() if round_row["started_at"] else None,
completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
participants=[RoundParticipantOut(**dict(r)) for r in participant_rows],
owner_holes_played=owner_holes_played,
owner_total_score=owner_total_score,
owner_score_to_par=owner_score_to_par,
)
async def _create_participant(
conn,
round_id: str,
resolved: _ResolvedCourse,
tee_name: str,
*,
user_id: str | None,
guest_name: str | None,
is_owner: bool,
gender: str,
handicap_index: float | None,
stat_level: StatLevel = "strokes_only",
) -> str:
rating = resolved.rating_for(tee_name, gender)
course_rating = slope_rating = tee_par = None
course_handicap_snapshot = None
if rating is not None:
course_rating, slope_rating, tee_par = rating
if handicap_index is not None:
course_handicap_snapshot = course_handicap(handicap_index, slope_rating, course_rating, tee_par)
participant_row = await conn.fetchrow(
"""
INSERT INTO round_participant
(round_id, user_id, guest_name, is_owner, gender, handicap_index_snapshot,
course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot,
stat_level)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id::text AS id
""",
round_id,
user_id,
guest_name,
is_owner,
gender,
handicap_index,
course_rating,
slope_rating,
tee_par,
course_handicap_snapshot,
stat_level,
)
participant_id = participant_row["id"]
for hole_number, par, stroke_index in resolved.holes:
await conn.execute(
"INSERT INTO round_hole (round_participant_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
participant_id,
hole_number,
par,
stroke_index,
)
return participant_id
@router.post("/rounds", response_model=RoundOut, status_code=201)
async def create_round(
body: RoundCreate,
user: CurrentUser = Depends(get_current_user),
) -> RoundOut:
if body.course_source == "teeoff":
if not body.teeoff_facility_slug or not body.teeoff_course_id:
raise app_error(400, "VALIDATION_FAILED", "teeoff_facility_slug og teeoff_course_id er påkrevd.")
resolved = await _resolve_teeoff_course(body.teeoff_facility_slug, body.teeoff_course_id)
else:
if not body.personal_course_id:
raise app_error(400, "VALIDATION_FAILED", "personal_course_id er påkrevd.")
resolved = await _resolve_personal_course(body.personal_course_id)
async with plain_connection() as conn:
owner_row = await conn.fetchrow(
"SELECT gender, handicap_index::float AS handicap_index FROM app_user WHERE id = $1",
user.user_id,
)
if owner_row is None or owner_row["gender"] is None:
raise app_error(400, "VALIDATION_FAILED", "Fullfør profilen din (kjønn/HCP) før du registrerer en runde.")
if resolved.rating_for(body.tee_name, owner_row["gender"]) is None:
raise app_error(
400, "VALIDATION_FAILED",
"Valgt utslag har ingen rating for ditt registrerte kjønn på denne banen.",
)
async with conn.transaction(), translate_db_errors():
round_row = await conn.fetchrow(
"""
INSERT INTO round
(owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id,
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
start_hole, holes_planned, started_at, name)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id::text AS id
""",
user.user_id,
body.course_source,
body.teeoff_facility_slug,
str(body.teeoff_course_id) if body.teeoff_course_id is not None else None,
body.personal_course_id,
resolved.course_name,
body.tee_name,
body.played_at,
body.start_hole,
body.holes_planned,
body.started_at,
body.name.strip() if body.name and body.name.strip() else None,
)
round_id = round_row["id"]
await _create_participant(
conn, round_id, resolved, body.tee_name,
user_id=user.user_id, guest_name=None, is_owner=True,
gender=owner_row["gender"], handicap_index=owner_row["handicap_index"],
stat_level=body.stat_level,
)
return await _load_round_out(conn, round_id)
@router.get("/rounds", response_model=list[RoundOut])
async def list_rounds(user: CurrentUser = Depends(get_current_user)) -> list[RoundOut]:
async with plain_connection() as conn:
ids = await conn.fetch(
"SELECT id::text AS id FROM round WHERE owner_user_id = $1 ORDER BY played_at DESC, created_at DESC",
user.user_id,
)
return [await _load_round_out(conn, r["id"]) for r in ids]
async def _get_owned_round_or_404(conn, round_id: str, user_id: str):
row = await conn.fetchrow("SELECT owner_user_id::text AS owner_user_id FROM round WHERE id = $1", round_id)
if row is None:
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
if row["owner_user_id"] != user_id:
raise app_error(403, "NOT_AUTHORIZED", "Du eier ikke denne runden.")
@router.get("/rounds/{round_id}", response_model=RoundOut)
async def get_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
return await _load_round_out(conn, round_id)
class RoundUpdate(BaseModel):
# Bane-bytte er ATOMISK -- enten oppgis course_source+tee_name (pluss de
# kildespesifikke feltene) sammen, eller ingen av dem. holes_planned/
# start_hole kan endres uavhengig, i samme kall eller alene -- disse
# rører aldri round_hole, kun visnings-/navigasjonsmetadata.
course_source: Literal["teeoff", "custom"] | None = None
teeoff_facility_slug: str | None = None
teeoff_course_id: int | None = None
personal_course_id: str | None = None
tee_name: str | None = None
# Rent metadata-felt -- kan endres/fjernes uansett fullført-status,
# samme begrunnelse som start_hole/started_at/completed_at under. Sendes
# feltet med tom streng, tolkes det som "fjern navnet" (NULL); mangler
# feltet i kallet, røres eksisterende navn ikke (samme mønster som de
# andre feltene her -- kun `None`/utelatt betyr "ikke rør").
name: str | None = Field(default=None, max_length=200)
holes_planned: Literal[9, 18] | None = None
start_hole: int | None = Field(default=None, ge=1, le=18)
# Utslagstidspunkt og "Ferdig"-tidspunkt -- begge kan justeres i
# etterkant (f.eks. glemte å trykke "Fullfør runde" før flere timer
# senere, og vil rette opp tidsbruken). completed_at kan KUN settes på
# en runde som allerede er fullført (se validering under) -- denne
# PATCH-en fullfører aldri runden selv, kun korrigerer et tidspunkt som
# /complete allerede har satt.
started_at: datetime | None = None
completed_at: datetime | None = None
@router.patch("/rounds/{round_id}", response_model=RoundOut)
async def update_round(
round_id: str,
body: RoundUpdate,
user: CurrentUser = Depends(get_current_user),
) -> RoundOut:
"""
Retter opp feil bane/utslag/antall hull/starthull ETTER at runden er
opprettet (2026-07-24) -- f.eks. hvis man oppdager underveis at feil
bane ble valgt, eller vil justere utslags-/fullført-tidspunktet i
etterkant. Endrer ALDRI allerede registrerte slag/putter/etc. i
round_hole -- kun rating-grunnlaget (par/stroke-index-snapshot +
course/slope-rating) hullene regnes ut fra. Bane-bytte og
holes_planned er bevisst avvist etter at runden er fullført
(differensialen er da allerede beregnet fra det gamle grunnlaget, og
en re-beregning etter fullføring er utenfor omfang her -- ulikt
tilsvarende bane-bytte for turnering-økter, som bevisst TILLATER dette
selv etter avgjørelse). start_hole/started_at/completed_at er derimot
ren metadata som ALDRI påvirker HCP-beregningen, og kan derfor justeres
uansett fullført-status.
"""
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow("SELECT started_at, completed_at FROM round WHERE id = $1", round_id)
is_completed = round_row["completed_at"] is not None
if is_completed and (body.course_source is not None or body.holes_planned is not None):
raise app_error(
409, "ALREADY_COMPLETED",
"Runden er allerede fullført -- kan ikke endre bane eller antall hull i etterkant.",
)
if body.completed_at is not None and not is_completed:
raise app_error(
400, "VALIDATION_FAILED",
"Runden må fullføres via \"Fullfør runde\" først -- deretter kan tidspunktet justeres.",
)
effective_started_at = body.started_at if body.started_at is not None else round_row["started_at"]
effective_completed_at = body.completed_at if body.completed_at is not None else round_row["completed_at"]
if (
effective_started_at is not None
and effective_completed_at is not None
and effective_completed_at <= effective_started_at
):
raise app_error(400, "VALIDATION_FAILED", "Fullført-tidspunktet må være etter utslagstidspunktet.")
resolved: _ResolvedCourse | None = None
participants = None
if body.course_source is not None:
if not body.tee_name:
raise app_error(400, "VALIDATION_FAILED", "tee_name er påkrevd ved bane-bytte.")
if body.course_source == "teeoff":
if not body.teeoff_facility_slug or not body.teeoff_course_id:
raise app_error(400, "VALIDATION_FAILED", "teeoff_facility_slug og teeoff_course_id er påkrevd.")
resolved = await _resolve_teeoff_course(body.teeoff_facility_slug, body.teeoff_course_id)
else:
if not body.personal_course_id:
raise app_error(400, "VALIDATION_FAILED", "personal_course_id er påkrevd.")
resolved = await _resolve_personal_course(body.personal_course_id)
participants = await conn.fetch(
"""
SELECT id::text AS id, gender, handicap_index_snapshot::float AS handicap_index_snapshot
FROM round_participant WHERE round_id = $1
""",
round_id,
)
# Valider FOR ALLE deltakere FØR noe skrives -- hele bane-byttet
# avvises tydelig hvis ÉN eneste deltaker ville mistet HCP-
# sporing, ingen delvis anvendt endring.
for p in participants:
if resolved.rating_for(body.tee_name, p["gender"]) is None:
raise app_error(
400, "VALIDATION_FAILED",
"Den nye banen/utslaget mangler rating for én eller flere av deltakernes kjønn -- ingenting er endret.",
)
async with conn.transaction():
if body.holes_planned is not None:
await conn.execute("UPDATE round SET holes_planned = $2 WHERE id = $1", round_id, body.holes_planned)
if body.start_hole is not None:
await conn.execute("UPDATE round SET start_hole = $2 WHERE id = $1", round_id, body.start_hole)
if body.name is not None:
await conn.execute(
"UPDATE round SET name = $2 WHERE id = $1",
round_id,
body.name.strip() if body.name.strip() else None,
)
if body.started_at is not None:
await conn.execute("UPDATE round SET started_at = $2 WHERE id = $1", round_id, body.started_at)
if body.completed_at is not None:
await conn.execute("UPDATE round SET completed_at = $2 WHERE id = $1", round_id, body.completed_at)
if resolved is not None and participants is not None:
await conn.execute(
"""
UPDATE round SET course_source = $2, teeoff_facility_slug = $3, teeoff_course_id = $4,
personal_course_id = $5, course_name_snapshot = $6, tee_name_snapshot = $7
WHERE id = $1
""",
round_id,
body.course_source,
body.teeoff_facility_slug,
str(body.teeoff_course_id) if body.teeoff_course_id is not None else None,
body.personal_course_id,
resolved.course_name,
body.tee_name,
)
par_by_hole = {h[0]: h[1] for h in resolved.holes}
index_by_hole = {h[0]: h[2] for h in resolved.holes}
for p in participants:
course_rating, slope_rating, tee_par = resolved.rating_for(body.tee_name, p["gender"])
course_handicap_snapshot = None
if p["handicap_index_snapshot"] is not None:
course_handicap_snapshot = course_handicap(
p["handicap_index_snapshot"], slope_rating, course_rating, tee_par
)
await conn.execute(
"""
UPDATE round_participant SET course_rating_snapshot = $2, slope_rating_snapshot = $3,
tee_par_snapshot = $4, course_handicap_snapshot = $5
WHERE id = $1
""",
p["id"], course_rating, slope_rating, tee_par, course_handicap_snapshot,
)
for hole_number in range(1, 19):
await conn.execute(
"""
UPDATE round_hole SET par = $3, stroke_index = $4
WHERE round_participant_id = $1 AND hole_number = $2
""",
p["id"], hole_number, par_by_hole[hole_number], index_by_hole[hole_number],
)
return await _load_round_out(conn, round_id)
@router.delete("/rounds/{round_id}", status_code=204)
async def delete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> None:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
await conn.execute("DELETE FROM round WHERE id = $1", round_id)
# ---------------------------------------------------------------------------
# Gjest-deltakere (flighten) -- se moduldoc om hvorfor kun gjester i v1
# ---------------------------------------------------------------------------
class GuestParticipantCreate(BaseModel):
guest_name: str = Field(min_length=1, max_length=100)
gender: Literal["m", "f", "x"]
handicap_index: float | None = Field(default=None, ge=-10, le=54)
stat_level: StatLevel = "strokes_only"
@router.post("/rounds/{round_id}/participants", response_model=RoundParticipantOut, status_code=201)
async def add_guest_participant(
round_id: str,
body: GuestParticipantCreate,
user: CurrentUser = Depends(get_current_user),
) -> RoundParticipantOut:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow(
"SELECT course_source, teeoff_facility_slug, teeoff_course_id, personal_course_id, tee_name_snapshot FROM round WHERE id = $1",
round_id,
)
if round_row["course_source"] == "teeoff":
resolved = await _resolve_teeoff_course(round_row["teeoff_facility_slug"], int(round_row["teeoff_course_id"]))
else:
resolved = await _resolve_personal_course(round_row["personal_course_id"])
if resolved.rating_for(round_row["tee_name_snapshot"], body.gender) is None and body.handicap_index is not None:
raise app_error(
400, "VALIDATION_FAILED",
"Rundens utslag har ingen rating for dette kjønnet -- HCP-sporing er ikke mulig for denne deltakeren.",
)
async with conn.transaction(), translate_db_errors():
participant_id = await _create_participant(
conn, round_id, resolved, round_row["tee_name_snapshot"],
user_id=None, guest_name=body.guest_name.strip(), is_owner=False,
gender=body.gender, handicap_index=body.handicap_index,
stat_level=body.stat_level,
)
row = await conn.fetchrow(
"""
SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
handicap_index_snapshot::float AS handicap_index_snapshot,
course_handicap_snapshot, counts_for_handicap,
score_differential::float AS score_differential, stat_level
FROM round_participant WHERE id = $1
""",
participant_id,
)
return RoundParticipantOut(**dict(row))
class ParticipantUpdate(BaseModel):
stat_level: StatLevel
@router.patch("/rounds/{round_id}/participants/{participant_id}", response_model=RoundParticipantOut)
async def update_participant(
round_id: str,
participant_id: str,
body: ParticipantUpdate,
user: CurrentUser = Depends(get_current_user),
) -> RoundParticipantOut:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
row = await conn.fetchrow(
"""
UPDATE round_participant SET stat_level = $3
WHERE id = $1 AND round_id = $2
RETURNING id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
handicap_index_snapshot::float AS handicap_index_snapshot,
course_handicap_snapshot, counts_for_handicap,
score_differential::float AS score_differential, stat_level
""",
participant_id, round_id, body.stat_level,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
return RoundParticipantOut(**dict(row))
@router.delete("/rounds/{round_id}/participants/{participant_id}", status_code=204)
async def remove_guest_participant(
round_id: str,
participant_id: str,
user: CurrentUser = Depends(get_current_user),
) -> None:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
row = await conn.fetchrow(
"SELECT is_owner FROM round_participant WHERE id = $1 AND round_id = $2",
participant_id, round_id,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.")
if row["is_owner"]:
raise app_error(400, "VALIDATION_FAILED", "Kan ikke fjerne runde-eieren.")
await conn.execute("DELETE FROM round_participant WHERE id = $1", participant_id)
# ---------------------------------------------------------------------------
# Hull-for-hull-registrering
# ---------------------------------------------------------------------------
class RoundHoleOut(BaseModel):
hole_number: int
par: int
stroke_index: int
played: bool
score: int | None
putts: int | None
club_off_tee: str | 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
anyway_strokes: int | None
# Slag mottatt på dette hullet, utledet fra course_handicap_snapshot
# (samme allokeringsalgoritme som resten av appen) -- kun til visning
# av netto-score i en oversiktstabell, aldri lagret. None hvis
# deltakeren ikke har en beregnet course handicap (f.eks. gjest uten
# HCP).
strokes_received: int | None
@router.get(
"/rounds/{round_id}/participants/{participant_id}/holes",
response_model=list[RoundHoleOut],
)
async def list_holes(
round_id: str,
participant_id: str,
user: CurrentUser = Depends(get_current_user),
) -> list[RoundHoleOut]:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
participant_row = await conn.fetchrow(
"SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2",
participant_id, round_id,
)
if participant_row is None:
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
rows = await conn.fetch(
"""
SELECT hole_number, par, stroke_index, played, score, putts, club_off_tee,
tee_shot_result, approach_result, chip_count, bunker_shot_count,
penalty_strokes, first_putt_distance_bucket, anyway_strokes
FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number
""",
participant_id,
)
strokes_received_by_hole: dict[int, int] | None = None
if participant_row["course_handicap_snapshot"] is not None:
allocation = allocate_strokes_by_index(
participant_row["course_handicap_snapshot"], [r["stroke_index"] for r in rows]
)
strokes_received_by_hole = {r["hole_number"]: a for r, a in zip(rows, allocation)}
return [
RoundHoleOut(
**dict(r),
strokes_received=strokes_received_by_hole[r["hole_number"]] if strokes_received_by_hole else None,
)
for r in rows
]
class HoleUpdate(BaseModel):
played: bool = True
score: int | None = Field(default=None, ge=1, le=20)
putts: int | None = Field(default=None, ge=0, le=10)
club_off_tee: str | None = Field(default=None, max_length=50)
tee_shot_result: Literal["fairway", "left", "right"] | None = None
approach_result: Literal["hit", "long", "short", "left", "right"] | None = None
chip_count: int | None = Field(default=None, ge=0)
bunker_shot_count: int | None = Field(default=None, ge=0)
penalty_strokes: int | None = Field(default=None, ge=0)
first_putt_distance_bucket: Literal["<1m", "<2m", "<3m", "<5m", "<8m", "8m+"] | None = None
anyway_strokes: int | None = Field(default=None, ge=0)
@router.patch(
"/rounds/{round_id}/participants/{participant_id}/holes/{hole_number}",
response_model=RoundHoleOut,
)
async def update_hole(
round_id: str,
participant_id: str,
hole_number: int,
body: HoleUpdate,
user: CurrentUser = Depends(get_current_user),
) -> RoundHoleOut:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
participant_row = await conn.fetchrow(
"SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2",
participant_id, round_id,
)
if participant_row is None:
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
async with translate_db_errors():
row = await conn.fetchrow(
"""
UPDATE round_hole SET
played = $3, score = $4, putts = $5, club_off_tee = $6,
tee_shot_result = $7, approach_result = $8, chip_count = $9,
bunker_shot_count = $10, penalty_strokes = $11, first_putt_distance_bucket = $12,
anyway_strokes = $13
WHERE round_participant_id = $1 AND hole_number = $2
RETURNING hole_number, par, stroke_index, played, score, putts, club_off_tee,
tee_shot_result, approach_result, chip_count, bunker_shot_count,
penalty_strokes, first_putt_distance_bucket, anyway_strokes
""",
participant_id, hole_number,
body.played, body.score, body.putts, body.club_off_tee,
body.tee_shot_result, body.approach_result, body.chip_count,
body.bunker_shot_count, body.penalty_strokes, body.first_putt_distance_bucket,
body.anyway_strokes,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.")
# strokes_received er utledet av HELE rundens stroke-indeks-rekkefølge
# (samme allokeringsalgoritme som list_holes/GET), ikke bare dette ene
# hullet -- må derfor hente alle 18 sin stroke_index for å plassere
# riktig antall mottatte slag på nøyaktig dette hullet.
strokes_received = None
if participant_row["course_handicap_snapshot"] is not None:
all_indexes = await conn.fetch(
"SELECT hole_number, stroke_index FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number",
participant_id,
)
allocation = allocate_strokes_by_index(
participant_row["course_handicap_snapshot"], [r["stroke_index"] for r in all_indexes]
)
by_hole = {r["hole_number"]: a for r, a in zip(all_indexes, allocation)}
strokes_received = by_hole[hole_number]
return RoundHoleOut(**dict(row), strokes_received=strokes_received)
# ---------------------------------------------------------------------------
# Fullføring -- Adjusted Gross Score / Score Differential (ADR-033 Beslutning G)
# ---------------------------------------------------------------------------
@router.post("/rounds/{round_id}/complete", response_model=RoundOut)
async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow("SELECT holes_planned FROM round WHERE id = $1", round_id)
participants = await conn.fetch(
"""
SELECT id::text AS id, handicap_index_snapshot::float AS handicap_index_snapshot,
course_rating_snapshot::float AS course_rating_snapshot,
slope_rating_snapshot, tee_par_snapshot
FROM round_participant WHERE round_id = $1
""",
round_id,
)
for p in participants:
holes = await conn.fetch(
"""
SELECT hole_number, par, stroke_index, played, score
FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number
""",
p["id"],
)
played_count = sum(1 for h in holes if h["played"])
counts = round_counts_for_handicap(played_count, round_row["holes_planned"])
differential = None
if counts and p["handicap_index_snapshot"] is not None and p["course_rating_snapshot"] is not None:
pars = [h["par"] for h in holes]
strokes_received = allocate_strokes_by_index(
_course_handicap_from_row(p),
[h["stroke_index"] for h in holes],
)
scores = [h["score"] if h["played"] else None for h in holes]
ags = adjusted_gross_score(scores, pars, strokes_received)
differential = score_differential(ags, p["course_rating_snapshot"], p["slope_rating_snapshot"])
await conn.execute(
"UPDATE round_participant SET counts_for_handicap = $2, score_differential = $3 WHERE id = $1",
p["id"], counts, differential,
)
await conn.execute("UPDATE round SET completed_at = now() WHERE id = $1", round_id)
return await _load_round_out(conn, round_id)
def _course_handicap_from_row(p) -> int:
return course_handicap(
p["handicap_index_snapshot"], p["slope_rating_snapshot"], p["course_rating_snapshot"], p["tee_par_snapshot"]
)