""" 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 TeeCreate(BaseModel): name: str = Field(min_length=1, max_length=50) gender: str | None = Field(default=None, pattern="^[mfx]$") course_rating: float = Field(gt=0) slope_rating: int = Field(ge=55, le=155) par: int = Field(ge=27, le=90) class Tee(BaseModel): id: str name: str gender: str | None course_rating: float slope_rating: int par: int _TEE_COLUMNS = """ t.id::text, t.name, t.gender, tr.course_rating::float AS course_rating, tr.slope_rating, tr.par """ @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( f""" SELECT {_TEE_COLUMNS} 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 """, course_id, ) return [Tee(**dict(r)) for r in rows] @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: 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, gender) VALUES ($1, $2, $3, $4) RETURNING id::text """, organization_id, course_id, body.name, body.gender, ) rating_row = await conn.fetchrow( """ INSERT INTO tee_rating (organization_id, tee_id, scope, course_rating, slope_rating, par) VALUES ($1, $2, 'full_18', $3, $4, $5) RETURNING course_rating::float AS course_rating, slope_rating, par """, organization_id, tee_row["id"], body.course_rating, body.slope_rating, body.par, ) return Tee( id=tee_row["id"], name=body.name, gender=body.gender, course_rating=rating_row["course_rating"], slope_rating=rating_row["slope_rating"], par=rating_row["par"], ) # --- 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.", ) # (navn, kjønn, course_rating, slope_rating) -- ADR-019 Beslutning D: kun # full_18-rating importeres, teeoff har ingen egen front9/back9-rating. tee_inputs: list[tuple[str, str, float, int]] = [] for t in tees: name = t.get("name") or "Tee" if t.get("cr_men") is not None and t.get("slope_men") is not None: tee_inputs.append((name, "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: tee_inputs.append((name, "f", float(t["cr_women"]), int(t["slope_women"]))) 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, gender, course_rating, slope_rating in tee_inputs: tee_row = await conn.fetchrow( """ INSERT INTO tee (organization_id, course_id, name, gender) VALUES ($1, $2, $3, $4) RETURNING id """, organization_id, course_id, name, gender, ) await conn.execute( """ INSERT INTO tee_rating (organization_id, tee_id, scope, course_rating, slope_rating, par) VALUES ($1, $2, 'full_18', $3, $4, $5) """, organization_id, tee_row["id"], course_rating, slope_rating, par_total, ) return Course(**dict(course_row))