teecup/app/routers/courses.py

451 lines
16 KiB
Python
Raw Normal View History

"""
Egendefinerte baner organisasjonsnivå (ADR-001: gjenbrukbar 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))