teecup/app/routers/courses.py
Erol Haagenrud df08339654 Update Todos
Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)

Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match

Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup

Widen tournaments.py: list_sessions/list_teams/concede_tournament

Widen courses.py: list_holes

Widen messaging.py: team chat REST-endepunkter (list/send/delete)

Legge til my_session_id/my_match_id i /auth/me sin my_tournaments

Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»

Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)

Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag

Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere

Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster)

Bygge HCP-historikk over tid
Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort:

Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder".

Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match).

Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00

485 lines
18 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 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 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)
# --- 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.
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))