Rangefinder-koordinater for offisielle (TeeOff-koblede) baner
Rangefinder-funksjonalitet fantes til nå kun for GolfAPI-importerte personlige baner. Offisielle teeoff-koblede baner (org-turneringer OG frittstående runder spilt på en ekte teeoff-bane) hadde aldri noen koordinatkilde. Ny migrasjon 079: delte domener (course_poi_type/ location/side) for å unngå duplisert CHECK-vedlikehold på tvers av tabeller, ny global tabell teeoff_course_coordinate. Ny delt modul app/target_points.py + resolve_match_course_key() i hole_history.py (tredje søster til de to eksisterende bane-bro- funksjonene) -- alle tre kallesteder (frittstående runder, individuell org-turnering, lag-matchplay) bruker nå samme CourseKey-abstraksjon. rounds.py sitt eksisterende endepunkt refaktorert til samme mønster -- sideeffekt: frittstående runder på en ekte teeoff-bane får nå også rangefinder (var alltid tom liste før). Ny skrive-vei i courses.py (PUT/GET .../coordinates) -- generell, gjenbrukbar mulighet for å legge inn koordinater på enhver offisiell bane, ikke en engangsfiks. HoleTargetDistance generalisert (roundId -> baseUrl) og wiret inn i begge org-turnering-scoringsflytene. 11 nye tester (106/106 backend totalt), full scratch-verifisert ende-til-ende i nettleser i alle tre flyter inkl. lys/mørk og selvskjuling, regresjonssjekk av eksisterende GolfAPI-rangefinder bekreftet uendret oppførsel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
f2ae68b30d
commit
93af7c5bef
12 changed files with 685 additions and 50 deletions
76
079_teeoff_course_coordinates.sql
Normal file
76
079_teeoff_course_coordinates.sql
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — migrasjon 079
|
||||||
|
-- Rangefinder-koordinater for OFFISIELLE (TeeOff-koblede) baner
|
||||||
|
-- =====================================================================
|
||||||
|
-- Frem til nå har rangefinder-koordinater (golfapi_course_coordinate,
|
||||||
|
-- 065/067/078) KUN eksistert for GolfAPI-importerte baner. Offisielle
|
||||||
|
-- baner (course.source = 'official', koblet til den ekte teeoff-
|
||||||
|
-- databasen via course.external_course_ref = "{facility_slug}:
|
||||||
|
-- {teeoff_course_id}") har ALDRI hatt noen koordinatkilde -- verken for
|
||||||
|
-- org-turneringer eller for frittstående runder spilt på en ekte
|
||||||
|
-- teeoff-bane (round.course_source = 'teeoff'). Konkret anledning:
|
||||||
|
-- bruker ba om at Tjøme Golfklubb sin OFFISIELLE bane (brukt av
|
||||||
|
-- organisasjonen "Tjøme Gents") skal få samme feltbefarte koordinater
|
||||||
|
-- som nettopp ble lagt inn på Erol sin PERSONLIGE GolfAPI-bane for
|
||||||
|
-- Tjøme -- og at dette skal være en generell, gjenbrukbar mulighet for
|
||||||
|
-- ALLE offisielle baner fremover, ikke en engangsfiks.
|
||||||
|
--
|
||||||
|
-- DEL 1 -- delte domener for poi_type/location/side_fairway. To runder
|
||||||
|
-- på rad (067: rock/layup, 078: landmark) måtte hver for seg utvide
|
||||||
|
-- DEN SAMME CHECK-constraint-listen. Å innføre en ny tabell med en
|
||||||
|
-- IDENTISK, dupliserende CHECK-liste ville gjort dette vedlikeholds-
|
||||||
|
-- problemet permanent (to steder å huske å endre, garantert å skli).
|
||||||
|
-- Domenene gir én kilde til sannhet -- fremtidige utvidelser endrer
|
||||||
|
-- domenet ett sted, brukt av begge tabeller.
|
||||||
|
--
|
||||||
|
-- DEL 2 -- teeoff_course_coordinate, samme kolonneform som
|
||||||
|
-- golfapi_course_coordinate, men nøkkel er course.external_course_ref
|
||||||
|
-- (tekst, IKKE en FK -- speiler at external_course_ref selv er en myk
|
||||||
|
-- referanse, ingen global "teeoff_course"-tabell finnes å FK-e mot).
|
||||||
|
-- GLOBAL (ikke org-scopet, ingen RLS) -- samme begrunnelse som
|
||||||
|
-- golfapi_course_coordinate: flere organisasjoner kan importere samme
|
||||||
|
-- fysiske bane fra teeoff hver sin course-rad, koordinatene skal deles,
|
||||||
|
-- ikke registreres på nytt per organisasjon.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
-- --- Del 1: delte domener -------------------------------------------
|
||||||
|
|
||||||
|
CREATE DOMAIN course_poi_type AS text CHECK (VALUE IN (
|
||||||
|
'green', 'green_bunker', 'fairway_bunker', 'water',
|
||||||
|
'trees', 'marker_100', 'marker_150', 'marker_200',
|
||||||
|
'dogleg', 'road', 'tee_front', 'tee_back',
|
||||||
|
'rock', 'layup', 'landmark'
|
||||||
|
));
|
||||||
|
|
||||||
|
CREATE DOMAIN course_poi_location AS text CHECK (VALUE IN ('front', 'middle', 'back'));
|
||||||
|
|
||||||
|
CREATE DOMAIN course_poi_side AS text CHECK (VALUE IN ('left', 'center', 'right'));
|
||||||
|
|
||||||
|
ALTER TABLE golfapi_course_coordinate
|
||||||
|
DROP CONSTRAINT golfapi_course_coordinate_poi_type_check,
|
||||||
|
DROP CONSTRAINT golfapi_course_coordinate_location_check,
|
||||||
|
DROP CONSTRAINT golfapi_course_coordinate_side_fairway_check;
|
||||||
|
|
||||||
|
ALTER TABLE golfapi_course_coordinate
|
||||||
|
ALTER COLUMN poi_type TYPE course_poi_type USING poi_type::text::course_poi_type,
|
||||||
|
ALTER COLUMN location TYPE course_poi_location USING location::text::course_poi_location,
|
||||||
|
ALTER COLUMN side_fairway TYPE course_poi_side USING side_fairway::text::course_poi_side;
|
||||||
|
|
||||||
|
-- --- Del 2: teeoff_course_coordinate ---------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE teeoff_course_coordinate (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
external_course_ref text NOT NULL,
|
||||||
|
hole_number smallint NOT NULL CHECK (hole_number >= 1 AND hole_number <= 18),
|
||||||
|
poi_type course_poi_type NOT NULL,
|
||||||
|
location course_poi_location,
|
||||||
|
side_fairway course_poi_side,
|
||||||
|
latitude double precision NOT NULL CHECK (latitude >= -90 AND latitude <= 90),
|
||||||
|
longitude double precision NOT NULL CHECK (longitude >= -180 AND longitude <= 180)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ON teeoff_course_coordinate (external_course_ref, hole_number);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON teeoff_course_coordinate TO teecup_app;
|
||||||
|
|
@ -25,6 +25,13 @@ Custom personal_course (external_golfapi_course_id IS NULL) og custom
|
||||||
course (source = 'custom') har INGEN bro her -- ekskluderes (resolve-
|
course (source = 'custom') har INGEN bro her -- ekskluderes (resolve-
|
||||||
funksjonene returnerer None, kallerne skjuler da historikk-panelet).
|
funksjonene returnerer None, kallerne skjuler da historikk-panelet).
|
||||||
|
|
||||||
|
`CourseKey`/de tre `resolve_*_course_key()`-funksjonene gjenbrukes også av
|
||||||
|
`app/target_points.py` (migrasjon 079, rangefinder for offisielle baner)
|
||||||
|
-- samme bane-bro, annet formål (koordinater, ikke historikk). Tredje
|
||||||
|
søster `resolve_match_course_key()` (lag-matchplay: match->session->
|
||||||
|
course) har INGEN egen hole-history-bruker ennå (kun ADR-072s to
|
||||||
|
flyter), men følger identisk mønster for symmetriens skyld.
|
||||||
|
|
||||||
GIR/fairway-formlene speiler round-stats.tsx/rounds.py sin ETABLERTE
|
GIR/fairway-formlene speiler round-stats.tsx/rounds.py sin ETABLERTE
|
||||||
`score - putts <= par - 2`-definisjon (IKKE skjema-kommentaren i
|
`score - putts <= par - 2`-definisjon (IKKE skjema-kommentaren i
|
||||||
020_personal_rounds.sql, som beskriver en avvikende, ubrukt formel --
|
020_personal_rounds.sql, som beskriver en avvikende, ubrukt formel --
|
||||||
|
|
@ -84,6 +91,25 @@ async def resolve_tournament_round_course_key(conn: Connection, tournament_round
|
||||||
return ("golfapi", row["external_course_ref"])
|
return ("golfapi", row["external_course_ref"])
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_match_course_key(conn: Connection, match_id: str) -> CourseKey | None:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT c.source, c.external_course_ref
|
||||||
|
FROM match m
|
||||||
|
JOIN session s ON s.id = m.session_id
|
||||||
|
JOIN course c ON c.id = s.course_id
|
||||||
|
WHERE m.id = $1
|
||||||
|
""",
|
||||||
|
match_id,
|
||||||
|
)
|
||||||
|
if row is None or row["source"] == "custom" or row["external_course_ref"] is None:
|
||||||
|
return None
|
||||||
|
if row["source"] == "official":
|
||||||
|
facility_slug, _, teeoff_course_id = row["external_course_ref"].partition(":")
|
||||||
|
return ("teeoff", facility_slug, teeoff_course_id)
|
||||||
|
return ("golfapi", row["external_course_ref"])
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class HoleInstance:
|
class HoleInstance:
|
||||||
source: Literal["personal", "tournament"]
|
source: Literal["personal", "tournament"]
|
||||||
|
|
|
||||||
|
|
@ -611,6 +611,104 @@ async def import_official_course(
|
||||||
return Course(**dict(course_row))
|
return Course(**dict(course_row))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Rangefinder-koordinater for offisielle baner (migrasjon 079) -----------
|
||||||
|
#
|
||||||
|
# Frem til nå har rangefinder-koordinater (grønn/hindringer, GPS-basert) KUN
|
||||||
|
# eksistert for GolfAPI-importerte baner. Offisielle (teeoff-koblede) baner
|
||||||
|
# har ALDRI hatt noen koordinatkilde -- teeoff selv har ingen slik data, og
|
||||||
|
# det finnes ingen automatisk importvei (i motsetning til GolfAPI). Dette er
|
||||||
|
# derfor en RENT MANUELL skrive-vei: selve tolkningsarbeidet (fritekst-navn
|
||||||
|
# på feltbefarte punkter -> poi_type/location/side_fairway) krever
|
||||||
|
# dømmekraft og skjer FØR kallet hit, ikke automatisert her (bekreftet
|
||||||
|
# konkret av Tjøme-runden -- "Voll"/"Bjella" kunne ikke vært gjettet av et
|
||||||
|
# script). Full erstatning (ikke delta) -- speiler hvordan koordinatsett
|
||||||
|
# faktisk oppstår i praksis (en ny feltbefaring erstatter den forrige, ikke
|
||||||
|
# et lite tillegg til den). Lagres GLOBALT (teeoff_course_coordinate, nøkkel
|
||||||
|
# course.external_course_ref) -- IKKE per organisasjon, samme begrunnelse
|
||||||
|
# som golfapi_course_coordinate: flere organisasjoner kan importere samme
|
||||||
|
# fysiske bane fra teeoff hver sin course-rad, koordinatene skal deles.
|
||||||
|
|
||||||
|
|
||||||
|
class CoursePointIn(BaseModel):
|
||||||
|
hole_number: int = Field(ge=1, le=18)
|
||||||
|
poi_type: str
|
||||||
|
location: str | None = None
|
||||||
|
side_fairway: str | None = None
|
||||||
|
latitude: float = Field(ge=-90, le=90)
|
||||||
|
longitude: float = Field(ge=-180, le=180)
|
||||||
|
|
||||||
|
|
||||||
|
class CoursePointOut(BaseModel):
|
||||||
|
hole_number: int
|
||||||
|
poi_type: str
|
||||||
|
location: str | None
|
||||||
|
side_fairway: str | None
|
||||||
|
latitude: float
|
||||||
|
longitude: float
|
||||||
|
|
||||||
|
|
||||||
|
_COORDINATE_COLUMNS = "hole_number, poi_type, location, side_fairway, latitude, longitude"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/orgs/{organization_id}/courses/{course_id}/coordinates", response_model=list[CoursePointOut])
|
||||||
|
async def list_course_coordinates(
|
||||||
|
course_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> list[CoursePointOut]:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
course_row = await conn.fetchrow("SELECT source, external_course_ref FROM course WHERE id = $1", course_id)
|
||||||
|
if course_row is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Banen finnes ikke.")
|
||||||
|
if course_row["source"] != "official" or course_row["external_course_ref"] is None:
|
||||||
|
return []
|
||||||
|
rows = await conn.fetch(
|
||||||
|
f"""
|
||||||
|
SELECT {_COORDINATE_COLUMNS} FROM teeoff_course_coordinate
|
||||||
|
WHERE external_course_ref = $1 ORDER BY hole_number
|
||||||
|
""",
|
||||||
|
course_row["external_course_ref"],
|
||||||
|
)
|
||||||
|
return [CoursePointOut(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/orgs/{organization_id}/courses/{course_id}/coordinates", response_model=list[CoursePointOut])
|
||||||
|
async def replace_course_coordinates(
|
||||||
|
course_id: str,
|
||||||
|
body: list[CoursePointIn],
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> list[CoursePointOut]:
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
course_row = await conn.fetchrow("SELECT source, external_course_ref FROM course WHERE id = $1", course_id)
|
||||||
|
if course_row is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Banen finnes ikke.")
|
||||||
|
if course_row["source"] != "official":
|
||||||
|
raise app_error(
|
||||||
|
400,
|
||||||
|
"VALIDATION_FAILED",
|
||||||
|
"Kun offisielle (TeeOff-koblede) baner kan få rangefinder-koordinater satt manuelt.",
|
||||||
|
)
|
||||||
|
external_ref = course_row["external_course_ref"]
|
||||||
|
|
||||||
|
await conn.execute("DELETE FROM teeoff_course_coordinate WHERE external_course_ref = $1", external_ref)
|
||||||
|
for p in body:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO teeoff_course_coordinate
|
||||||
|
(external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
""",
|
||||||
|
external_ref, p.hole_number, p.poi_type, p.location, p.side_fairway, p.latitude, p.longitude,
|
||||||
|
)
|
||||||
|
rows = await conn.fetch(
|
||||||
|
f"""
|
||||||
|
SELECT {_COORDINATE_COLUMNS} FROM teeoff_course_coordinate
|
||||||
|
WHERE external_course_ref = $1 ORDER BY hole_number
|
||||||
|
""",
|
||||||
|
external_ref,
|
||||||
|
)
|
||||||
|
return [CoursePointOut(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
# --- Internasjonal banedata fra GolfAPI.io (ADR-064) -------------------------
|
# --- Internasjonal banedata fra GolfAPI.io (ADR-064) -------------------------
|
||||||
#
|
#
|
||||||
# Tredje banekilde, for baner utenfor TeeOffs dekning -- Tjøme Golfklubb var
|
# Tredje banekilde, for baner utenfor TeeOffs dekning -- Tjøme Golfklubb var
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ from ..hole_history import (
|
||||||
hole_history_for_user,
|
hole_history_for_user,
|
||||||
resolve_tournament_round_course_key,
|
resolve_tournament_round_course_key,
|
||||||
)
|
)
|
||||||
|
from ..target_points import TargetPoint, get_target_points
|
||||||
from ..team_authz import user_is_own_tournament_participant
|
from ..team_authz import user_is_own_tournament_participant
|
||||||
from .scoring import played_hole_numbers
|
from .scoring import played_hole_numbers
|
||||||
|
|
||||||
|
|
@ -1107,6 +1108,32 @@ async def get_round_participant_hole_history(
|
||||||
return await hole_history_for_user(target_user_id, key, hole_number)
|
return await hole_history_for_user(target_user_id, key, hole_number)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
||||||
|
"/holes/{hole_number}/target-points",
|
||||||
|
response_model=list[TargetPoint],
|
||||||
|
)
|
||||||
|
async def get_tournament_round_hole_target_points(
|
||||||
|
tournament_id: str,
|
||||||
|
round_id: str,
|
||||||
|
hole_number: int,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> list[TargetPoint]:
|
||||||
|
"""Rangefinder-data for hullet (migrasjon 079) -- samme kilde/prinsipp
|
||||||
|
som frittstående runders `GET /rounds/{id}/holes/{n}/target-points`,
|
||||||
|
her for en org-turnering-runde (individuell slagspill). Tom liste for
|
||||||
|
håndlagde baner, rangefinder-UI-et skjules da bare."""
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
exists = await conn.fetchval(
|
||||||
|
"SELECT id FROM tournament_round WHERE id = $1 AND tournament_id = $2", round_id, tournament_id
|
||||||
|
)
|
||||||
|
if exists is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
||||||
|
course_key = await resolve_tournament_round_course_key(conn, round_id)
|
||||||
|
points = await get_target_points(conn, course_key, hole_number)
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
||||||
"/participants/{round_participant_id}/holes",
|
"/participants/{round_participant_id}/holes",
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ from ..hole_history import (
|
||||||
resolve_personal_round_course_key,
|
resolve_personal_round_course_key,
|
||||||
)
|
)
|
||||||
from ..realtime import broadcast_round_update, live_sockets_for_round
|
from ..realtime import broadcast_round_update, live_sockets_for_round
|
||||||
|
from ..target_points import TargetPoint, get_target_points
|
||||||
from .auth import _hash_secret
|
from .auth import _hash_secret
|
||||||
from .notifications import create_notification
|
from .notifications import create_notification
|
||||||
from handicap_engine import (
|
from handicap_engine import (
|
||||||
|
|
@ -3148,54 +3149,25 @@ async def get_round_tee_options(round_id: str, user: CurrentUser = Depends(get_c
|
||||||
return resolved.tee_options()
|
return resolved.tee_options()
|
||||||
|
|
||||||
|
|
||||||
class TargetPoint(BaseModel):
|
|
||||||
poi_type: str
|
|
||||||
location: str | None
|
|
||||||
side_fairway: str | None
|
|
||||||
latitude: float
|
|
||||||
longitude: float
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/rounds/{round_id}/holes/{hole_number}/target-points", response_model=list[TargetPoint])
|
@router.get("/rounds/{round_id}/holes/{hole_number}/target-points", response_model=list[TargetPoint])
|
||||||
async def get_round_hole_target_points(
|
async def get_round_hole_target_points(
|
||||||
round_id: str,
|
round_id: str,
|
||||||
hole_number: int = Path(ge=1, le=18),
|
hole_number: int = Path(ge=1, le=18),
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> list[TargetPoint]:
|
) -> list[TargetPoint]:
|
||||||
"""Rangefinder-data for hullet (ADR-064) -- grønn front/midt/bak,
|
"""Rangefinder-data for hullet (ADR-064, utvidet til ekte teeoff-baner
|
||||||
hindringer, tee-punkter fra GolfAPI. KUN for baner importert derfra
|
i migrasjon 079) -- grønn front/midt/bak, hindringer, tee-punkter.
|
||||||
(`personal_course.external_golfapi_course_id` satt) -- returnerer tom
|
Returnerer tom liste for håndlagde baner (ingen koordinatkilde),
|
||||||
liste ellers (TeeOff-baner/manuelt opprettede baner har ingen
|
rangefinder-UI-et skjules da bare, resten av scorekortet upåvirket.
|
||||||
koordinatdata), rangefinder-UI-et skjules da bare, resten av
|
Returnerer RÅ punkter, ALDRI en ferdigregnet avstand -- den ville vært
|
||||||
scorekortet upåvirket. Returnerer RÅ punkter, ALDRI en ferdigregnet
|
utdatert idet spilleren beveger seg. Klienten Haversine-regner selv
|
||||||
avstand -- den ville vært utdatert idet spilleren beveger seg. Klienten
|
(frontend/lib/geo.ts, samme funksjon som ADR-048) mot spillerens EGEN,
|
||||||
Haversine-regner selv (frontend/lib/geo.ts, samme funksjon som
|
ferske GPS-posisjon."""
|
||||||
ADR-048) mot spillerens EGEN, ferske GPS-posisjon."""
|
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||||
round_row = await conn.fetchrow(
|
course_key = await resolve_personal_round_course_key(conn, round_id)
|
||||||
"SELECT course_source, personal_course_id FROM round WHERE id = $1", round_id
|
points = await get_target_points(conn, course_key, hole_number)
|
||||||
)
|
return points
|
||||||
if round_row is None:
|
|
||||||
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
||||||
if round_row["course_source"] != "custom":
|
|
||||||
return []
|
|
||||||
golfapi_course_id = await conn.fetchval(
|
|
||||||
"SELECT external_golfapi_course_id FROM personal_course WHERE id = $1",
|
|
||||||
round_row["personal_course_id"],
|
|
||||||
)
|
|
||||||
if golfapi_course_id is None:
|
|
||||||
return []
|
|
||||||
rows = await conn.fetch(
|
|
||||||
"""
|
|
||||||
SELECT poi_type, location, side_fairway, latitude, longitude
|
|
||||||
FROM golfapi_course_coordinate
|
|
||||||
WHERE golfapi_course_id = $1 AND hole_number = $2
|
|
||||||
""",
|
|
||||||
golfapi_course_id,
|
|
||||||
hole_number,
|
|
||||||
)
|
|
||||||
return [TargetPoint(**dict(r)) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
class ParticipantUpdate(BaseModel):
|
class ParticipantUpdate(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ from ..db import org_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from ..handicap import parse_allowance_config, relative_strokes_for_match
|
from ..handicap import parse_allowance_config, relative_strokes_for_match
|
||||||
from ..realtime import broadcast_live_update
|
from ..realtime import broadcast_live_update
|
||||||
|
from ..hole_history import resolve_match_course_key
|
||||||
|
from ..target_points import TargetPoint, get_target_points
|
||||||
from ..team_authz import (
|
from ..team_authz import (
|
||||||
is_org_member,
|
is_org_member,
|
||||||
user_is_match_participant,
|
user_is_match_participant,
|
||||||
|
|
@ -852,6 +854,39 @@ async def get_scorecard(
|
||||||
return await fetch_scorecard(conn, match_id)
|
return await fetch_scorecard(conn, match_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/orgs/{organization_id}/matches/{match_id}/holes/{hole_number}/target-points",
|
||||||
|
response_model=list[TargetPoint],
|
||||||
|
)
|
||||||
|
async def get_match_hole_target_points(
|
||||||
|
match_id: str,
|
||||||
|
hole_number: int,
|
||||||
|
organization_id: str,
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> list[TargetPoint]:
|
||||||
|
"""Rangefinder-data for hullet (migrasjon 079) -- samme kilde/prinsipp
|
||||||
|
som frittstående runders og individuell-turnerings target-points, her
|
||||||
|
for lag-matchplay (Ryder Cup-format). Samme tilgangsnivå/-mønster som
|
||||||
|
`get_scorecard` over (org-medlem ELLER deltaker i turneringen)."""
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
tournament_id = await conn.fetchval(
|
||||||
|
"""
|
||||||
|
SELECT s.tournament_id::text FROM match m JOIN session s ON s.id = m.session_id
|
||||||
|
WHERE m.id = $1
|
||||||
|
""",
|
||||||
|
match_id,
|
||||||
|
)
|
||||||
|
if tournament_id is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
|
||||||
|
if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
|
||||||
|
conn, organization_id, tournament_id, user.user_id
|
||||||
|
):
|
||||||
|
raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
|
||||||
|
course_key = await resolve_match_course_key(conn, match_id)
|
||||||
|
points = await get_target_points(conn, course_key, hole_number)
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Nassau Match Play (2026-07-30) -- INGEN ny motor, INGEN lagring. Tre
|
# Nassau Match Play (2026-07-30) -- INGEN ny motor, INGEN lagring. Tre
|
||||||
# parallelle vinduer (hull 1-9, 10-18, 1-18) av EKSISTERENDE hull-for-hull-
|
# parallelle vinduer (hull 1-9, 10-18, 1-18) av EKSISTERENDE hull-for-hull-
|
||||||
|
|
|
||||||
60
app/target_points.py
Normal file
60
app/target_points.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""
|
||||||
|
Rangefinder-koordinater (migrasjon 065/079) -- avstand til green/hindringer,
|
||||||
|
GPS-basert. Delt mellom rounds.py (frittstående runder), individual_
|
||||||
|
tournaments.py (org-turnering, individuell slagspill) og scoring.py
|
||||||
|
(org-turnering, lag-matchplay) -- alle tre løser sin egen kontekst om til
|
||||||
|
samme `CourseKey` (se hole_history.py, som denne modulen gjenbruker banen-
|
||||||
|
bro-abstraksjonen fra) og kaller `get_target_points()` her.
|
||||||
|
|
||||||
|
To kildetabeller, ingen RLS på noen av dem (globale, delt på tvers av
|
||||||
|
organisasjoner/brukere som tilfeldigvis bruker samme fysiske bane -- samme
|
||||||
|
begrunnelse som golfapi_course_coordinate alltid har hatt): `golfapi_
|
||||||
|
course_coordinate` (GolfAPI-importerte baner) og `teeoff_course_coordinate`
|
||||||
|
(ekte teeoff-baner, migrasjon 079). Fordi ingen av dem er RLS-beskyttet,
|
||||||
|
virker `get_target_points()` uendret uansett om `conn` kommer fra
|
||||||
|
`plain_connection()` eller `org_connection()`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from asyncpg import Connection
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .hole_history import CourseKey
|
||||||
|
|
||||||
|
|
||||||
|
class TargetPoint(BaseModel):
|
||||||
|
poi_type: str
|
||||||
|
location: str | None
|
||||||
|
side_fairway: str | None
|
||||||
|
latitude: float
|
||||||
|
longitude: float
|
||||||
|
|
||||||
|
|
||||||
|
async def get_target_points(conn: Connection, course_key: CourseKey | None, hole_number: int) -> list[TargetPoint]:
|
||||||
|
if course_key is None:
|
||||||
|
return []
|
||||||
|
kind = course_key[0]
|
||||||
|
if kind == "teeoff":
|
||||||
|
_, facility_slug, teeoff_course_id = course_key
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT poi_type, location, side_fairway, latitude, longitude
|
||||||
|
FROM teeoff_course_coordinate
|
||||||
|
WHERE external_course_ref = $1 AND hole_number = $2
|
||||||
|
""",
|
||||||
|
f"{facility_slug}:{teeoff_course_id}",
|
||||||
|
hole_number,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_, golfapi_course_id = course_key
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT poi_type, location, side_fairway, latitude, longitude
|
||||||
|
FROM golfapi_course_coordinate
|
||||||
|
WHERE golfapi_course_id = $1 AND hole_number = $2
|
||||||
|
""",
|
||||||
|
golfapi_course_id,
|
||||||
|
hole_number,
|
||||||
|
)
|
||||||
|
return [TargetPoint(**dict(r)) for r in rows]
|
||||||
|
|
@ -17,6 +17,13 @@ import { haversineMeters, type LatLng } from "@/lib/geo"
|
||||||
// 2026-08-12): brukeren pekte ut hovedvisningen som riktig sted og ba
|
// 2026-08-12): brukeren pekte ut hovedvisningen som riktig sted og ba
|
||||||
// om mer bredde enn den opprinnelige kompaktvarianten, 2026-08-13.
|
// om mer bredde enn den opprinnelige kompaktvarianten, 2026-08-13.
|
||||||
//
|
//
|
||||||
|
// `baseUrl` (ikke `roundId`) siden migrasjon 079 -- komponenten gjenbrukes
|
||||||
|
// nå av tre kallesteder med tre ulike URL-familier (frittstående runde
|
||||||
|
// `/rounds/{id}`, individuell org-turnering `/orgs/{org}/tournaments/{t}/
|
||||||
|
// rounds/{r}`, lag-matchplay `/orgs/{org}/matches/{m}`) -- kalleren bygger
|
||||||
|
// selv `${baseUrl}/holes/{n}/target-points`, komponenten trenger ikke vite
|
||||||
|
// hvilken flyt den sitter i.
|
||||||
|
//
|
||||||
// Strømsparing (2026-08-13, brukerrapport om høyt batteriforbruk):
|
// Strømsparing (2026-08-13, brukerrapport om høyt batteriforbruk):
|
||||||
// (1) `maximumAge` satt til 4000ms (var 1000ms) -- en golfer beveger seg
|
// (1) `maximumAge` satt til 4000ms (var 1000ms) -- en golfer beveger seg
|
||||||
// ~1,5 m/s, oppdatering hvert 4. sekund er umerkelig i praksis men ber
|
// ~1,5 m/s, oppdatering hvert 4. sekund er umerkelig i praksis men ber
|
||||||
|
|
@ -44,12 +51,12 @@ const _HAZARD_LABELS: Record<string, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HoleTargetDistance({
|
export function HoleTargetDistance({
|
||||||
roundId,
|
baseUrl,
|
||||||
holeNumber,
|
holeNumber,
|
||||||
size = "compact",
|
size = "compact",
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
roundId: string
|
baseUrl: string
|
||||||
holeNumber: number
|
holeNumber: number
|
||||||
size?: "compact" | "full"
|
size?: "compact" | "full"
|
||||||
className?: string
|
className?: string
|
||||||
|
|
@ -61,7 +68,7 @@ export function HoleTargetDistance({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setPoints(null)
|
setPoints(null)
|
||||||
fetch(`/rounds/${roundId}/holes/${holeNumber}/target-points`, { credentials: "include" })
|
fetch(`${baseUrl}/holes/${holeNumber}/target-points`, { credentials: "include" })
|
||||||
.then((res) => (res.ok ? res.json() : []))
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
.then((data: ApiTargetPoint[]) => {
|
.then((data: ApiTargetPoint[]) => {
|
||||||
if (!cancelled) setPoints(data)
|
if (!cancelled) setPoints(data)
|
||||||
|
|
@ -72,7 +79,7 @@ export function HoleTargetDistance({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [roundId, holeNumber])
|
}, [baseUrl, holeNumber])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Ingen vits å spore GPS når banen uansett ikke har koordinatdata for
|
// Ingen vits å spore GPS når banen uansett ikke har koordinatdata for
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ import {
|
||||||
WizardSection,
|
WizardSection,
|
||||||
} from "@/components/hole-stat-inputs"
|
} from "@/components/hole-stat-inputs"
|
||||||
import { HoleHistoryDetail } from "@/components/hole-history-detail"
|
import { HoleHistoryDetail } from "@/components/hole-history-detail"
|
||||||
|
import { HoleTargetDistance } from "@/components/hole-target-distance"
|
||||||
import { ClubPicker } from "@/components/teecup/club-picker"
|
import { ClubPicker } from "@/components/teecup/club-picker"
|
||||||
import { TournamentPresentationPanel } from "@/components/tournament-presentation"
|
import { TournamentPresentationPanel } from "@/components/tournament-presentation"
|
||||||
import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard"
|
import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard"
|
||||||
|
|
@ -3282,6 +3283,7 @@ function HoleStatsSheet({
|
||||||
strokesReceived,
|
strokesReceived,
|
||||||
ownBagClubs,
|
ownBagClubs,
|
||||||
historyUrl,
|
historyUrl,
|
||||||
|
targetPointsBaseUrl,
|
||||||
onSave,
|
onSave,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -3291,6 +3293,7 @@ function HoleStatsSheet({
|
||||||
strokesReceived: number | null
|
strokesReceived: number | null
|
||||||
ownBagClubs: string[]
|
ownBagClubs: string[]
|
||||||
historyUrl: string
|
historyUrl: string
|
||||||
|
targetPointsBaseUrl: string
|
||||||
onSave: (patch: HolePatch) => Promise<void>
|
onSave: (patch: HolePatch) => Promise<void>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -3436,6 +3439,11 @@ function HoleStatsSheet({
|
||||||
|
|
||||||
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto p-5 sm:p-6">
|
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto p-5 sm:p-6">
|
||||||
<div className="mx-auto flex w-full max-w-sm flex-col gap-6">
|
<div className="mx-auto flex w-full max-w-sm flex-col gap-6">
|
||||||
|
{/* Rangefinder (migrasjon 079) -- samme selvskjulende komponent
|
||||||
|
som frittstående runder, persistent uansett hvilket steg
|
||||||
|
brukeren er på (samme plassering/prinsipp som round-detail.tsx). */}
|
||||||
|
<HoleTargetDistance baseUrl={targetPointsBaseUrl} holeNumber={hole.hole_number} size="full" />
|
||||||
|
|
||||||
{/* ADR-079: personlig historikk, flyttet hit ut av "strokes"-
|
{/* ADR-079: personlig historikk, flyttet hit ut av "strokes"-
|
||||||
steget -- se begrunnelsen for auto-hopp ved SheetStep over.
|
steget -- se begrunnelsen for auto-hopp ved SheetStep over.
|
||||||
Samme kompakte oppsummering + klikk-for-full-historikk-
|
Samme kompakte oppsummering + klikk-for-full-historikk-
|
||||||
|
|
@ -3726,6 +3734,7 @@ function HoleGrid({
|
||||||
strokesReceived={strokesReceived(sheetHole)}
|
strokesReceived={strokesReceived(sheetHole)}
|
||||||
ownBagClubs={ownBagClubs}
|
ownBagClubs={ownBagClubs}
|
||||||
historyUrl={`${base}/rounds/${roundId}/participants/${participantId}/holes/${sheetHole}/history`}
|
historyUrl={`${base}/rounds/${roundId}/participants/${participantId}/holes/${sheetHole}/history`}
|
||||||
|
targetPointsBaseUrl={`${base}/rounds/${roundId}`}
|
||||||
onSave={(patch) => onUpdateFull(sheetHole, patch)}
|
onSave={(patch) => onUpdateFull(sheetHole, patch)}
|
||||||
onClose={() => setSheetHole(null)}
|
onClose={() => setSheetHole(null)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -6680,12 +6680,13 @@ function PlayerHoleCards({
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Avstand til grønn (ADR-064, GolfAPI) -- kun for baner med
|
{/* Avstand til grønn (ADR-064, utvidet til ekte teeoff-baner i
|
||||||
koordinatdata; ingen synlig rad i det hele tatt for andre baner
|
migrasjon 079) -- kun for baner med koordinatdata; ingen synlig
|
||||||
(HoleTargetDistance returnerer null da). Full bredde, samme
|
rad i det hele tatt for andre baner (HoleTargetDistance returnerer
|
||||||
plassering brukeren pekte ut (rett under hull-navigatoren,
|
null da). Full bredde, samme plassering brukeren pekte ut (rett
|
||||||
over spillerkortene) -- ikke den smale kompaktvarianten. */}
|
under hull-navigatoren, over spillerkortene) -- ikke den smale
|
||||||
<HoleTargetDistance roundId={roundId} holeNumber={currentHole} size="full" />
|
kompaktvarianten. */}
|
||||||
|
<HoleTargetDistance baseUrl={`/rounds/${roundId}`} holeNumber={currentHole} size="full" />
|
||||||
|
|
||||||
{isTwoSided && sides.length === 2 && (
|
{isTwoSided && sides.length === 2 && (
|
||||||
<div className="flex items-center justify-center gap-2 rounded-2xl border border-border bg-card px-4 py-2.5 text-sm">
|
<div className="flex items-center justify-center gap-2 rounded-2xl border border-border bg-card px-4 py-2.5 text-sm">
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
WifiOff,
|
WifiOff,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { HoleTargetDistance } from "@/components/hole-target-distance"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
||||||
|
|
||||||
|
|
@ -855,6 +856,18 @@ export function SessionScorecard({
|
||||||
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {currentHole}</h2>
|
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {currentHole}</h2>
|
||||||
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
|
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Rangefinder (migrasjon 079) -- samme selvskjulende
|
||||||
|
komponent som frittstående runder/individuell
|
||||||
|
org-turnering, ingen synlig rad for baner uten
|
||||||
|
koordinatdata. */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<HoleTargetDistance
|
||||||
|
baseUrl={`/orgs/${organizationId}/matches/${matchId}`}
|
||||||
|
holeNumber={currentHole}
|
||||||
|
size="full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{units.map((unit) => {
|
{units.map((unit) => {
|
||||||
const teamColor = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b"
|
const teamColor = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b"
|
||||||
|
|
@ -929,6 +942,18 @@ export function SessionScorecard({
|
||||||
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {currentHole}</h2>
|
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {currentHole}</h2>
|
||||||
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
|
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Rangefinder (migrasjon 079) -- samme selvskjulende
|
||||||
|
komponent som frittstående runder/individuell
|
||||||
|
org-turnering, ingen synlig rad for baner uten
|
||||||
|
koordinatdata. */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<HoleTargetDistance
|
||||||
|
baseUrl={`/orgs/${organizationId}/matches/${matchId}`}
|
||||||
|
holeNumber={currentHole}
|
||||||
|
size="full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
299
tests/test_target_points.py
Normal file
299
tests/test_target_points.py
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
"""
|
||||||
|
Rangefinder-koordinater for offisielle (TeeOff-koblede) baner (migrasjon
|
||||||
|
079, ADR-081). To lag med tester:
|
||||||
|
|
||||||
|
1. `get_target_points()`/`resolve_match_course_key()` -- de delte
|
||||||
|
byggeklossene direkte, uavhengig av hvilket av de tre kallestedene
|
||||||
|
som bruker dem.
|
||||||
|
2. Integrasjon mot alle tre endepunktene (frittstående runde, individuell
|
||||||
|
org-turnering, lag-matchplay) + den nye skrive-veien for offisielle
|
||||||
|
baners koordinater i courses.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.auth import CurrentUser
|
||||||
|
from app.hole_history import resolve_match_course_key
|
||||||
|
from app.target_points import get_target_points
|
||||||
|
from app.routers.courses import CoursePointIn, list_course_coordinates, replace_course_coordinates
|
||||||
|
from app.routers.individual_tournaments import get_tournament_round_hole_target_points
|
||||||
|
from app.routers.rounds import get_round_hole_target_points
|
||||||
|
from app.routers.scoring import get_match_hole_target_points
|
||||||
|
|
||||||
|
from tests.conftest import (
|
||||||
|
add_membership,
|
||||||
|
create_course,
|
||||||
|
create_match,
|
||||||
|
create_org,
|
||||||
|
create_org_hole,
|
||||||
|
create_participant,
|
||||||
|
create_player,
|
||||||
|
create_round,
|
||||||
|
create_session,
|
||||||
|
create_team,
|
||||||
|
create_team_roster,
|
||||||
|
create_tee,
|
||||||
|
create_tournament,
|
||||||
|
create_tournament_participant,
|
||||||
|
create_tournament_round,
|
||||||
|
create_tournament_round_participant,
|
||||||
|
create_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
import app.db as app_db
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. get_target_points() / resolve_match_course_key()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_get_target_points_returns_empty_for_none_key(pool):
|
||||||
|
async with app_db.plain_connection() as conn:
|
||||||
|
points = await get_target_points(conn, None, 1)
|
||||||
|
assert points == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_target_points_teeoff_branch(pool):
|
||||||
|
async with app_db.plain_connection() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO teeoff_course_coordinate
|
||||||
|
(external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude)
|
||||||
|
VALUES ('slug:99', 5, 'green', 'middle', 'center', 59.1, 10.4)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
points = await get_target_points(conn, ("teeoff", "slug", "99"), 5)
|
||||||
|
other_hole = await get_target_points(conn, ("teeoff", "slug", "99"), 6)
|
||||||
|
assert len(points) == 1
|
||||||
|
assert points[0].poi_type == "green"
|
||||||
|
assert points[0].latitude == 59.1
|
||||||
|
assert other_hole == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_target_points_golfapi_branch(pool):
|
||||||
|
async with app_db.plain_connection() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO golfapi_course (course_id, club_id, club_name, course_name, num_holes, num_coordinates)
|
||||||
|
VALUES ('gapi-1', 'club-1', 'Testklubb', 'Bane', 18, 1)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO golfapi_course_coordinate
|
||||||
|
(golfapi_course_id, hole_number, poi_type, location, side_fairway, latitude, longitude)
|
||||||
|
VALUES ('gapi-1', 3, 'water', 'front', 'left', 59.2, 10.5)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
points = await get_target_points(conn, ("golfapi", "gapi-1"), 3)
|
||||||
|
assert len(points) == 1
|
||||||
|
assert points[0].poi_type == "water"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resolve_match_course_key_custom_course_returns_none(pool):
|
||||||
|
user_id = await create_user()
|
||||||
|
org_id = await create_org()
|
||||||
|
await add_membership(org_id, user_id, role="member")
|
||||||
|
tournament_id = await create_tournament(org_id)
|
||||||
|
course_id = await create_course(org_id) # source defaults to 'custom'
|
||||||
|
team_a = await create_team(org_id, tournament_id)
|
||||||
|
team_b = await create_team(org_id, tournament_id)
|
||||||
|
session_id = await create_session(org_id, tournament_id, course_id)
|
||||||
|
match_id = await create_match(org_id, session_id, team_a, team_b)
|
||||||
|
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
key = await resolve_match_course_key(conn, match_id)
|
||||||
|
assert key is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resolve_match_course_key_official_course(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
tournament_id = await create_tournament(org_id)
|
||||||
|
course_id = await create_course(org_id)
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE course SET source = 'official', external_course_ref = 'slug-x:42' WHERE id = $1",
|
||||||
|
course_id,
|
||||||
|
)
|
||||||
|
team_a = await create_team(org_id, tournament_id)
|
||||||
|
team_b = await create_team(org_id, tournament_id)
|
||||||
|
session_id = await create_session(org_id, tournament_id, course_id)
|
||||||
|
match_id = await create_match(org_id, session_id, team_a, team_b)
|
||||||
|
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
key = await resolve_match_course_key(conn, match_id)
|
||||||
|
assert key == ("teeoff", "slug-x", "42")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resolve_match_course_key_international_course(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
tournament_id = await create_tournament(org_id)
|
||||||
|
course_id = await create_course(org_id)
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE course SET source = 'international', external_course_ref = 'gapi-77' WHERE id = $1",
|
||||||
|
course_id,
|
||||||
|
)
|
||||||
|
team_a = await create_team(org_id, tournament_id)
|
||||||
|
team_b = await create_team(org_id, tournament_id)
|
||||||
|
session_id = await create_session(org_id, tournament_id, course_id)
|
||||||
|
match_id = await create_match(org_id, session_id, team_a, team_b)
|
||||||
|
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
key = await resolve_match_course_key(conn, match_id)
|
||||||
|
assert key == ("golfapi", "gapi-77")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resolve_match_course_key_nonexistent_match_returns_none(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
key = await resolve_match_course_key(conn, "00000000-0000-0000-0000-000000000000")
|
||||||
|
assert key is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Integrasjon: de tre GET-endepunktene + skrive-veien i courses.py.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_round_target_points_teeoff_round_now_returns_points(pool):
|
||||||
|
"""Regresjonsbevis for refaktoreringen: FØR migrasjon 079 returnerte
|
||||||
|
dette endepunktet [] for EN HVER teeoff-sourcet frittstående runde,
|
||||||
|
uansett koordinatdata. Nå slår den opp via samme bane-bro som
|
||||||
|
hole_history.py."""
|
||||||
|
user_id = await create_user()
|
||||||
|
async with app_db.plain_connection() as conn:
|
||||||
|
round_id = await create_round(conn, user_id) # teeoff/test-facility/test-course
|
||||||
|
await create_participant(conn, round_id, user_id=user_id, is_owner=True)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO teeoff_course_coordinate
|
||||||
|
(external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude)
|
||||||
|
VALUES ('test-facility:test-course', 4, 'tee_front', 'middle', 'center', 59.0, 10.0)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
points = await get_round_hole_target_points(round_id, 4, user=CurrentUser(user_id=user_id))
|
||||||
|
assert len(points) == 1
|
||||||
|
assert points[0].poi_type == "tee_front"
|
||||||
|
|
||||||
|
empty = await get_round_hole_target_points(round_id, 5, user=CurrentUser(user_id=user_id))
|
||||||
|
assert empty == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tournament_round_target_points_official_course(pool):
|
||||||
|
user_id = await create_user()
|
||||||
|
org_id = await create_org()
|
||||||
|
await add_membership(org_id, user_id, role="member")
|
||||||
|
tournament_id = await create_tournament(org_id)
|
||||||
|
course_id = await create_course(org_id)
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE course SET source = 'official', external_course_ref = 'tourn-slug:7' WHERE id = $1",
|
||||||
|
course_id,
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO teeoff_course_coordinate
|
||||||
|
(external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude)
|
||||||
|
VALUES ('tourn-slug:7', 9, 'rock', 'front', 'center', 59.3, 10.6)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for n in range(1, 19):
|
||||||
|
await create_org_hole(org_id, course_id, hole_number=n)
|
||||||
|
tournament_round_id = await create_tournament_round(org_id, tournament_id, course_id)
|
||||||
|
|
||||||
|
points = await get_tournament_round_hole_target_points(
|
||||||
|
tournament_id, tournament_round_id, 9, organization_id=org_id,
|
||||||
|
)
|
||||||
|
assert len(points) == 1
|
||||||
|
assert points[0].poi_type == "rock"
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await get_tournament_round_hole_target_points(
|
||||||
|
"00000000-0000-0000-0000-000000000000", tournament_round_id, 9, organization_id=org_id,
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_match_target_points_official_course_and_access_control(pool):
|
||||||
|
member_id = await create_user()
|
||||||
|
outsider_id = await create_user()
|
||||||
|
org_id = await create_org()
|
||||||
|
await add_membership(org_id, member_id, role="member")
|
||||||
|
tournament_id = await create_tournament(org_id)
|
||||||
|
course_id = await create_course(org_id)
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE course SET source = 'official', external_course_ref = 'match-slug:3' WHERE id = $1",
|
||||||
|
course_id,
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO teeoff_course_coordinate
|
||||||
|
(external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude)
|
||||||
|
VALUES ('match-slug:3', 2, 'water', 'back', 'right', 59.4, 10.7)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
team_a = await create_team(org_id, tournament_id)
|
||||||
|
team_b = await create_team(org_id, tournament_id)
|
||||||
|
session_id = await create_session(org_id, tournament_id, course_id)
|
||||||
|
match_id = await create_match(org_id, session_id, team_a, team_b)
|
||||||
|
|
||||||
|
points = await get_match_hole_target_points(
|
||||||
|
match_id, 2, organization_id=org_id, user=CurrentUser(user_id=member_id),
|
||||||
|
)
|
||||||
|
assert len(points) == 1
|
||||||
|
assert points[0].poi_type == "water"
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await get_match_hole_target_points(
|
||||||
|
match_id, 2, organization_id=org_id, user=CurrentUser(user_id=outsider_id),
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_course_coordinates_write_path_requires_official_and_fully_replaces(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
course_id = await create_course(org_id) # 'custom' by default
|
||||||
|
|
||||||
|
# 400 på ikke-offisiell bane.
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await replace_course_coordinates(
|
||||||
|
course_id,
|
||||||
|
[CoursePointIn(hole_number=1, poi_type="green", location="middle", side_fairway="center",
|
||||||
|
latitude=59.0, longitude=10.0)],
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
|
# GET returnerer stille tom liste for ikke-offisiell bane (ikke feil).
|
||||||
|
assert await list_course_coordinates(course_id, organization_id=org_id) == []
|
||||||
|
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE course SET source = 'official', external_course_ref = 'write-slug:1' WHERE id = $1",
|
||||||
|
course_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
first_set = [
|
||||||
|
CoursePointIn(hole_number=1, poi_type="green", location="front", side_fairway="center",
|
||||||
|
latitude=59.0, longitude=10.0),
|
||||||
|
CoursePointIn(hole_number=1, poi_type="water", location="back", side_fairway="left",
|
||||||
|
latitude=59.01, longitude=10.01),
|
||||||
|
]
|
||||||
|
result = await replace_course_coordinates(course_id, first_set, organization_id=org_id)
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
# Ny PUT ERSTATTER hele settet -- ikke et tillegg.
|
||||||
|
second_set = [
|
||||||
|
CoursePointIn(hole_number=2, poi_type="rock", location="middle", side_fairway="right",
|
||||||
|
latitude=59.02, longitude=10.02),
|
||||||
|
]
|
||||||
|
result = await replace_course_coordinates(course_id, second_set, organization_id=org_id)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].hole_number == 2
|
||||||
|
assert result[0].poi_type == "rock"
|
||||||
|
|
||||||
|
read_back = await list_course_coordinates(course_id, organization_id=org_id)
|
||||||
|
assert len(read_back) == 1
|
||||||
|
assert read_back[0].poi_type == "rock"
|
||||||
Loading…
Reference in a new issue