""" 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, 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 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)) 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 # --------------------------------------------------------------------------- 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_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.") 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 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) @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: # 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 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)