teecup/app/routers/rounds.py

1509 lines
67 KiB
Python
Raw Normal View History

"""
Frittstående rundeføring med detaljert statistikk (ADR-033).
Eid av en BRUKER (`app_user.id`), ikke en organisasjon -- INGEN RLS
`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
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, HTTPException, Query, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, EmailStr, Field
from .. import teeoff_client
from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket
from ..db import plain_connection
from ..errors import app_error, translate_db_errors
from ..realtime import broadcast_round_update, live_sockets_for_round
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
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
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]
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
@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))
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
# ---------------------------------------------------------------------------
# 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]
]
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
@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))
def tee_options(self) -> list["TeeOption"]:
names: dict[str, list[Literal["m", "f"]]] = {}
for tee_name, gender in self._ratings:
names.setdefault(tee_name, []).append(gender) # type: ignore[arg-type]
return [TeeOption(name=n, genders=sorted(g)) for n, g in names.items()]
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
# Valgfritt kontaktfelt, KUN for gjester (2026-07-26) -- ingen betydning
# for en lenket bruker, som allerede har sin egen kontos e-post.
guest_email: str | None
# Alltid utfylt -- guest_name for gjester, ellers en LEVENDE oppslått
# app_user.display_name for enhver lenket bruker (eier ELLER medspiller,
# ADR-036 fase 3-utvidelsen 2026-07-26) -- ikke en snapshot, samme
# "vis gjeldende navn"-mønster som org-medlemslister/chat-forfatter.
display_name: str
is_owner: bool
gender: str
# Per-deltaker utslagssted (2026-07-26) -- kan avvike fra en annen
# deltakers, f.eks. et kjønnsdelt utslag eller et bevisst enklere valg.
# Redigerbar av eieren via PATCH .../participants/{id}, kun for DENNE
# runden (endrer aldri spillerens profil).
tee_name_snapshot: 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]
# Den SPØRRENDE brukerens (eier ELLER en lenket medspiller, ADR-036
# fase 3-utvidelsen 2026-07-26) egen fremdrift/score, utledet fra
# round_hole (aldri lagret) -- omdøpt fra `owner_*` til `my_*` samme
# dag, siden feltet nå er VIEWER-relativt, ikke alltid eierens.
# `my_score_to_par` er None helt til minst ett hull er registrert.
my_holes_played: int
my_total_score: int | None
my_score_to_par: int | None
async def _load_round_out(conn, round_id: str, viewer_user_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 rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
rp.is_owner, rp.gender, rp.tee_name_snapshot,
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
rp.course_handicap_snapshot, rp.counts_for_handicap,
rp.score_differential::float AS score_differential, rp.stat_level
FROM round_participant rp
LEFT JOIN app_user au ON au.id = rp.user_id
WHERE rp.round_id = $1 ORDER BY rp.is_owner DESC, rp.created_at
""",
round_id,
)
# Viewer-relativt (ADR-036 fase 3-utvidelsen 2026-07-26): en lenket
# medspiller som ser SIN EGEN "Egne runder"-liste skal se SIN EGEN
# fremdrift her, ikke alltid eierens. Faller tilbake til eierens rad
# i det usannsynlige tilfellet spørreren ikke har noen egen
# deltaker-rad (bør ikke skje, siden tilgang allerede er gatet til
# eier-eller-deltaker av kallerne).
viewer_participant_id = next((r["id"] for r in participant_rows if r["user_id"] == viewer_user_id), None)
target_id = viewer_participant_id or next((r["id"] for r in participant_rows if r["is_owner"]), None)
viewer_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
""",
target_id,
)
my_holes_played = viewer_agg["played_count"] if viewer_agg else 0
my_total_score = viewer_agg["total_score"] if my_holes_played > 0 else None
my_score_to_par = (
viewer_agg["total_score"] - viewer_agg["total_par"] if my_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],
my_holes_played=my_holes_played,
my_total_score=my_total_score,
my_score_to_par=my_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",
guest_email: str | None = None,
) -> 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, guest_email, is_owner, gender, tee_name_snapshot, 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, $12, $13)
RETURNING id::text AS id
""",
round_id,
user_id,
guest_name,
guest_email,
is_owner,
gender,
tee_name,
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, user.user_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:
# ADR-036 fase 3-utvidelsen (2026-07-26): en lenket medspiller ser nå
# runden i SIN EGEN liste også, ikke bare eieren -- bekreftet av
# brukeren som ønsket allerede 2026-07-25.
ids = await conn.fetch(
"""
SELECT r.id::text AS id
FROM round r
WHERE r.owner_user_id = $1
OR EXISTS(SELECT 1 FROM round_participant WHERE round_id = r.id AND user_id = $1)
ORDER BY r.played_at DESC, r.created_at DESC
""",
user.user_id,
)
return [await _load_round_out(conn, r["id"], user.user_id) for r in ids]
async def _get_owned_round_or_404(conn, round_id: str, user_id: str):
"""Strengt eier-only -- for runde-forvaltning (rediger/slett metadata,
legge til/fjerne deltakere). IKKE for lesing/scoreregistrering, se
`_get_accessible_round_or_404`."""
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.")
async def _get_accessible_round_or_404(conn, round_id: str, user_id: str):
"""Eier ELLER en lenket medspiller (round_participant.user_id) --
ADR-036 fase 3-utvidelsen (2026-07-26): en medspiller kan se runden og
registrere score for HELE flighten (bekreftet regel), men kan IKKE
forvalte runden (se `_get_owned_round_or_404` for det)."""
row = await conn.fetchrow(
"""
SELECT r.owner_user_id::text AS owner_user_id,
EXISTS(
SELECT 1 FROM round_participant
WHERE round_id = r.id AND user_id = $2
) AS is_linked_participant
FROM round r WHERE r.id = $1
""",
round_id,
user_id,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
if row["owner_user_id"] != user_id and not row["is_linked_participant"]:
raise app_error(403, "NOT_AUTHORIZED", "Du har ikke tilgang til 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_accessible_round_or_404(conn, round_id, user.user_id)
return await _load_round_out(conn, round_id, user.user_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 tee_name_snapshot = $2, course_rating_snapshot = $3,
slope_rating_snapshot = $4, tee_par_snapshot = $5, course_handicap_snapshot = $6
WHERE id = $1
""",
p["id"], body.tee_name, 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],
)
result = await _load_round_out(conn, round_id, user.user_id)
await broadcast_round_update(round_id)
return result
@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)
# Varsler evt. tilkoblede medspillere om at runden er borte -- deres
# neste refetch vil da naturlig treffe 404 og vise en tydelig feil.
await broadcast_round_update(round_id)
# ---------------------------------------------------------------------------
# Medspillere (flighten) -- enten en EKTE TeeCup-bruker (funnet via det
# delte tiered personsøket, /people/search, ADR-036) eller en gjest uten
# konto (fritekstnavn). ADR-036 fase 3-utvidelsen (2026-07-26) -- inntil da
# var alle deltakere utenom eieren gjester.
# ---------------------------------------------------------------------------
class ParticipantCreate(BaseModel):
# Nøyaktig én av user_id/guest_name.
user_id: str | None = None
guest_name: str | None = Field(default=None, min_length=1, max_length=100)
# Kun brukt for guest_name-varianten -- for user_id hentes kjønn/HCP fra
# personens egen profil (samme kilde som eierens egen deltaker-rad).
gender: Literal["m", "f", "x"] | None = None
handicap_index: float | None = Field(default=None, ge=-10, le=54)
# Valgfritt kontaktfelt for en gjest (2026-07-26) -- avvist hvis oppgitt
# sammen med user_id (en lenket bruker har allerede sin egen e-post).
guest_email: EmailStr | None = None
# Utslagssted for DENNE deltakeren (2026-07-26) -- valgfritt, faller
# tilbake til rundens eget utslag (samme som før) hvis utelatt. Kan
# avvike fra de andre deltakerne, f.eks. et kjønnsdelt utslag.
tee_name: str | None = None
stat_level: StatLevel = "strokes_only"
@router.post("/rounds/{round_id}/participants", response_model=RoundParticipantOut, status_code=201)
async def add_participant(
round_id: str,
body: ParticipantCreate,
user: CurrentUser = Depends(get_current_user),
) -> RoundParticipantOut:
if (body.user_id is not None) == (body.guest_name is not None):
raise app_error(400, "VALIDATION_FAILED", "Oppgi enten user_id (funnet via søk) eller guest_name, ikke begge/ingen.")
if body.user_id is not None and body.guest_email is not None:
raise app_error(400, "VALIDATION_FAILED", "guest_email gir ikke mening sammen med user_id -- en lenket bruker har allerede sin egen konto-e-post.")
async with plain_connection() as conn:
round_row = await conn.fetchrow(
"SELECT owner_user_id::text AS owner_user_id, course_source, teeoff_facility_slug, "
"teeoff_course_id, personal_course_id, tee_name_snapshot FROM round WHERE id = $1",
round_id,
)
if round_row is None:
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
if round_row["owner_user_id"] != user.user_id:
raise app_error(403, "NOT_AUTHORIZED", "Du eier ikke denne runden.")
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 body.user_id is not None:
if body.user_id == round_row["owner_user_id"]:
raise app_error(400, "VALIDATION_FAILED", "Du er allerede med i runden som eier.")
target = await conn.fetchrow(
"SELECT gender, handicap_index::float AS handicap_index FROM app_user WHERE id = $1",
body.user_id,
)
if target is None:
raise app_error(404, "NOT_FOUND", "Fant ikke brukeren.")
if target["gender"] is None:
raise app_error(
400, "VALIDATION_FAILED",
"Spilleren du valgte har ikke fullført profilen sin (mangler kjønn) og kan ikke legges til ennå.",
)
gender = target["gender"]
handicap_index = target["handicap_index"]
guest_name = None
linked_user_id = body.user_id
else:
gender = body.gender
handicap_index = body.handicap_index
guest_name = body.guest_name.strip()
linked_user_id = None
if gender is None:
raise app_error(400, "VALIDATION_FAILED", "gender er påkrevd for en gjest uten konto.")
tee_name = body.tee_name or round_row["tee_name_snapshot"]
if resolved.rating_for(tee_name, gender) is None and handicap_index is not None:
raise app_error(
400, "VALIDATION_FAILED",
"Valgt 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, tee_name,
user_id=linked_user_id, guest_name=guest_name, is_owner=False,
gender=gender, handicap_index=handicap_index,
stat_level=body.stat_level,
guest_email=body.guest_email,
)
row = await conn.fetchrow(
"""
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
rp.is_owner, rp.gender, rp.tee_name_snapshot,
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
rp.course_handicap_snapshot, rp.counts_for_handicap,
rp.score_differential::float AS score_differential, rp.stat_level
FROM round_participant rp
LEFT JOIN app_user au ON au.id = rp.user_id
WHERE rp.id = $1
""",
participant_id,
)
await broadcast_round_update(round_id)
return RoundParticipantOut(**dict(row))
@router.get("/rounds/{round_id}/tee-options", response_model=list[TeeOption])
async def get_round_tee_options(round_id: str, user: CurrentUser = Depends(get_current_user)) -> list[TeeOption]:
"""Utslagene tilgjengelig på RUNDENS bane -- brukt av 'rediger spiller'-
panelet til å la eieren velge et annet utslag enn rundens default for en
enkelt deltaker (f.eks. et kjønnsdelt utslag). Tilgjengelig for eier
ELLER en lenket medspiller (samme lese-tilgang som resten av runden)."""
async with plain_connection() as conn:
await _get_accessible_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 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"])
return resolved.tee_options()
class ParticipantUpdate(BaseModel):
# Vanlig exclude_unset-PATCH-semantikk, som resten av appen -- et
# utelatt felt endres ikke. `handicap_index` kan sendes eksplisitt som
# `null` for å fjerne HCP-sporing for denne deltakeren (samme mønster
# som profil-PATCH-en).
stat_level: StatLevel | None = None
# Utslagssted OG HCP er begge KUN et snapshot for DENNE runden (2026-07-26)
# -- endrer aldri spillerens faktiske profil, samme reproduserbarhets-
# prinsipp som resten av round_participant. Bevisst avvist etter at
# runden er fullført, samme presedens som bane-bytte i RoundUpdate
# (differensialen er da allerede beregnet fra det gamle grunnlaget).
tee_name: str | None = None
handicap_index: float | None = Field(default=None, ge=-10, le=54)
# KUN gyldig for en gjest (user_id IS NULL) -- gir ingen mening for en
# lenket bruker, som allerede har sin egen kontos navn/kjønn/e-post.
# `gender` er en del av rating-bunten under (påvirker hvilken utslags-
# rating som er gyldig), `guest_name`/`guest_email` er rene metadata.
guest_name: str | None = Field(default=None, min_length=1, max_length=100)
gender: Literal["m", "f", "x"] | None = None
guest_email: EmailStr | None = None
@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:
updates = body.model_dump(exclude_unset=True)
if not updates:
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
current = await conn.fetchrow(
"""
SELECT rp.user_id::text AS user_id, rp.gender, rp.tee_name_snapshot,
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
r.completed_at, r.course_source, r.teeoff_facility_slug,
r.teeoff_course_id, r.personal_course_id
FROM round_participant rp
JOIN round r ON r.id = rp.round_id
WHERE rp.id = $1 AND rp.round_id = $2
""",
participant_id, round_id,
)
if current is None:
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
guest_only_fields = {"guest_name", "gender", "guest_email"}
if current["user_id"] is not None and guest_only_fields & updates.keys():
raise app_error(
400, "VALIDATION_FAILED",
"Navn/kjønn/e-post kan ikke endres her for en spiller med egen konto -- kun for en gjest.",
)
rating_changed = "tee_name" in updates or "handicap_index" in updates or "gender" in updates
if rating_changed and current["completed_at"] is not None:
raise app_error(
409, "ALREADY_COMPLETED",
"Runden er allerede fullført -- kan ikke endre utslag, HCP eller kjønn i etterkant.",
)
set_clauses: list[str] = []
values: list[object] = []
if "stat_level" in updates:
values.append(updates["stat_level"])
set_clauses.append(f"stat_level = ${len(values)}")
if "guest_name" in updates:
values.append(updates["guest_name"].strip())
set_clauses.append(f"guest_name = ${len(values)}")
if "guest_email" in updates:
values.append(updates["guest_email"])
set_clauses.append(f"guest_email = ${len(values)}")
if rating_changed:
if current["course_source"] == "teeoff":
resolved = await _resolve_teeoff_course(
current["teeoff_facility_slug"], int(current["teeoff_course_id"])
)
else:
resolved = await _resolve_personal_course(current["personal_course_id"])
new_tee_name = updates.get("tee_name") or current["tee_name_snapshot"]
new_gender = updates.get("gender") or current["gender"]
new_handicap_index = (
updates["handicap_index"] if "handicap_index" in updates else current["handicap_index_snapshot"]
)
rating = resolved.rating_for(new_tee_name, new_gender)
if rating is None:
raise app_error(
400, "VALIDATION_FAILED",
"Valgt utslag har ingen rating for spillerens (nye) kjønn.",
)
course_rating, slope_rating, tee_par = rating
course_handicap_snapshot = (
course_handicap(new_handicap_index, slope_rating, course_rating, tee_par)
if new_handicap_index is not None else None
)
values.append(new_tee_name)
set_clauses.append(f"tee_name_snapshot = ${len(values)}")
values.append(new_handicap_index)
set_clauses.append(f"handicap_index_snapshot = ${len(values)}")
values.append(course_rating)
set_clauses.append(f"course_rating_snapshot = ${len(values)}")
values.append(slope_rating)
set_clauses.append(f"slope_rating_snapshot = ${len(values)}")
values.append(tee_par)
set_clauses.append(f"tee_par_snapshot = ${len(values)}")
values.append(course_handicap_snapshot)
set_clauses.append(f"course_handicap_snapshot = ${len(values)}")
if "gender" in updates:
values.append(new_gender)
set_clauses.append(f"gender = ${len(values)}")
values.append(participant_id)
updated_id = await conn.fetchval(
f"UPDATE round_participant SET {', '.join(set_clauses)} WHERE id = ${len(values)} RETURNING id",
*values,
)
row = await conn.fetchrow(
"""
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
rp.is_owner, rp.gender, rp.tee_name_snapshot,
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
rp.course_handicap_snapshot, rp.counts_for_handicap,
rp.score_differential::float AS score_differential, rp.stat_level
FROM round_participant rp
LEFT JOIN app_user au ON au.id = rp.user_id
WHERE rp.id = $1
""",
updated_id,
)
await broadcast_round_update(round_id)
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)
await broadcast_round_update(round_id)
# ---------------------------------------------------------------------------
# Hull-for-hull-registrering
# ---------------------------------------------------------------------------
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
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
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
@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_accessible_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",
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
participant_id, round_id,
)
if participant_row is None:
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
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
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
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
]
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
class LeaderboardEntryOut(BaseModel):
participant_id: str
display_name: str
is_owner: bool
holes_played: int
total_score: int | None
score_to_par: int | None
# None når deltakeren ikke har noen beregnet course handicap (f.eks.
# gjest uten registrert HCP) -- samme begrensning som strokes_received
# i RoundHoleOut/list_holes, samme algoritme (allocate_strokes_by_index).
net_score_to_par: int | None
class LeaderboardOut(BaseModel):
holes_planned: int
completed: bool
entries: list[LeaderboardEntryOut]
@router.get("/rounds/{round_id}/leaderboard", response_model=LeaderboardOut)
async def get_leaderboard(round_id: str, user: CurrentUser = Depends(get_current_user)) -> LeaderboardOut:
async with plain_connection() as conn:
await _get_accessible_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow(
"SELECT holes_planned, completed_at FROM round WHERE id = $1", round_id
)
participant_rows = await conn.fetch(
"""
SELECT rp.id::text AS id, rp.is_owner, rp.guest_name, rp.course_handicap_snapshot,
au.display_name AS linked_display_name
FROM round_participant rp
LEFT JOIN app_user au ON au.id = rp.user_id
WHERE rp.round_id = $1
ORDER BY rp.is_owner DESC, rp.created_at
""",
round_id,
)
entries: list[LeaderboardEntryOut] = []
for p in participant_rows:
hole_rows = 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_holes = [h for h in hole_rows if h["played"] and h["score"] is not None]
holes_played = len(played_holes)
total_score = sum(h["score"] for h in played_holes) if holes_played else None
total_par = sum(h["par"] for h in played_holes) if holes_played else None
score_to_par = total_score - total_par if total_score is not None else None
net_score_to_par = None
if total_score is not None and p["course_handicap_snapshot"] is not None:
allocation = allocate_strokes_by_index(
p["course_handicap_snapshot"], [h["stroke_index"] for h in hole_rows]
)
strokes_received_by_hole = {h["hole_number"]: a for h, a in zip(hole_rows, allocation)}
strokes_received_total = sum(strokes_received_by_hole[h["hole_number"]] for h in played_holes)
net_score_to_par = score_to_par - strokes_received_total
# Aldri "Deg" her -- leaderboardet kan nå ses av flere (eier
# OG lenkede medspillere, ADR-036 fase 3-utvidelsen), så et
# viewer-relativt "Deg" ville vært feil for de andre som ser på.
display_name = p["guest_name"] or p["linked_display_name"] or "Medspiller"
entries.append(
LeaderboardEntryOut(
participant_id=p["id"],
display_name=display_name,
is_owner=p["is_owner"],
holes_played=holes_played,
total_score=total_score,
score_to_par=score_to_par,
net_score_to_par=net_score_to_par,
)
)
entries.sort(key=lambda e: (e.score_to_par is None, e.score_to_par))
return LeaderboardOut(
holes_planned=round_row["holes_planned"],
completed=round_row["completed_at"] is not None,
entries=entries,
)
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)
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
@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),
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
) -> RoundHoleOut:
async with plain_connection() as conn:
# ADR-036 fase 3-utvidelsen (2026-07-26): en lenket medspiller kan
# registrere score for HELE flighten (bekreftet regel), ikke bare
# sin egen rad -- derfor accessible-sjekk, ikke owner-only, og
# INGEN participant_id == viewer-sjekk under.
await _get_accessible_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
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
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]
await broadcast_round_update(round_id)
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:
# Samme begrunnelse som update_hole -- en medspiller som fører
# score for flighten skal også kunne markere runden ferdig.
await _get_accessible_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)
result = await _load_round_out(conn, round_id, user.user_id)
await broadcast_round_update(round_id)
return result
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"]
)
# ---------------------------------------------------------------------------
# Sanntid (2026-07-26, oppfølging av ADR-027) -- "noe endret seg, hent på
# nytt"-signal for round-detail.tsx/round-stats.tsx/round-scorecard.tsx.
# Ulikt turnering-live (ADR-027) er en runde ALDRI anonymt tilgjengelig --
# krever alltid en ekte, autorisert sesjon (eier ELLER lenket medspiller).
# ---------------------------------------------------------------------------
@router.websocket("/ws/rounds/{round_id}/live")
async def round_live_ws(websocket: WebSocket, round_id: str) -> None:
user = await get_current_user_from_websocket(websocket)
if user is None:
await websocket.close(code=4401)
return
async with plain_connection() as conn:
try:
await _get_accessible_round_or_404(conn, round_id, user.user_id)
except HTTPException as exc:
await websocket.close(code=4404 if exc.status_code == 404 else 4403)
return
await websocket.accept()
sockets = live_sockets_for_round(round_id)
sockets.add(websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
sockets.discard(websocket)