Ny tredje banekilde (ADR-064) for baner utenfor TeeOffs dekning: delt GolfAPI-cache (migrasjon 065), import til både org-turneringer og frittstående runder, target-points-endepunkt, og en rangefinder- visning i scorekortet (V0-generert target-distance.tsx + hånd-koblet GPS/Haversine-wrapper). Fant og fikset at TEECUP_GOLFAPI_TOKEN aldri ble videreført til teecup_api-containeren i docker-compose.yml. I samme runde: y-akse-benevnelser og verdi i begge ender på statistikk-trendgrafene (rounds-stats-summary.tsx). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
85 lines
3.5 KiB
Python
85 lines
3.5 KiB
Python
"""
|
|
Utgående, lesende HTTP-klient mot GolfAPI.io (ADR-064) -- tredje banekilde
|
|
for baner utenfor TeeOffs dekning (f.eks. Tjøme Golfklubb, bekreftet IKKE
|
|
i TeeOff 2026-08-12). Ren HTTP-modul -- ingen db/RLS-avhengighet, samme
|
|
isolasjonsprinsipp som teeoff_client.py/handicap_engine.py.
|
|
|
|
ULIKT teeoff: GolfAPI-kall koster ekte, begrenset budsjett (kjøpt 20 kall,
|
|
~2/bane -- 1 for /courses/{id}, 1 for /coordinates/{id}, søk koster 0.1).
|
|
Denne modulen gjør RÅ HTTP-kall og returnerer JSON uendret -- selve
|
|
cache-sjekken ("har vi denne banen fra før?") ligger i
|
|
_get_or_fetch_golfapi_course() i courses.py, IKKE her, slik at denne
|
|
modulen forblir en ren, testbar transportlag uten db-avhengighet.
|
|
|
|
Alltid measureUnit=m i kallene -- unngår yard/meter-fellen research
|
|
advarte om (internasjonale kilder returnerer ofte yards som standard).
|
|
"""
|
|
|
|
import httpx
|
|
|
|
from .config import settings
|
|
|
|
_BASE_URL = "https://golfapi.io/api/v2.3"
|
|
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
|
|
|
|
|
class GolfApiNotConfiguredError(Exception):
|
|
"""TEECUP_GOLFAPI_TOKEN er ikke satt i .env."""
|
|
|
|
|
|
class GolfApiUnavailableError(Exception):
|
|
"""GolfAPI svarte ikke (nede, timeout, uventet statuskode)."""
|
|
|
|
|
|
class GolfApiNotFoundError(Exception):
|
|
"""Banen/klubben finnes ikke hos GolfAPI (404)."""
|
|
|
|
|
|
def _headers() -> dict[str, str]:
|
|
if not settings.GOLFAPI_TOKEN:
|
|
raise GolfApiNotConfiguredError()
|
|
return {"Authorization": f"Bearer {settings.GOLFAPI_TOKEN}"}
|
|
|
|
|
|
async def search_clubs(name: str | None = None, city: str | None = None, country: str | None = None) -> list[dict]:
|
|
"""Koster 0.1 API-kall. Returnerer rå `clubs`-array (hver med `courses[]`)."""
|
|
params = {k: v for k, v in {"name": name, "city": city, "country": country}.items() if v}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
|
res = await client.get(f"{_BASE_URL}/clubs", params=params, headers=_headers())
|
|
except httpx.HTTPError as e:
|
|
raise GolfApiUnavailableError(str(e)) from e
|
|
if res.status_code != 200:
|
|
raise GolfApiUnavailableError(f"uventet status {res.status_code}")
|
|
return res.json().get("clubs", [])
|
|
|
|
|
|
async def get_course(course_id: str) -> dict:
|
|
"""Koster 1 API-kall. Full scorekortdata: par/SI per hull, alle tees
|
|
med per-hull-lengde (meter) og rating/slope."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
|
res = await client.get(f"{_BASE_URL}/courses/{course_id}", params={"measureUnit": "m"}, headers=_headers())
|
|
except httpx.HTTPError as e:
|
|
raise GolfApiUnavailableError(str(e)) from e
|
|
if res.status_code == 404:
|
|
raise GolfApiNotFoundError(course_id)
|
|
if res.status_code != 200:
|
|
raise GolfApiUnavailableError(f"uventet status {res.status_code}")
|
|
return res.json()
|
|
|
|
|
|
async def get_coordinates(course_id: str) -> list[dict]:
|
|
"""Koster 1 API-kall. Rå `coordinates`-array (poi/location/sideFW/hole/
|
|
latitude/longitude, rå GolfAPI-tallkoder -- oversettes til TeeCups
|
|
tekst-enum i courses.py, ikke her)."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
|
res = await client.get(f"{_BASE_URL}/coordinates/{course_id}", headers=_headers())
|
|
except httpx.HTTPError as e:
|
|
raise GolfApiUnavailableError(str(e)) from e
|
|
if res.status_code == 404:
|
|
raise GolfApiNotFoundError(course_id)
|
|
if res.status_code != 200:
|
|
raise GolfApiUnavailableError(f"uventet status {res.status_code}")
|
|
return res.json().get("coordinates", [])
|