Program-skjermen er bygget og grundig scratch-verifisert. Kort oppsummert:
Nytt:
app/routers/courses.py — enkel bane-CRUD (GET/POST /orgs/{id}/courses), fant og tettet et reelt hull: SessionCreate.course_id var påkrevd, men ingen vei fantes til å skaffe én
components/tournament-program.tsx + rute /tournaments/[id]/program — tidslinje over økter, opprett-skjema med bane-type-ahead og avanserte handicap-brytere
Fanerad lagt til i både roster- og program-skjermen så du kan bevege deg mellom dem
To reelle feil rettet før integrering:
V0-promptet mitt ba om ett generisk "Scramble"-format, men databasen/motoren krever scramble_2/scramble_4 som atskilte verdier — rettet til to segment-knapper
Verifiserte allowance_override-JSON-formen eksakt mot parse_allowance_config (typet combined/per_player + 0–1-brøk, ikke flat prosent) — bekreftet med en ekte rundtur i scratch, ikke bare lest fra koden
Verifisert: courses opprettet+listet, kryss-org-isolasjon, økt med klokkeslett, økt med scramble_4+full handicap-override-rundtur, gammel "scramble"-verdi korrekt avvist, test_isolation.sql 12/12, ekte typesjekket produksjonsbuild (samme Dockerfile som deployes).
2026-07-18 12:20:45 +02:00
|
|
|
"""
|
|
|
|
|
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).
|
|
|
|
|
"""
|
|
|
|
|
|
Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.
To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?
Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
2026-07-18 12:44:05 +02:00
|
|
|
from fastapi import APIRouter, Depends, Query
|
Program-skjermen er bygget og grundig scratch-verifisert. Kort oppsummert:
Nytt:
app/routers/courses.py — enkel bane-CRUD (GET/POST /orgs/{id}/courses), fant og tettet et reelt hull: SessionCreate.course_id var påkrevd, men ingen vei fantes til å skaffe én
components/tournament-program.tsx + rute /tournaments/[id]/program — tidslinje over økter, opprett-skjema med bane-type-ahead og avanserte handicap-brytere
Fanerad lagt til i både roster- og program-skjermen så du kan bevege deg mellom dem
To reelle feil rettet før integrering:
V0-promptet mitt ba om ett generisk "Scramble"-format, men databasen/motoren krever scramble_2/scramble_4 som atskilte verdier — rettet til to segment-knapper
Verifiserte allowance_override-JSON-formen eksakt mot parse_allowance_config (typet combined/per_player + 0–1-brøk, ikke flat prosent) — bekreftet med en ekte rundtur i scratch, ikke bare lest fra koden
Verifisert: courses opprettet+listet, kryss-org-isolasjon, økt med klokkeslett, økt med scramble_4+full handicap-override-rundtur, gammel "scramble"-verdi korrekt avvist, test_isolation.sql 12/12, ekte typesjekket produksjonsbuild (samme Dockerfile som deployes).
2026-07-18 12:20:45 +02:00
|
|
|
|
Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.
To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?
Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
2026-07-18 12:44:05 +02:00
|
|
|
from .. import teeoff_client
|
Program-skjermen er bygget og grundig scratch-verifisert. Kort oppsummert:
Nytt:
app/routers/courses.py — enkel bane-CRUD (GET/POST /orgs/{id}/courses), fant og tettet et reelt hull: SessionCreate.course_id var påkrevd, men ingen vei fantes til å skaffe én
components/tournament-program.tsx + rute /tournaments/[id]/program — tidslinje over økter, opprett-skjema med bane-type-ahead og avanserte handicap-brytere
Fanerad lagt til i både roster- og program-skjermen så du kan bevege deg mellom dem
To reelle feil rettet før integrering:
V0-promptet mitt ba om ett generisk "Scramble"-format, men databasen/motoren krever scramble_2/scramble_4 som atskilte verdier — rettet til to segment-knapper
Verifiserte allowance_override-JSON-formen eksakt mot parse_allowance_config (typet combined/per_player + 0–1-brøk, ikke flat prosent) — bekreftet med en ekte rundtur i scratch, ikke bare lest fra koden
Verifisert: courses opprettet+listet, kryss-org-isolasjon, økt med klokkeslett, økt med scramble_4+full handicap-override-rundtur, gammel "scramble"-verdi korrekt avvist, test_isolation.sql 12/12, ekte typesjekket produksjonsbuild (samme Dockerfile som deployes).
2026-07-18 12:20:45 +02:00
|
|
|
from ..auth import get_authorized_org
|
|
|
|
|
from ..db import org_connection
|
Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.
To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?
Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
2026-07-18 12:44:05 +02:00
|
|
|
from ..errors import app_error, translate_db_errors
|
Program-skjermen er bygget og grundig scratch-verifisert. Kort oppsummert:
Nytt:
app/routers/courses.py — enkel bane-CRUD (GET/POST /orgs/{id}/courses), fant og tettet et reelt hull: SessionCreate.course_id var påkrevd, men ingen vei fantes til å skaffe én
components/tournament-program.tsx + rute /tournaments/[id]/program — tidslinje over økter, opprett-skjema med bane-type-ahead og avanserte handicap-brytere
Fanerad lagt til i både roster- og program-skjermen så du kan bevege deg mellom dem
To reelle feil rettet før integrering:
V0-promptet mitt ba om ett generisk "Scramble"-format, men databasen/motoren krever scramble_2/scramble_4 som atskilte verdier — rettet til to segment-knapper
Verifiserte allowance_override-JSON-formen eksakt mot parse_allowance_config (typet combined/per_player + 0–1-brøk, ikke flat prosent) — bekreftet med en ekte rundtur i scratch, ikke bare lest fra koden
Verifisert: courses opprettet+listet, kryss-org-isolasjon, økt med klokkeslett, økt med scramble_4+full handicap-override-rundtur, gammel "scramble"-verdi korrekt avvist, test_isolation.sql 12/12, ekte typesjekket produksjonsbuild (samme Dockerfile som deployes).
2026-07-18 12:20:45 +02:00
|
|
|
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))
|
Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.
To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?
Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
2026-07-18 12:44:05 +02:00
|
|
|
|
|
|
|
|
|
2026-07-18 17:15:50 +02:00
|
|
|
# --- 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"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.
To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?
Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
2026-07-18 12:44:05 +02:00
|
|
|
# --- 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))
|