teecup/app/routers/courses.py
Erol Haagenrud aee1662de2 Fiks turneringsoppsett-rekkefølge, koble til TeeOff-import, og bygg delt bane-mal-bibliotek
Klasser vises nå etter bane-/rundeoppsett (var meningsløst å velge
standardutslag før banen fantes). TeeOff-import koblet inn i
individuelle turneringer. Ny mal-basert baneoppretting i
turneringsmodulen (fra bunnen av / TeeOff / offentlig custom-bane),
med personal_course gjenbrukt som et delt, plattform-omfattende
bane-bibliotek på tvers av org- og single-runde-modulen (ADR-042,
migrasjon 054).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 11:34:28 +02:00

610 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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 CurrentUser, get_authorized_org, get_current_user
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.",
)
# 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))