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