""" 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 golfapi_cache, golfapi_client, teeoff_client from ..auth import CurrentUser, get_authorized_org, get_current_user from ..config import settings from ..db import org_connection from ..errors import app_error, translate_db_errors from ..team_authz import is_org_member, user_is_org_player from pydantic import BaseModel, Field router = APIRouter() 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] # --- 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, user: CurrentUser = Depends(get_current_user), ) -> list[Hole]: """2026-07-21 (deltaker-tilgang-runden): org-medlem ELLER en bruker koblet til NOEN spillerprofil i organisasjonen (`user_is_org_player`, bevisst løsere enn en full turnering-deltaker-sjekk -- par/stroke-index er lavsensitiv banedata, ikke spillerdata, og en bane er ikke bundet til én bestemt turnering).""" async with org_connection(organization_id) as conn: if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_org_player( conn, organization_id, user.user_id ): raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne organisasjonen.") 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) # --- Manuell baneoppretting med fullt hull-/utslagoppsett (2026-08-04) ------ # # `create_course` over tok frem til nå KUN imot et navn -- ingen vei til # hull/utslag fantes for en manuelt opprettet org-bane (kun de to ubrukte # sub-ressurs-endepunktene over, `create_tee`/`create_holes`, som ALDRI fikk # noe frontend-kallsted). Brukeren oppdaget dette hullet: "opprett manuell # bane" i turneringsmodulen var i praksis ubrukelig uten et sted å taste inn # hull/par/hcp-indeks/utslag etterpå. # # Denne utvidede varianten dekker begge deler i ÉN innsending (speiler # `PersonalCourseCreate` i rounds.py sin form), OG publiserer samtidig en # kopi til det delte, plattform-omfattende bane-biblioteket # (`personal_course`, 020_personal_rounds.sql) -- brukeren bekreftet # eksplisitt at custom-baner opprettet via den nye mal-baserte flyten skal # "lagres og være offentlige", slik at samme bane kan brukes som mal av # andre organisasjoner OG i single-runde-modulen. `personal_course` har # ingen RLS/organization_id (bevisst globalt siden migrasjon 020) -- å skrive # til den fra en org-scopet tilkobling er trygt, ingen policy er i veien. class CourseCreate(BaseModel): name: str = Field(min_length=1, max_length=200) # Enten BEGGE er med, eller INGEN -- ufullstendig oppsett (kun holes, # ikke tees) gir en tydelig 400 heller enn en course-rad som stille # mangler halve dataene. holes: list[HoleCreate] | None = None tees: list[TeeCreate] | None = None # Satt når banen opprettes med en offentlig custom-bane (personal_course) # som mal -- ren attribusjon på den NYE personal_course-publiseringen # under, ingen vedvarende kobling på selve org-bane-raden (samme # "engangs-kopi"-filosofi som offisiell TeeOff-import, ADR-019). template_personal_course_id: str | None = None @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), user: CurrentUser = Depends(get_current_user), ) -> Course: if (body.holes is None) != (body.tees is None): raise app_error(400, "VALIDATION_FAILED", "Hull og utslag må enten begge være med, eller ingen av dem.") if body.holes is not None and body.tees is not None: numbers = sorted(h.hole_number for h in body.holes) if len(body.holes) != 18 or numbers != list(range(1, 19)): raise app_error(400, "VALIDATION_FAILED", "Må oppgi nøyaktig 18 hull, nummerert 1-18.") for t in body.tees: genders = [r.gender for r in t.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(): async with conn.transaction(): row = await conn.fetchrow( f""" INSERT INTO course (organization_id, name, source) VALUES ($1, $2, 'custom') RETURNING {_COURSE_COLUMNS} """, organization_id, body.name, ) if body.holes is not None and body.tees is not None: for h in body.holes: await conn.execute( """ INSERT INTO hole (organization_id, course_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4, $5) """, organization_id, row["id"], h.hole_number, h.par, h.stroke_index, ) for t in body.tees: tee_row = await conn.fetchrow( "INSERT INTO tee (organization_id, course_id, name) VALUES ($1, $2, $3) RETURNING id", organization_id, row["id"], t.name, ) for r in t.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, ) pc_row = await conn.fetchrow( """ INSERT INTO personal_course (name, created_by_user_id, forked_from_id) VALUES ($1, $2, $3) RETURNING id """, body.name, user.user_id, body.template_personal_course_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) """, pc_row["id"], h.hole_number, h.par, h.stroke_index, ) for t in body.tees: pc_tee_row = await conn.fetchrow( "INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id", pc_row["id"], t.name, ) 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) """, pc_tee_row["id"], r.gender, r.course_rating, r.slope_rating, r.par, ) return Course(**dict(row)) # --- 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.", ) # Funnet 2026-08-21: teeoff sin egen kildedata for en bane kan ha # UGYLDIGE hcp-indekser (f.eks. duplisert -- én verdi to ganger, en # annen mangler helt) uten at feltene over fanger det opp (de sjekker # bare at HVER rad HAR en verdi, ikke at SETTET av 18 verdier faktisk # er 1..18). Ubemerket kom dette forbi valideringen og traff i stedet # `hole`-tabellens UNIQUE-constraint på (course_id, stroke_index) ved # selve INSERT-en -- fanget opp av den generiske # translate_db_errors()-oversetteren og vist til brukeren som # "allerede importert" (samme kode som et EKTE duplikat-forsøk), en # totalt misvisende feilmelding som skjulte den ekte årsaken. Sjekkes # derfor eksplisitt her, FØR noe skrives, med sin egen ærlige # feilmelding -- samme "feil tydelig FØR skriving"-prinsipp som # sjekken over (ADR-019 Beslutning C). hole_numbers = [h["hole_number"] for h in holes] hcp_indexes = [h["hcp_index"] for h in holes] if sorted(hole_numbers) != list(range(1, 19)) or sorted(hcp_indexes) != list(range(1, 19)): raise app_error( 400, "EXTERNAL_DATA_INVALID", "Banedataen fra teeoff er ugyldig (hull-nummer eller HCP-indeks er duplisert eller " "mangler for ett eller flere hull) -- dette må rettes hos teeoff før banen kan " "importeres.", ) # 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. # UNNTAK (2026-07-25, brukerrapport om Tjøme Golfklubb): har anlegget # bare ÉN bane totalt, er banenavnet ("Hovedbanen") overflødig # informasjon -- kun anleggsnavnet brukes da. 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'}" 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)) # --- Rangefinder-koordinater for offisielle baner (migrasjon 079) ----------- # # Frem til nå har rangefinder-koordinater (grønn/hindringer, GPS-basert) KUN # eksistert for GolfAPI-importerte baner. Offisielle (teeoff-koblede) baner # har ALDRI hatt noen koordinatkilde -- teeoff selv har ingen slik data, og # det finnes ingen automatisk importvei (i motsetning til GolfAPI). Dette er # derfor en RENT MANUELL skrive-vei: selve tolkningsarbeidet (fritekst-navn # på feltbefarte punkter -> poi_type/location/side_fairway) krever # dømmekraft og skjer FØR kallet hit, ikke automatisert her (bekreftet # konkret av Tjøme-runden -- "Voll"/"Bjella" kunne ikke vært gjettet av et # script). Full erstatning (ikke delta) -- speiler hvordan koordinatsett # faktisk oppstår i praksis (en ny feltbefaring erstatter den forrige, ikke # et lite tillegg til den). Lagres GLOBALT (teeoff_course_coordinate, nøkkel # course.external_course_ref) -- IKKE per organisasjon, samme begrunnelse # som golfapi_course_coordinate: flere organisasjoner kan importere samme # fysiske bane fra teeoff hver sin course-rad, koordinatene skal deles. class CoursePointIn(BaseModel): hole_number: int = Field(ge=1, le=18) poi_type: str location: str | None = None side_fairway: str | None = None latitude: float = Field(ge=-90, le=90) longitude: float = Field(ge=-180, le=180) # Eksplisitt, menneske-satt paring av forkant/bakkant til SAMME # fysiske hindring (migrasjon 080/ADR-084) -- se hazard_group- # kommentaren i migrasjonsfilen. Frivillig, ingen validering utover # tekst -- kun meningsfullt unikt innenfor ett hull. hazard_group: str | None = None class CoursePointOut(BaseModel): hole_number: int poi_type: str location: str | None side_fairway: str | None latitude: float longitude: float hazard_group: str | None _COORDINATE_COLUMNS = "hole_number, poi_type, location, side_fairway, latitude, longitude, hazard_group" @router.get("/orgs/{organization_id}/courses/{course_id}/coordinates", response_model=list[CoursePointOut]) async def list_course_coordinates( course_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[CoursePointOut]: async with org_connection(organization_id) as conn: course_row = await conn.fetchrow("SELECT source, external_course_ref FROM course WHERE id = $1", course_id) if course_row is None: raise app_error(404, "NOT_FOUND", "Banen finnes ikke.") if course_row["source"] != "official" or course_row["external_course_ref"] is None: return [] rows = await conn.fetch( f""" SELECT {_COORDINATE_COLUMNS} FROM teeoff_course_coordinate WHERE external_course_ref = $1 ORDER BY hole_number """, course_row["external_course_ref"], ) return [CoursePointOut(**dict(r)) for r in rows] @router.put("/orgs/{organization_id}/courses/{course_id}/coordinates", response_model=list[CoursePointOut]) async def replace_course_coordinates( course_id: str, body: list[CoursePointIn], organization_id: str = Depends(get_authorized_org), ) -> list[CoursePointOut]: async with org_connection(organization_id) as conn, translate_db_errors(): course_row = await conn.fetchrow("SELECT source, external_course_ref FROM course WHERE id = $1", course_id) if course_row is None: raise app_error(404, "NOT_FOUND", "Banen finnes ikke.") if course_row["source"] != "official": raise app_error( 400, "VALIDATION_FAILED", "Kun offisielle (TeeOff-koblede) baner kan få rangefinder-koordinater satt manuelt.", ) external_ref = course_row["external_course_ref"] await conn.execute("DELETE FROM teeoff_course_coordinate WHERE external_course_ref = $1", external_ref) for p in body: await conn.execute( """ INSERT INTO teeoff_course_coordinate (external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude, hazard_group) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) """, external_ref, p.hole_number, p.poi_type, p.location, p.side_fairway, p.latitude, p.longitude, p.hazard_group, ) rows = await conn.fetch( f""" SELECT {_COORDINATE_COLUMNS} FROM teeoff_course_coordinate WHERE external_course_ref = $1 ORDER BY hole_number """, external_ref, ) return [CoursePointOut(**dict(r)) for r in rows] # --- Internasjonal banedata fra GolfAPI.io (ADR-064) ------------------------- # # Tredje banekilde, for baner utenfor TeeOffs dekning -- Tjøme Golfklubb var # den konkrete anledningen (bekreftet IKKE i TeeOff 2026-08-12). ULIKT # TeeOff-importen over: GolfAPI-kall koster ekte, begrenset budsjett, derfor # går ALL henting via golfapi_cache.get_or_fetch_golfapi_course() -- aldri # et direkte golfapi_client-kall herfra. Samme "kopier inn, aldri live # oppslag"-filosofi og samme "fail loudly on ufullstendige data"-prinsipp # som ADR-019, men fra en annen kilde. class InternationalCourseOption(BaseModel): course_id: str course_name: str num_holes: int has_gps: bool class InternationalClub(BaseModel): club_id: str club_name: str city: str | None = None country: str | None = None courses: list[InternationalCourseOption] class InternationalCourseImport(BaseModel): golfapi_course_id: str @router.get("/orgs/{organization_id}/courses/international-search", response_model=list[InternationalClub]) async def search_international_courses( q: str = Query(default=""), organization_id: str = Depends(get_authorized_org), ) -> list[InternationalClub]: if not q.strip(): return [] try: clubs = await golfapi_client.search_clubs(name=q) except golfapi_client.GolfApiNotConfiguredError: raise app_error(400, "NOT_CONFIGURED", "GolfAPI er ikke konfigurert ennå.") except golfapi_client.GolfApiUnavailableError: raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra GolfAPI akkurat nå.") return [ InternationalClub( club_id=c["clubID"], club_name=c["clubName"], city=c.get("city"), country=c.get("country"), courses=[ InternationalCourseOption( course_id=course["courseID"], course_name=course.get("courseName") or "Bane", num_holes=int(course["numHoles"]), has_gps=bool(int(course.get("hasGPS") or 0)), ) for course in c.get("courses", []) ], ) for c in clubs ] @router.post("/orgs/{organization_id}/courses/international-import", response_model=Course, status_code=201) async def import_international_course( body: InternationalCourseImport, organization_id: str = Depends(get_authorized_org), ) -> Course: if not settings.GOLFAPI_TOKEN: raise app_error(400, "NOT_CONFIGURED", "GolfAPI er ikke konfigurert ennå.") # Idempotent, samme begrunnelse som import_official_course over: samme # organisasjon importerer ofte samme bane til flere økter. async with org_connection(organization_id) as conn: existing = await conn.fetchrow( f"SELECT {_COURSE_COLUMNS} FROM course WHERE external_course_ref = $1 AND source = 'international'", body.golfapi_course_id, ) if existing is not None: return Course(**dict(existing)) async with org_connection(organization_id) as conn, translate_db_errors(): try: await golfapi_cache.get_or_fetch_golfapi_course(conn, body.golfapi_course_id) except golfapi_client.GolfApiNotFoundError: raise app_error(404, "NOT_FOUND", "Banen finnes ikke hos GolfAPI.") except golfapi_client.GolfApiUnavailableError: raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra GolfAPI akkurat nå.") gc = await conn.fetchrow("SELECT club_name, course_name, city FROM golfapi_course WHERE course_id = $1", body.golfapi_course_id) holes = await conn.fetch( "SELECT hole_number, par_men, index_men FROM golfapi_course_hole WHERE golfapi_course_id = $1 ORDER BY hole_number", body.golfapi_course_id, ) tees = await conn.fetch( "SELECT name, course_rating_men, slope_men, course_rating_women, slope_women FROM golfapi_course_tee WHERE golfapi_course_id = $1", body.golfapi_course_id, ) # ADR-064 (speiler ADR-019 Beslutning C): ufullstendige data feiler # importen tydelig FØR noe skrives til course/hole/tee. if len(holes) != 18: raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull hos GolfAPI.") if not tees: raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ingen registrerte utslag hos GolfAPI.") if not any( (t["course_rating_men"] is not None and t["slope_men"] is not None) or (t["course_rating_women"] is not None and t["slope_women"] is not None) for t in tees ): raise app_error( 400, "EXTERNAL_DATA_INCOMPLETE", "Ingen av utslagene har en brukbar rating/slope hos GolfAPI." ) if any(h["par_men"] is None or h["index_men"] is None for h in holes): raise app_error( 400, "EXTERNAL_DATA_INCOMPLETE", "Banen mangler par eller HCP-index på ett eller flere hull hos GolfAPI." ) # Samme sjekk, samme begrunnelse som import_official_course (teeoff) # -- se kommentaren der. En kilde kan ha gyldig-utseende, men # dupliserte/manglende index-verdier som ellers først feiler stille # som en misvisende "allerede importert" ved selve INSERT-en. hole_numbers = [h["hole_number"] for h in holes] hcp_indexes = [h["index_men"] for h in holes] if sorted(hole_numbers) != list(range(1, 19)) or sorted(hcp_indexes) != list(range(1, 19)): raise app_error( 400, "EXTERNAL_DATA_INVALID", "Banedataen fra GolfAPI er ugyldig (hull-nummer eller HCP-indeks er duplisert eller " "mangler for ett eller flere hull) -- dette må rettes hos GolfAPI før banen kan " "importeres.", ) par_total = sum(h["par_men"] for h in holes) course_name = f"{gc['club_name']} – {gc['course_name']}" if gc["course_name"] else gc["club_name"] course_row = await conn.fetchrow( f""" INSERT INTO course (organization_id, name, source, external_course_ref) VALUES ($1, $2, 'international', $3) RETURNING {_COURSE_COLUMNS} """, organization_id, course_name, body.golfapi_course_id, ) 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_men"], h["index_men"], ) for t in tees: # Reelt funn (Nesbyen, 2026-08-13): GolfAPI kan mangle RATING # FOR BEGGE kjønn på et utslag (opprinnelig antatt at menns alltid # finnes, speilet fra ADR-019s teeoff-import) -- begge sjekkes nå # individuelt, samme mønster som teeoff-importen allerede bruker. # Utslag uten NOEN brukbar rating hoppes over (usable_tees under). ratings: list[tuple[str, float, int]] = [] if t["course_rating_men"] is not None and t["slope_men"] is not None: ratings.append(("m", float(t["course_rating_men"]), int(t["slope_men"]))) if t["course_rating_women"] is not None and t["slope_women"] is not None: ratings.append(("f", float(t["course_rating_women"]), int(t["slope_women"]))) if not ratings: continue tee_row = await conn.fetchrow( "INSERT INTO tee (organization_id, course_id, name) VALUES ($1, $2, $3) RETURNING id", organization_id, course_id, t["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))