""" Egendefinerte baner på organisasjonsnivå (ADR-001: gjenbrukbar på tvers av turneringer, samme mønster som players.py). `course.source = 'custom'` er den eneste veien inn her -- 'official' (ekte teeoff-baner via ADR-004 sitt planlagte, lesende API) er fortsatt kun vedtatt, ikke bygget (se FEATURE_BACKLOG.md), og krever uansett `external_course_ref` satt (skjemaets CHECK-constraint), som ingen klient kan oppgi meningsfullt ennå. Denne runden dekker KUN det organisatoren trenger for å opprette en økt: en bane ved navn, ingen hull-/tee-/rating-detaljer ennå (session-motoren bruker foreløpig kun course_id som fremmednøkkel, ikke banens innhold). """ from fastapi import APIRouter, Depends, Query from .. import teeoff_client from ..auth import get_authorized_org from ..db import org_connection from ..errors import app_error, translate_db_errors from pydantic import BaseModel, Field router = APIRouter() class CourseCreate(BaseModel): name: str = Field(min_length=1, max_length=200) class Course(BaseModel): id: str name: str source: str _COURSE_COLUMNS = "id::text, name, source::text AS source" @router.get("/orgs/{organization_id}/courses", response_model=list[Course]) async def list_courses( organization_id: str = Depends(get_authorized_org), ) -> list[Course]: async with org_connection(organization_id) as conn: rows = await conn.fetch(f"SELECT {_COURSE_COLUMNS} FROM course ORDER BY name") return [Course(**dict(r)) for r in rows] @router.post("/orgs/{organization_id}/courses", response_model=Course, status_code=201) async def create_course( body: CourseCreate, organization_id: str = Depends(get_authorized_org), ) -> Course: async with org_connection(organization_id) as conn, translate_db_errors(): row = await conn.fetchrow( f""" INSERT INTO course (organization_id, name, source) VALUES ($1, $2, 'custom') RETURNING {_COURSE_COLUMNS} """, organization_id, body.name, ) return Course(**dict(row)) # --- Tee-er (ADR-013-relatert: match_participant.tee_id krever en ekte tee) -- # # Manglet helt frem til nå -- verken en liste-vei (selv offisielt importerte # baner har tee-rader, men ingenting eksponerte dem) eller en opprett-vei for # egendefinerte baner (de har ALDRI hatt noen vei til tee-data, uavhengig av # ADR-019). Reist av brukeren rett før blind draw-skjermen: uten dette kan en # match aldri få en deltaker (tee_id er påkrevd, ingen gyldig verdi finnes). # Kun full_18-rating dekket her, samme begrunnelse som ADR-019 Beslutning D. # Hull-/stroke-index-data for egendefinerte baner (trengs for korrekt # slagfordeling i SCORING-fasen, ikke i blind draw) er bevisst UTENFOR # omfang denne runden -- egen, senere sak når scorekort-skjermen bygges. class TeeRatingInput(BaseModel): gender: str = Field(pattern="^[mf]$") course_rating: float = Field(gt=0) slope_rating: int = Field(ge=55, le=155) par: int = Field(ge=27, le=90) class TeeCreate(BaseModel): name: str = Field(min_length=1, max_length=50) # ADR-029: kjønn hører til RATINGEN, ikke selve utslaget -- ett fysisk # utslag kan ha én rating per kjønn (aldri to av samme), organisator # oppgir hvilke som faktisk finnes for banen (ofte begge, noen ganger # bare én -- klubben har bevisst ikke slopet den andre). ratings: list[TeeRatingInput] = Field(min_length=1, max_length=2) class TeeRatingOut(BaseModel): gender: str course_rating: float slope_rating: int par: int class Tee(BaseModel): id: str name: str ratings: list[TeeRatingOut] @router.get("/orgs/{organization_id}/courses/{course_id}/tees", response_model=list[Tee]) async def list_tees( course_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[Tee]: async with org_connection(organization_id) as conn: rows = await conn.fetch( """ SELECT t.id::text AS id, t.name, tr.gender, tr.course_rating::float AS course_rating, tr.slope_rating, tr.par FROM tee t JOIN tee_rating tr ON tr.tee_id = t.id AND tr.scope = 'full_18' WHERE t.course_id = $1 ORDER BY t.name, tr.gender """, course_id, ) tees: dict[str, Tee] = {} for r in rows: tee = tees.setdefault(r["id"], Tee(id=r["id"], name=r["name"], ratings=[])) tee.ratings.append( TeeRatingOut( gender=r["gender"], course_rating=r["course_rating"], slope_rating=r["slope_rating"], par=r["par"], ) ) return list(tees.values()) @router.post("/orgs/{organization_id}/courses/{course_id}/tees", response_model=Tee, status_code=201) async def create_tee( course_id: str, body: TeeCreate, organization_id: str = Depends(get_authorized_org), ) -> Tee: genders = [r.gender for r in body.ratings] if len(genders) != len(set(genders)): raise app_error( 400, "VALIDATION_FAILED", "Kan ikke oppgi to ratinger for samme kjønn på ett utslag." ) async with org_connection(organization_id) as conn, translate_db_errors(): source = await conn.fetchval("SELECT source FROM course WHERE id = $1", course_id) if source is None: raise app_error(404, "NOT_FOUND", "Banen finnes ikke.") if source == "official": raise app_error( 400, "VALIDATION_FAILED", "Offisielle baner får tee-ene sine fra teeoff -- kan ikke legges til manuelt.", ) tee_row = await conn.fetchrow( "INSERT INTO tee (organization_id, course_id, name) VALUES ($1, $2, $3) RETURNING id::text", organization_id, course_id, body.name, ) ratings_out: list[TeeRatingOut] = [] for r in body.ratings: await conn.execute( """ INSERT INTO tee_rating (organization_id, tee_id, scope, gender, course_rating, slope_rating, par) VALUES ($1, $2, 'full_18', $3, $4, $5, $6) """, organization_id, tee_row["id"], r.gender, r.course_rating, r.slope_rating, r.par, ) ratings_out.append( TeeRatingOut( gender=r.gender, course_rating=r.course_rating, slope_rating=r.slope_rating, par=r.par ) ) return Tee(id=tee_row["id"], name=body.name, ratings=ratings_out) # --- Hull (ADR-008-relatert: stroke_index kreves for slagfordeling i -- # scoring-fasen -- se app/routers/scoring.py sin bruk av `hole`-tabellen) - # # Egendefinerte baner har ALDRI hatt noen vei til hull-data, uavhengig av # ADR-019 (samme klasse hull som tee-mangelen -- flagget FØR scorekort- # skjermen ble bygget, ikke oppdaget for sent). Kun `stroke`-scoringsmodus # trenger dette; `hole_result`-modus fungerer allerede uten. Engangs # oppsett: enten alle 18 hull settes i ÉN innsending, eller ingen -- unngår # kompleksiteten med delvise/partielle hull-sett. class HoleCreate(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 Hole(BaseModel): hole_number: int par: int stroke_index: int @router.get("/orgs/{organization_id}/courses/{course_id}/holes", response_model=list[Hole]) async def list_holes( course_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[Hole]: async with org_connection(organization_id) as conn: rows = await conn.fetch( "SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", course_id, ) return [Hole(**dict(r)) for r in rows] @router.post("/orgs/{organization_id}/courses/{course_id}/holes", response_model=list[Hole], status_code=201) async def create_holes( course_id: str, body: list[HoleCreate], organization_id: str = Depends(get_authorized_org), ) -> list[Hole]: if len(body) != 18: raise app_error(400, "VALIDATION_FAILED", "Må oppgi nøyaktig 18 hull i én innsending.") numbers = sorted(h.hole_number for h in body) if numbers != list(range(1, 19)): raise app_error(400, "VALIDATION_FAILED", "Hullnumrene må dekke nøyaktig 1-18, hver én gang.") async with org_connection(organization_id) as conn, translate_db_errors(): source = await conn.fetchval("SELECT source FROM course WHERE id = $1", course_id) if source is None: raise app_error(404, "NOT_FOUND", "Banen finnes ikke.") if source == "official": raise app_error( 400, "VALIDATION_FAILED", "Offisielle baner får hullene sine fra teeoff -- kan ikke legges til manuelt.", ) existing = await conn.fetchval("SELECT count(*) FROM hole WHERE course_id = $1", course_id) if existing > 0: raise app_error( 409, "DUPLICATE", "Banen har allerede registrert hull -- kan ikke settes opp på nytt." ) rows = [] for h in body: rows.append( await conn.fetchrow( """ INSERT INTO hole (organization_id, course_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4, $5) RETURNING hole_number, par, stroke_index """, organization_id, course_id, h.hole_number, h.par, h.stroke_index, ) ) return sorted((Hole(**dict(r)) for r in rows), key=lambda h: h.hole_number) # --- Offisiell banedata fra teeoff (ADR-019) --------------------------------- class OfficialFacility(BaseModel): slug: str name: str city: str | None = None county: str | None = None class OfficialCourseOption(BaseModel): teeoff_course_id: int name: str is_main_course: bool class OfficialFacilityDetail(BaseModel): slug: str name: str courses: list[OfficialCourseOption] class OfficialCourseImport(BaseModel): facility_slug: str teeoff_course_id: int @router.get("/orgs/{organization_id}/courses/official-search", response_model=list[OfficialFacility]) async def search_official_courses( q: str = Query(default=""), organization_id: str = Depends(get_authorized_org), ) -> 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 ] @router.get( "/orgs/{organization_id}/courses/official-search/{slug}", response_model=OfficialFacilityDetail, ) async def get_official_facility( slug: str, organization_id: str = Depends(get_authorized_org), ) -> 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 = [ OfficialCourseOption( teeoff_course_id=c["id"], name=c["name"], is_main_course=bool(c.get("is_main_course")), ) # Kun 18-hulls baner kan importeres foreløpig (ADR-019) -- filtrert # her slik at organisator aldri ser et valg som senere feiler. for c in facility.get("courses", []) if len(c.get("holes") or []) == 18 ] return OfficialFacilityDetail(slug=facility["slug"], name=facility["name"], courses=courses) @router.post("/orgs/{organization_id}/courses/official-import", response_model=Course, status_code=201) async def import_official_course( body: OfficialCourseImport, organization_id: str = Depends(get_authorized_org), ) -> Course: external_ref = f"{body.facility_slug}:{body.teeoff_course_id}" # Idempotent: importerer organisatoren samme bane på nytt (vanlig -- # flere økter spilles ofte på samme bane), gi tilbake den EKSISTERENDE # raden i stedet for en 409-feil. Sjekket FØR noe teeoff-kall gjøres, # både raskere og mer robust mot at teeoff er nede akkurat da. Fant # og fikset 2026-07-18 etter en reell brukerrapport: duplikat-sperren # (migrasjon 010) var riktig i prinsippet, men ga ingen vei videre for # den helt vanlige "bruk samme bane på runde 2" siden av saken. async with org_connection(organization_id) as conn: existing = await conn.fetchrow( f"SELECT {_COURSE_COLUMNS} FROM course WHERE external_course_ref = $1", external_ref, ) if existing is not None: return Course(**dict(existing)) try: facility = await teeoff_client.get_facility(body.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") == body.teeoff_course_id), None ) if course_data is None: raise app_error(404, "NOT_FOUND", "Banen finnes ikke på dette anlegget i teeoff.") holes = course_data.get("holes") or [] tees = course_data.get("tees") or [] # ADR-019 Beslutning C: hele importen feiler tydelig FØR noe skrives, # aldri en delvis importert bane som senere feiler i handicap-beregning. if len(holes) != 18: raise app_error( 400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull i teeoff ennå." ) for h in 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 i teeoff.", ) # ADR-019 Beslutning D: kun full_18-rating importeres, teeoff har ingen # egen front9/back9-rating. ADR-029: kjønn hører til RATINGEN, ikke selve # utslaget -- ett teeoff-utslag blir HER importert som ÉN fysisk # `tee`-rad med inntil to kjønnsspesifikke ratinger (tidligere ble dette # feilaktig importert som to separate tee-rader, én per kjønn). tee_inputs: list[tuple[str, list[tuple[str, float, int]]]] = [] for t in tees: name = t.get("name") or "Tee" ratings: list[tuple[str, float, int]] = [] if t.get("cr_men") is not None and t.get("slope_men") is not None: ratings.append(("m", float(t["cr_men"]), int(t["slope_men"]))) if t.get("cr_women") is not None and t.get("slope_women") is not None: ratings.append(("f", float(t["cr_women"]), int(t["slope_women"]))) if ratings: tee_inputs.append((name, ratings)) if not tee_inputs: raise app_error( 400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ingen tee med registrert rating/slope i teeoff ennå.", ) par_total = sum(h["par"] for h in holes) # Navn kombinerer anlegg + bane -- "Hovedbanen" alene er tvetydig (mange # klubber navngir hovedbanen sin nøyaktig likt). Fant og fikset samme # runde som idempotent-fiksen over, samme bakenforliggende brukerrapport. course_name = f"{facility.get('name')} – {course_data.get('name') or 'Bane'}" async with org_connection(organization_id) as conn, translate_db_errors(): course_row = await conn.fetchrow( f""" INSERT INTO course (organization_id, name, source, external_course_ref) VALUES ($1, $2, 'official', $3) RETURNING {_COURSE_COLUMNS} """, organization_id, course_name, external_ref, ) course_id = course_row["id"] for h in holes: await conn.execute( """ INSERT INTO hole (organization_id, course_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4, $5) """, organization_id, course_id, h["hole_number"], h["par"], h["hcp_index"], ) for name, ratings in tee_inputs: tee_row = await conn.fetchrow( "INSERT INTO tee (organization_id, course_id, name) VALUES ($1, $2, $3) RETURNING id", organization_id, course_id, name, ) for gender, course_rating, slope_rating in ratings: await conn.execute( """ INSERT INTO tee_rating (organization_id, tee_id, scope, gender, course_rating, slope_rating, par) VALUES ($1, $2, 'full_18', $3, $4, $5, $6) """, organization_id, tee_row["id"], gender, course_rating, slope_rating, par_total, ) return Course(**dict(course_row))