""" 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)) # --- 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: 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å.", ) external_ref = f"{body.facility_slug}:{body.teeoff_course_id}" par_total = sum(h["par"] for h in holes) 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_data.get("name") or facility.get("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))