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)",
|
||||
"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 && 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": [
|
||||
"/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.
|
||||
|
||||
**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å)
|
||||
|
||||
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.
|
||||
slug` (f.eks. `borregaard-golfklubb`) — selve banen har kun en intern
|
||||
serial-id, ingen egen slug, så `course.external_course_ref` må bære
|
||||
facility-slug + bane-id sammen. **Ikke scopet eller bygget ennå** — egen,
|
||||
senere avklaring med bruker om omfang (import-flyt vs. live-oppslag hver
|
||||
gang, hvor mye av bane-/tee-/hull-detaljene som skal kopieres inn i
|
||||
`teecup_db` vs. slås opp on-demand).
|
||||
facility-slug + bane-id sammen.
|
||||
**Oppdatering samme dag: brukeren ba meg ta fatt på dette NÅ** (bevisst
|
||||
sidesprang fra "bygg i rekkefølgen ting brukes"-planen — blind draw-
|
||||
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:
|
||||
1. Flere V0-skjermer (blind draw, scorekort, leaderboard) — samme mønster:
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
| 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: 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. |
|
||||
| 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. |
|
||||
|
|
|
|||
|
|
@ -83,5 +83,10 @@ class Settings:
|
|||
MINIO_BUCKET: str = os.getenv("TEECUP_MINIO_BUCKET", "teecup-media")
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -7,3 +7,4 @@ minio>=7.2
|
|||
pillow>=10.0
|
||||
pillow-avif-plugin>=1.4
|
||||
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).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from .. import teeoff_client
|
||||
from ..auth import get_authorized_org
|
||||
from ..db import org_connection
|
||||
from ..errors import translate_db_errors
|
||||
from ..errors import app_error, translate_db_errors
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -59,3 +60,182 @@ async def create_course(
|
|||
body.name,
|
||||
)
|
||||
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">
|
||||
<CreateSessionCard nextSequence={nextSequence} courses={courses} onCreate={addSession} />
|
||||
<CreateSessionCard
|
||||
nextSequence={nextSequence}
|
||||
courses={courses}
|
||||
organizationId={organizationId}
|
||||
onCourseImported={(course) => setCourses((prev) => [...prev, course])}
|
||||
onCreate={addSession}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -381,10 +387,14 @@ function EmptyState() {
|
|||
function CreateSessionCard({
|
||||
nextSequence,
|
||||
courses,
|
||||
organizationId,
|
||||
onCourseImported,
|
||||
onCreate,
|
||||
}: {
|
||||
nextSequence: number
|
||||
courses: ApiCourse[]
|
||||
organizationId: string
|
||||
onCourseImported: (course: ApiCourse) => void
|
||||
onCreate: (input: {
|
||||
name?: string
|
||||
format: SessionFormat
|
||||
|
|
@ -411,6 +421,7 @@ function CreateSessionCard({
|
|||
|
||||
const [courseQuery, setCourseQuery] = useState("")
|
||||
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null)
|
||||
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
|
||||
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [useHandicap, setUseHandicap] = useState(true)
|
||||
|
|
@ -491,6 +502,7 @@ function CreateSessionCard({
|
|||
setStartHole("1")
|
||||
setCourseQuery("")
|
||||
setSelectedCourseId(null)
|
||||
setOfficialSearchOpen(false)
|
||||
setAdvancedOpen(false)
|
||||
setUseHandicap(true)
|
||||
setUseCourseHandicap(true)
|
||||
|
|
@ -603,6 +615,24 @@ function CreateSessionCard({
|
|||
</ul>
|
||||
</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>
|
||||
|
|
@ -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 ------------------------------------------------------
|
||||
|
||||
function SegmentButton({
|
||||
|
|
|
|||
Loading…
Reference in a new issue