teecup/app/golfapi_client.py
Erol Haagenrud a9abe86116 Avstand til mål via GolfAPI.io (Tjøme) + statistikk-graf-finpuss
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>
2026-08-12 21:39:13 +02:00

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", [])