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)
This commit is contained in:
parent
5e4e4af26c
commit
b28f604fe9
10 changed files with 595 additions and 10 deletions
|
|
@ -240,7 +240,14 @@
|
||||||
"Bash(cd /opt/teeoff/backend && ls -la; echo ---; find . -maxdepth 2 -type f | grep -v node_modules)",
|
"Bash(cd /opt/teeoff/backend && ls -la; echo ---; find . -maxdepth 2 -type f | grep -v node_modules)",
|
||||||
"Read(//opt/teeoff/backend/**)",
|
"Read(//opt/teeoff/backend/**)",
|
||||||
"Bash(cd /opt/teeoff/backend && grep -n \"app\\\\.\\\\\\(get\\\\|post\\\\|put\\\\|delete\\\\|patch\\\\\\)\\\\|@app\\\\.\\\\|APIRouter\\\\|router\\\\.\\\\\\(get\\\\|post\\\\\\)\" main.py | grep -i \"facilit\\\\|course\\\\|club\" )",
|
"Bash(cd /opt/teeoff/backend && grep -n \"app\\\\.\\\\\\(get\\\\|post\\\\|put\\\\|delete\\\\|patch\\\\\\)\\\\|@app\\\\.\\\\|APIRouter\\\\|router\\\\.\\\\\\(get\\\\|post\\\\\\)\" main.py | grep -i \"facilit\\\\|course\\\\|club\" )",
|
||||||
"Bash(cd /opt/teeoff && grep -n \"CREATE TABLE.*\\\\\\(courses\\\\|tees\\\\|holes\\\\|hole_lengths\\\\|facilities\\\\\\)\\\\b\" schema.sql init.sql 2>/dev/null)"
|
"Bash(cd /opt/teeoff && grep -n \"CREATE TABLE.*\\\\\\(courses\\\\|tees\\\\|holes\\\\|hole_lengths\\\\|facilities\\\\\\)\\\\b\" schema.sql init.sql 2>/dev/null)",
|
||||||
|
"Bash(awk 'NR>=4645 && NR<=4682' /opt/teeoff/backend/main.py)",
|
||||||
|
"Bash(python3 -m py_compile app/routers/courses.py app/teeoff_client.py app/config.py)",
|
||||||
|
"Bash(grep -n \"setSelectedCourseId\\(null\\)$\" /opt/teecup/frontend/components/tournament-program.tsx)",
|
||||||
|
"Bash(awk -F: '$1>=782 && $1<=980')",
|
||||||
|
"Bash(python3 -)",
|
||||||
|
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(len\\(d\\), 'facilities'\\); print\\(d[:2]\\)\")",
|
||||||
|
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/scratch_cookies2.txt)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
19
010_official_course_unique_ref.sql
Normal file
19
010_official_course_unique_ref.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — migrasjon 010
|
||||||
|
-- Offisiell banedata fra teeoff: hindre dupliserte importer (ADR-019)
|
||||||
|
-- =====================================================================
|
||||||
|
-- Kjøres etter 001-009. Additiv (partiell unik indeks, rører ingen
|
||||||
|
-- eksisterende rader -- alle course-rader har source='custom' i dag,
|
||||||
|
-- external_course_ref er alltid NULL, og NULL matcher aldri seg selv i en
|
||||||
|
-- UNIQUE-indeks).
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
-- Importerer en organisator samme teeoff-bane to ganger, skal den andre
|
||||||
|
-- importen avvises av den eksisterende DUPLICATE-feilkoden (409) --
|
||||||
|
-- ikke stille sette inn en ny rad med identisk banedata (ADR-019
|
||||||
|
-- Beslutning E).
|
||||||
|
CREATE UNIQUE INDEX course_org_external_ref_unique
|
||||||
|
ON course (organization_id, external_course_ref)
|
||||||
|
WHERE external_course_ref IS NOT NULL;
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
> Endre aldri en beslutning uten å legge til en ny ADR som erstatter den — historikken skal bevares.
|
> Endre aldri en beslutning uten å legge til en ny ADR som erstatter den — historikken skal bevares.
|
||||||
|
|
||||||
**Status:** Levende dokument
|
**Status:** Levende dokument
|
||||||
**Sist oppdatert:** 2026-07-16
|
**Sist oppdatert:** 2026-07-18
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -566,6 +566,83 @@ satt opp i praksis).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-019 — Offisiell banedata: import (kopi), ikke live oppslag
|
||||||
|
|
||||||
|
**Kontekst:** ADR-004 vedtok prinsippet (lesende API, ikke delt database) helt
|
||||||
|
i starten av prosjektet, men ble aldri bygget — kun `source='custom'`-baner
|
||||||
|
fantes (organisator taster inn banen selv). Brukeren påpekte dette rett etter
|
||||||
|
program-skjerm-runden (2026-07-18): det er en reell mangel at en organisator
|
||||||
|
må taste inn en bane manuelt når banen faktisk allerede finnes i teeoff.
|
||||||
|
|
||||||
|
**Kartlagt før noe ble besluttet** (lest `/opt/teeoff/backend/main.py`, kun
|
||||||
|
lesing): `GET /api/facilities/{slug}` er offentlig, uten auth/API-nøkkel, og
|
||||||
|
returnerer allerede `courses[]` med `holes[]` (`hole_number`, `par`,
|
||||||
|
`hcp_index`) og `tees[]` (`name`, `cr_men`/`slope_men`, `cr_women`/
|
||||||
|
`slope_women`) — nøyaktig det `hole`/`tee`/`tee_rating`-tabellene i
|
||||||
|
`teecup_db` (migrasjon 001) allerede modellerer. `GET /api/facilities?
|
||||||
|
view=search` gir en lettvekts liste (`id`, `slug`, `name`, `city`, `county`,
|
||||||
|
…) egnet for søk. Par/hcp_index kan være NULL i teeoffs skjema (ufullstendig
|
||||||
|
baneregistrering) — teecups `hole`-tabell krever begge NOT NULL.
|
||||||
|
|
||||||
|
**Beslutning A — Import ved organisators eksplisitte valg, ikke live
|
||||||
|
oppslag ved hver bruk.** Organisator søker blant teeoff sine baner, velger
|
||||||
|
én, og teecup kopierer da bane+hull+tee+tee_rating INN i `teecup_db` som en
|
||||||
|
vanlig `course`-rad med `source='official'` og
|
||||||
|
`external_course_ref = '{facility_slug}:{teeoff_course_id}'`. Etter import
|
||||||
|
er raden en helt vanlig lokal `course`-rad — økt-/match-/handicap-koden
|
||||||
|
(som allerede kun kjenner `course_id` som fremmednøkkel) trenger INGEN
|
||||||
|
endring.
|
||||||
|
|
||||||
|
**Begrunnelse:** Handicap-motoren joiner `tee_rating`/`hole` direkte i SQL
|
||||||
|
midt i en database-transaksjon (`app/handicap.py`) — et live HTTP-kall til
|
||||||
|
teeoff derfra ville krevd at hver matchberegning avhenger av teeoffs
|
||||||
|
oppetid, og ville ikke vært transaksjonssikkert. Import fryser dataene på
|
||||||
|
importtidspunktet — samme reproduserbarhets-prinsipp som handicap-
|
||||||
|
snapshotten i ADR-007 (`team_roster.handicap_index_snapshot`): endrer
|
||||||
|
teeoff en rating i etterkant, skal ikke en allerede opprettet turnering
|
||||||
|
plutselig regne annerledes. Dette er også det som gjør ADR-004s opprinnelige
|
||||||
|
"et brudd ett sted skal ikke ta ned begge produktene"-begrunnelse reell: en
|
||||||
|
teeoff-nedetid blokkerer kun NYE importer, ikke bruk av allerede importerte
|
||||||
|
baner.
|
||||||
|
|
||||||
|
**Beslutning B — Server-til-server, internt Docker-nettverk, ingen ny
|
||||||
|
hemmelighet.** `teecup_api` kaller `http://teeoff_api:8000` direkte (samme
|
||||||
|
`teeoff_default`-nettverk begge allerede deler) — ikke via Caddy/det
|
||||||
|
offentlige domenet. Teeoff sitt API har ingen auth-mekanisme på disse
|
||||||
|
endepunktene i det hele tatt, så ingen ny credential trengs. CORS-listen på
|
||||||
|
teeoff-siden (ekskluderer teecups origin) er irrelevant — CORS gjelder kun
|
||||||
|
nettleser-fetch, ikke et backend-til-backend-kall. Ny avhengighet: `httpx`
|
||||||
|
(async HTTP-klient), lagt til i `app/requirements.txt`.
|
||||||
|
|
||||||
|
**Beslutning C — Ufullstendige teeoff-data feiler importen tydelig, importen
|
||||||
|
skjer aldri delvis.** Mangler et hull `par`/`hcp_index`, eller mangler en
|
||||||
|
tee både `cr_men` OG `cr_women`, avvises HELE importen med en klar feil
|
||||||
|
(`code: EXTERNAL_DATA_INCOMPLETE`) FØR noe skrives — ikke en delvis
|
||||||
|
importert bane med hull som senere feiler i handicap-beregning. Hele
|
||||||
|
importen kjører i én DB-transaksjon (`org_connection` sin eksisterende
|
||||||
|
`conn.transaction()`), så en feil midtveis ruller automatisk tilbake.
|
||||||
|
|
||||||
|
**Beslutning D — Kun `full_18`-rating importeres.** Teeoff har ingen egen
|
||||||
|
front9/back9-rating i sitt skjema (kun én CR/slope per tee, kjønnsdelt).
|
||||||
|
Samme valg som ADR-008 allerede gjorde bevisst for egendefinerte baner: den
|
||||||
|
formelle WHS 9-hulls-metoden er ikke i bruk, 18-hulls-tildelingen med uttak
|
||||||
|
av spilte hull dekker front/back-økter.
|
||||||
|
|
||||||
|
**Beslutning E — Reimport av samme teeoff-bane er en feil, ikke en
|
||||||
|
duplikat-rad.** Ny partiell unik indeks `(organization_id,
|
||||||
|
external_course_ref) WHERE external_course_ref IS NOT NULL` (migrasjon
|
||||||
|
`010`) — importerer en organisator samme teeoff-bane to ganger, avvises det
|
||||||
|
med den eksisterende `DUPLICATE`-koden (409), ikke en ny rad med samme
|
||||||
|
banedata.
|
||||||
|
|
||||||
|
**Konsekvens:** `app/teeoff_client.py` (ny, ren HTTP-klient, ingen
|
||||||
|
db/RLS-avhengighet — samme isolasjonsprinsipp som `handicap_engine.py`).
|
||||||
|
`app/routers/courses.py` utvides med `GET .../courses/official-search` og
|
||||||
|
`POST .../courses/official-import`. Migrasjon `010_official_course_unique_
|
||||||
|
ref.sql`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Åpne spørsmål (ikke besluttet ennå)
|
## Åpne spørsmål (ikke besluttet ennå)
|
||||||
|
|
||||||
Disse må avklares før eller under de relevante fasene:
|
Disse må avklares før eller under de relevante fasene:
|
||||||
|
|
|
||||||
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -722,10 +722,18 @@ Ferdig og verifisert:
|
||||||
over er basert på å lese koden direkte. Stabil identifikator: `facilities.
|
over er basert på å lese koden direkte. Stabil identifikator: `facilities.
|
||||||
slug` (f.eks. `borregaard-golfklubb`) — selve banen har kun en intern
|
slug` (f.eks. `borregaard-golfklubb`) — selve banen har kun en intern
|
||||||
serial-id, ingen egen slug, så `course.external_course_ref` må bære
|
serial-id, ingen egen slug, så `course.external_course_ref` må bære
|
||||||
facility-slug + bane-id sammen. **Ikke scopet eller bygget ennå** — egen,
|
facility-slug + bane-id sammen.
|
||||||
senere avklaring med bruker om omfang (import-flyt vs. live-oppslag hver
|
**Oppdatering samme dag: brukeren ba meg ta fatt på dette NÅ** (bevisst
|
||||||
gang, hvor mye av bane-/tee-/hull-detaljene som skal kopieres inn i
|
sidesprang fra "bygg i rekkefølgen ting brukes"-planen — blind draw-
|
||||||
`teecup_db` vs. slås opp on-demand).
|
skjermen er fortsatt neste steg i den planen ETTERPÅ, ikke droppet).
|
||||||
|
Design besluttet og skrevet som **ADR-019** (se ARCHITECTURE_DECISIONS.md):
|
||||||
|
import (kopi) ved organisators eksplisitte valg, ikke live oppslag ved
|
||||||
|
hver bruk — fryser data på importtidspunktet, samme reproduserbarhets-
|
||||||
|
prinsipp som handicap-snapshotten (ADR-007). Server-til-server-kall
|
||||||
|
(`teecup_api` → `http://teeoff_api:8000`, internt Docker-nettverk, ingen
|
||||||
|
auth trengs). Ny migrasjon `010` (unik `external_course_ref` per org,
|
||||||
|
hindrer dupliserte importer). Bygges nå — se ADR-019 for alle fem
|
||||||
|
delbeslutningene.
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
1. Flere V0-skjermer (blind draw, scorekort, leaderboard) — samme mønster:
|
1. Flere V0-skjermer (blind draw, scorekort, leaderboard) — samme mønster:
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
| Dedikert app-rolle (ingen superuser/BYPASSRLS) | ✅ | migrasjon 002. |
|
| Dedikert app-rolle (ingen superuser/BYPASSRLS) | ✅ | migrasjon 002. |
|
||||||
| API: oppsett (spillere/lag/roster/økter/matcher/blind draw) | ✅ | Verifisert for ekte mot scratch-db + engangscontainer. |
|
| API: oppsett (spillere/lag/roster/økter/matcher/blind draw) | ✅ | Verifisert for ekte mot scratch-db + engangscontainer. |
|
||||||
| API: scoring (hole_score/match_hole_result, matchstatus, handicap-beregning) | ✅ | ADR-012/014. Verifisert for ekte, inkl. fourball better-ball og race-sikker recompute. |
|
| API: scoring (hole_score/match_hole_result, matchstatus, handicap-beregning) | ✅ | ADR-012/014. Verifisert for ekte, inkl. fourball better-ball og race-sikker recompute. |
|
||||||
| Banedata fra teeoff via API | 🔀 | ADR-004. Endret fra Geminis «delt database direkte». **Kartlagt 2026-07-18** (ikke bygget ennå): `GET /api/facilities/{slug}` i `/opt/teeoff/backend/main.py` er offentlig, uten auth, og har allerede alt vi trenger — navn, hull med par+hcp_index, tee-navn, course/slope rating per tee (kjønnsdelt). Ingen CORS/API-nøkkel-hinder siden dette blir et server-til-server-kall, ikke nettleser. Se program-skjerm-runden i CLAUDE.md-status for full kartlegging. |
|
| Banedata fra teeoff via API | 🔨 | ADR-004 → **ADR-019** (design besluttet 2026-07-18, bygges nå — sidesprang fra "bygg i rekkefølgen ting brukes", blind draw-skjermen er fortsatt neste steg etterpå). Import (kopi) ved eksplisitt organisator-valg, ikke live oppslag — se ADR-019 i ARCHITECTURE_DECISIONS.md for alle fem delbeslutningene og CLAUDE.md-status for kartleggingen av teeoff sitt `/api/facilities`-API. |
|
||||||
| Konfigurerbar handicap-pipeline (4 brytere) | ✅ | ADR-014. Bygget i `app/handicap.py`, brukt av scoring-runden. |
|
| Konfigurerbar handicap-pipeline (4 brytere) | ✅ | ADR-014. Bygget i `app/handicap.py`, brukt av scoring-runden. |
|
||||||
| Ekte autentisering (magic-link + JWT-sesjon) | ✅ | ADR-009. `app/routers/auth.py` + migrasjon `004_auth.sql`. `X-Debug-User-Id`-stubben er helt fjernet. |
|
| Ekte autentisering (magic-link + JWT-sesjon) | ✅ | ADR-009. `app/routers/auth.py` + migrasjon `004_auth.sql`. `X-Debug-User-Id`-stubben er helt fjernet. |
|
||||||
| RLS-tomstreng-fiks (`app_current_org()`) | ✅ | Migrasjon `005_rls_null_guard.sql`. Se detaljer under. |
|
| RLS-tomstreng-fiks (`app_current_org()`) | ✅ | Migrasjon `005_rls_null_guard.sql`. Se detaljer under. |
|
||||||
|
|
|
||||||
|
|
@ -83,5 +83,10 @@ class Settings:
|
||||||
MINIO_BUCKET: str = os.getenv("TEECUP_MINIO_BUCKET", "teecup-media")
|
MINIO_BUCKET: str = os.getenv("TEECUP_MINIO_BUCKET", "teecup-media")
|
||||||
MINIO_INTERNAL_ENDPOINT: str = os.getenv("TEECUP_MINIO_INTERNAL_ENDPOINT", "teecup-minio:9000")
|
MINIO_INTERNAL_ENDPOINT: str = os.getenv("TEECUP_MINIO_INTERNAL_ENDPOINT", "teecup-minio:9000")
|
||||||
|
|
||||||
|
# Offisiell banedata (ADR-019). Internt Docker-nettverk (teeoff_default,
|
||||||
|
# delt med teecup_api) -- ikke via Caddy/det offentlige domenet. Teeoffs
|
||||||
|
# facilities-API er offentlig uten auth, så ingen hemmelighet trengs her.
|
||||||
|
TEEOFF_API_BASE_URL: str = os.getenv("TEECUP_TEEOFF_API_BASE_URL", "http://teeoff_api:8000")
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,4 @@ minio>=7.2
|
||||||
pillow>=10.0
|
pillow>=10.0
|
||||||
pillow-avif-plugin>=1.4
|
pillow-avif-plugin>=1.4
|
||||||
python-multipart>=0.0.9
|
python-multipart>=0.0.9
|
||||||
|
httpx>=0.27
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,12 @@ ennå. Denne runden dekker KUN det organisatoren trenger for å opprette en
|
||||||
bruker foreløpig kun course_id som fremmednøkkel, ikke banens innhold).
|
bruker foreløpig kun course_id som fremmednøkkel, ikke banens innhold).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from .. import teeoff_client
|
||||||
from ..auth import get_authorized_org
|
from ..auth import get_authorized_org
|
||||||
from ..db import org_connection
|
from ..db import org_connection
|
||||||
from ..errors import translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -59,3 +60,182 @@ async def create_course(
|
||||||
body.name,
|
body.name,
|
||||||
)
|
)
|
||||||
return Course(**dict(row))
|
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))
|
||||||
|
|
|
||||||
58
app/teeoff_client.py
Normal file
58
app/teeoff_client.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""
|
||||||
|
Utgående, lesende HTTP-klient mot teeoff sitt offentlige facilities-API
|
||||||
|
(ADR-004/ADR-019). Ren HTTP-modul -- ingen db/RLS-avhengighet, samme
|
||||||
|
isolasjonsprinsipp som handicap_engine.py.
|
||||||
|
|
||||||
|
Server-til-server: teecup_api snakker med teeoff_api direkte over det delte
|
||||||
|
Docker-nettverket (teeoff_default), ikke via Caddy/det offentlige domenet.
|
||||||
|
Endepunktene (`/api/facilities`, `/api/facilities/{slug}`) er offentlige i
|
||||||
|
teeoff -- ingen auth-mekanisme finnes der, så ingen hemmelighet trengs her.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TeeoffUnavailableError(Exception):
|
||||||
|
"""Teeoff svarte ikke (nede, timeout, uventet statuskode)."""
|
||||||
|
|
||||||
|
|
||||||
|
class TeeoffNotFoundError(Exception):
|
||||||
|
"""Anlegget finnes ikke i teeoff (404)."""
|
||||||
|
|
||||||
|
|
||||||
|
async def search_facilities(query: str) -> list[dict]:
|
||||||
|
"""`?view=search` gir en lettvekts liste (id/slug/name/city/county/...)
|
||||||
|
egnet for søk -- filtrerer på navn her, teeoff har ingen egen
|
||||||
|
søkeparameter på dette endepunktet."""
|
||||||
|
url = f"{settings.TEEOFF_API_BASE_URL}/api/facilities"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
res = await client.get(url, params={"view": "search"})
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise TeeoffUnavailableError(str(e)) from e
|
||||||
|
if res.status_code != 200:
|
||||||
|
raise TeeoffUnavailableError(f"uventet status {res.status_code}")
|
||||||
|
|
||||||
|
facilities: list[dict] = res.json()
|
||||||
|
q = query.strip().lower()
|
||||||
|
if not q:
|
||||||
|
return facilities
|
||||||
|
return [f for f in facilities if q in (f.get("name") or "").lower()]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_facility(slug: str) -> dict:
|
||||||
|
url = f"{settings.TEEOFF_API_BASE_URL}/api/facilities/{slug}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
res = await client.get(url)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise TeeoffUnavailableError(str(e)) from e
|
||||||
|
if res.status_code == 404:
|
||||||
|
raise TeeoffNotFoundError(slug)
|
||||||
|
if res.status_code != 200:
|
||||||
|
raise TeeoffUnavailableError(f"uventet status {res.status_code}")
|
||||||
|
return res.json()
|
||||||
|
|
@ -277,7 +277,13 @@ export function TournamentProgram({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<CreateSessionCard nextSequence={nextSequence} courses={courses} onCreate={addSession} />
|
<CreateSessionCard
|
||||||
|
nextSequence={nextSequence}
|
||||||
|
courses={courses}
|
||||||
|
organizationId={organizationId}
|
||||||
|
onCourseImported={(course) => setCourses((prev) => [...prev, course])}
|
||||||
|
onCreate={addSession}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -381,10 +387,14 @@ function EmptyState() {
|
||||||
function CreateSessionCard({
|
function CreateSessionCard({
|
||||||
nextSequence,
|
nextSequence,
|
||||||
courses,
|
courses,
|
||||||
|
organizationId,
|
||||||
|
onCourseImported,
|
||||||
onCreate,
|
onCreate,
|
||||||
}: {
|
}: {
|
||||||
nextSequence: number
|
nextSequence: number
|
||||||
courses: ApiCourse[]
|
courses: ApiCourse[]
|
||||||
|
organizationId: string
|
||||||
|
onCourseImported: (course: ApiCourse) => void
|
||||||
onCreate: (input: {
|
onCreate: (input: {
|
||||||
name?: string
|
name?: string
|
||||||
format: SessionFormat
|
format: SessionFormat
|
||||||
|
|
@ -411,6 +421,7 @@ function CreateSessionCard({
|
||||||
|
|
||||||
const [courseQuery, setCourseQuery] = useState("")
|
const [courseQuery, setCourseQuery] = useState("")
|
||||||
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null)
|
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null)
|
||||||
|
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
|
||||||
|
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
const [useHandicap, setUseHandicap] = useState(true)
|
const [useHandicap, setUseHandicap] = useState(true)
|
||||||
|
|
@ -491,6 +502,7 @@ function CreateSessionCard({
|
||||||
setStartHole("1")
|
setStartHole("1")
|
||||||
setCourseQuery("")
|
setCourseQuery("")
|
||||||
setSelectedCourseId(null)
|
setSelectedCourseId(null)
|
||||||
|
setOfficialSearchOpen(false)
|
||||||
setAdvancedOpen(false)
|
setAdvancedOpen(false)
|
||||||
setUseHandicap(true)
|
setUseHandicap(true)
|
||||||
setUseCourseHandicap(true)
|
setUseCourseHandicap(true)
|
||||||
|
|
@ -603,6 +615,24 @@ function CreateSessionCard({
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOfficialSearchOpen(true)}
|
||||||
|
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Hent bane fra teeoff i stedet
|
||||||
|
</button>
|
||||||
|
{officialSearchOpen && (
|
||||||
|
<OfficialCourseSearch
|
||||||
|
organizationId={organizationId}
|
||||||
|
onClose={() => setOfficialSearchOpen(false)}
|
||||||
|
onImported={(course) => {
|
||||||
|
onCourseImported(course)
|
||||||
|
setSelectedCourseId(course.id)
|
||||||
|
setOfficialSearchOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -749,6 +779,206 @@ function CreateSessionCard({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Offisiell bane fra teeoff (ADR-019) ------------------------------------
|
||||||
|
|
||||||
|
type ApiOfficialFacility = {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
city: string | null
|
||||||
|
county: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiOfficialCourseOption = {
|
||||||
|
teeoff_course_id: number
|
||||||
|
name: string
|
||||||
|
is_main_course: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function OfficialCourseSearch({
|
||||||
|
organizationId,
|
||||||
|
onClose,
|
||||||
|
onImported,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
onClose: () => void
|
||||||
|
onImported: (course: ApiCourse) => void
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [facilities, setFacilities] = useState<ApiOfficialFacility[] | null>(null)
|
||||||
|
const [selectedFacility, setSelectedFacility] = useState<ApiOfficialFacility | null>(null)
|
||||||
|
const [courseOptions, setCourseOptions] = useState<ApiOfficialCourseOption[] | null>(null)
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function runSearch(e?: React.FormEvent) {
|
||||||
|
e?.preventDefault()
|
||||||
|
setSearching(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/orgs/${organizationId}/courses/official-search?q=${encodeURIComponent(query.trim())}`,
|
||||||
|
{ credentials: "include" },
|
||||||
|
)
|
||||||
|
if (!res.ok) throw new Error(`search: ${res.status}`)
|
||||||
|
setFacilities(await res.json())
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
|
||||||
|
setFacilities([])
|
||||||
|
} finally {
|
||||||
|
setSearching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFacility(facility: ApiOfficialFacility) {
|
||||||
|
setSelectedFacility(facility)
|
||||||
|
setError(null)
|
||||||
|
setCourseOptions(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/courses/official-search/${facility.slug}`, {
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`facility detail: ${res.status}`)
|
||||||
|
const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
|
||||||
|
setCourseOptions(detail.courses)
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å hente baner for dette anlegget.")
|
||||||
|
setCourseOptions([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importCourse(course: ApiOfficialCourseOption) {
|
||||||
|
if (!selectedFacility) return
|
||||||
|
setImporting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/courses/official-import`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
facility_slug: selectedFacility.slug,
|
||||||
|
teeoff_course_id: course.teeoff_course_id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null)
|
||||||
|
const code = body?.detail?.code
|
||||||
|
if (code === "EXTERNAL_DATA_INCOMPLETE") {
|
||||||
|
setError("Denne banen mangler nok data i teeoff til å importeres ennå.")
|
||||||
|
} else if (code === "DUPLICATE") {
|
||||||
|
setError("Denne banen er allerede importert til organisasjonen.")
|
||||||
|
} else {
|
||||||
|
setError("Klarte ikke å importere banen. Prøv igjen.")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onImported(await res.json())
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å importere banen. Prøv igjen.")
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-bold text-foreground">Hent bane fra teeoff</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="size-8 rounded-lg text-muted-foreground"
|
||||||
|
aria-label="Lukk"
|
||||||
|
>
|
||||||
|
<X aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
{!selectedFacility ? (
|
||||||
|
<>
|
||||||
|
<form onSubmit={runSearch} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Søk anleggsnavn…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="h-11 flex-1 rounded-xl text-base"
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={searching} className="h-11 shrink-0 rounded-xl font-bold">
|
||||||
|
{searching ? "Søker…" : "Søk"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{facilities && (
|
||||||
|
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
|
||||||
|
{facilities.map((f) => (
|
||||||
|
<li key={f.slug} className="border-b border-border last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => pickFacility(f)}
|
||||||
|
className="flex w-full flex-col px-4 py-2.5 text-left transition-colors hover:bg-accent/60"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-foreground">{f.name}</span>
|
||||||
|
{(f.city || f.county) && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{[f.city, f.county].filter(Boolean).join(", ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{facilities.length === 0 && (
|
||||||
|
<li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedFacility(null)
|
||||||
|
setCourseOptions(null)
|
||||||
|
}}
|
||||||
|
className="self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
← {selectedFacility.name}
|
||||||
|
</button>
|
||||||
|
{courseOptions === null ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Laster baner…</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
|
||||||
|
{courseOptions.map((c) => (
|
||||||
|
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={importing}
|
||||||
|
onClick={() => importCourse(c)}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
{importing && <span className="text-xs font-normal text-muted-foreground">Importerer…</span>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{courseOptions.length === 0 && (
|
||||||
|
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
|
||||||
|
Ingen 18-hulls baner å importere hos dette anlegget ennå.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Small UI helpers ------------------------------------------------------
|
// --- Small UI helpers ------------------------------------------------------
|
||||||
|
|
||||||
function SegmentButton({
|
function SegmentButton({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue