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-
|
||||
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
|
||||
`score - putts <= par - 2`-definisjon (IKKE skjema-kommentaren i
|
||||
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"])
|
||||
|
||||
|
||||
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
|
||||
class HoleInstance:
|
||||
source: Literal["personal", "tournament"]
|
||||
|
|
|
|||
|
|
@ -611,6 +611,104 @@ async def import_official_course(
|
|||
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) -------------------------
|
||||
#
|
||||
# Tredje banekilde, for baner utenfor TeeOffs dekning -- Tjøme Golfklubb var
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ from ..hole_history import (
|
|||
hole_history_for_user,
|
||||
resolve_tournament_round_course_key,
|
||||
)
|
||||
from ..target_points import TargetPoint, get_target_points
|
||||
from ..team_authz import user_is_own_tournament_participant
|
||||
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)
|
||||
|
||||
|
||||
@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(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
||||
"/participants/{round_participant_id}/holes",
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ from ..hole_history import (
|
|||
resolve_personal_round_course_key,
|
||||
)
|
||||
from ..realtime import broadcast_round_update, live_sockets_for_round
|
||||
from ..target_points import TargetPoint, get_target_points
|
||||
from .auth import _hash_secret
|
||||
from .notifications import create_notification
|
||||
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()
|
||||
|
||||
|
||||
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])
|
||||
async def get_round_hole_target_points(
|
||||
round_id: str,
|
||||
hole_number: int = Path(ge=1, le=18),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[TargetPoint]:
|
||||
"""Rangefinder-data for hullet (ADR-064) -- grønn front/midt/bak,
|
||||
hindringer, tee-punkter fra GolfAPI. KUN for baner importert derfra
|
||||
(`personal_course.external_golfapi_course_id` satt) -- returnerer tom
|
||||
liste ellers (TeeOff-baner/manuelt opprettede baner har ingen
|
||||
koordinatdata), rangefinder-UI-et skjules da bare, resten av
|
||||
scorekortet upåvirket. Returnerer RÅ punkter, ALDRI en ferdigregnet
|
||||
avstand -- den ville vært utdatert idet spilleren beveger seg. Klienten
|
||||
Haversine-regner selv (frontend/lib/geo.ts, samme funksjon som
|
||||
ADR-048) mot spillerens EGEN, ferske GPS-posisjon."""
|
||||
"""Rangefinder-data for hullet (ADR-064, utvidet til ekte teeoff-baner
|
||||
i migrasjon 079) -- grønn front/midt/bak, hindringer, tee-punkter.
|
||||
Returnerer tom liste for håndlagde baner (ingen koordinatkilde),
|
||||
rangefinder-UI-et skjules da bare, resten av scorekortet upåvirket.
|
||||
Returnerer RÅ punkter, ALDRI en ferdigregnet avstand -- den ville vært
|
||||
utdatert idet spilleren beveger seg. Klienten Haversine-regner selv
|
||||
(frontend/lib/geo.ts, samme funksjon som ADR-048) mot spillerens EGEN,
|
||||
ferske GPS-posisjon."""
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
round_row = await conn.fetchrow(
|
||||
"SELECT course_source, personal_course_id FROM round WHERE id = $1", round_id
|
||||
)
|
||||
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]
|
||||
course_key = await resolve_personal_round_course_key(conn, round_id)
|
||||
points = await get_target_points(conn, course_key, hole_number)
|
||||
return points
|
||||
|
||||
|
||||
class ParticipantUpdate(BaseModel):
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ from ..db import org_connection
|
|||
from ..errors import app_error, translate_db_errors
|
||||
from ..handicap import parse_allowance_config, relative_strokes_for_match
|
||||
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 (
|
||||
is_org_member,
|
||||
user_is_match_participant,
|
||||
|
|
@ -852,6 +854,39 @@ async def get_scorecard(
|
|||
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
|
||||
# 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
|
||||
// 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):
|
||||
// (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
|
||||
|
|
@ -44,12 +51,12 @@ const _HAZARD_LABELS: Record<string, string> = {
|
|||
}
|
||||
|
||||
export function HoleTargetDistance({
|
||||
roundId,
|
||||
baseUrl,
|
||||
holeNumber,
|
||||
size = "compact",
|
||||
className,
|
||||
}: {
|
||||
roundId: string
|
||||
baseUrl: string
|
||||
holeNumber: number
|
||||
size?: "compact" | "full"
|
||||
className?: string
|
||||
|
|
@ -61,7 +68,7 @@ export function HoleTargetDistance({
|
|||
useEffect(() => {
|
||||
let cancelled = false
|
||||
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((data: ApiTargetPoint[]) => {
|
||||
if (!cancelled) setPoints(data)
|
||||
|
|
@ -72,7 +79,7 @@ export function HoleTargetDistance({
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [roundId, holeNumber])
|
||||
}, [baseUrl, holeNumber])
|
||||
|
||||
useEffect(() => {
|
||||
// Ingen vits å spore GPS når banen uansett ikke har koordinatdata for
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import {
|
|||
WizardSection,
|
||||
} from "@/components/hole-stat-inputs"
|
||||
import { HoleHistoryDetail } from "@/components/hole-history-detail"
|
||||
import { HoleTargetDistance } from "@/components/hole-target-distance"
|
||||
import { ClubPicker } from "@/components/teecup/club-picker"
|
||||
import { TournamentPresentationPanel } from "@/components/tournament-presentation"
|
||||
import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard"
|
||||
|
|
@ -3282,6 +3283,7 @@ function HoleStatsSheet({
|
|||
strokesReceived,
|
||||
ownBagClubs,
|
||||
historyUrl,
|
||||
targetPointsBaseUrl,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
|
|
@ -3291,6 +3293,7 @@ function HoleStatsSheet({
|
|||
strokesReceived: number | null
|
||||
ownBagClubs: string[]
|
||||
historyUrl: string
|
||||
targetPointsBaseUrl: string
|
||||
onSave: (patch: HolePatch) => Promise<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">
|
||||
<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"-
|
||||
steget -- se begrunnelsen for auto-hopp ved SheetStep over.
|
||||
Samme kompakte oppsummering + klikk-for-full-historikk-
|
||||
|
|
@ -3726,6 +3734,7 @@ function HoleGrid({
|
|||
strokesReceived={strokesReceived(sheetHole)}
|
||||
ownBagClubs={ownBagClubs}
|
||||
historyUrl={`${base}/rounds/${roundId}/participants/${participantId}/holes/${sheetHole}/history`}
|
||||
targetPointsBaseUrl={`${base}/rounds/${roundId}`}
|
||||
onSave={(patch) => onUpdateFull(sheetHole, patch)}
|
||||
onClose={() => setSheetHole(null)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -6680,12 +6680,13 @@ function PlayerHoleCards({
|
|||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Avstand til grønn (ADR-064, GolfAPI) -- kun for baner med
|
||||
koordinatdata; ingen synlig rad i det hele tatt for andre baner
|
||||
(HoleTargetDistance returnerer null da). Full bredde, samme
|
||||
plassering brukeren pekte ut (rett under hull-navigatoren,
|
||||
over spillerkortene) -- ikke den smale kompaktvarianten. */}
|
||||
<HoleTargetDistance roundId={roundId} holeNumber={currentHole} size="full" />
|
||||
{/* Avstand til grønn (ADR-064, utvidet til ekte teeoff-baner i
|
||||
migrasjon 079) -- kun for baner med koordinatdata; ingen synlig
|
||||
rad i det hele tatt for andre baner (HoleTargetDistance returnerer
|
||||
null da). Full bredde, samme plassering brukeren pekte ut (rett
|
||||
under hull-navigatoren, over spillerkortene) -- ikke den smale
|
||||
kompaktvarianten. */}
|
||||
<HoleTargetDistance baseUrl={`/rounds/${roundId}`} holeNumber={currentHole} size="full" />
|
||||
|
||||
{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">
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
WifiOff,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { HoleTargetDistance } from "@/components/hole-target-distance"
|
||||
import { cn } from "@/lib/utils"
|
||||
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>
|
||||
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
|
||||
</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">
|
||||
{units.map((unit) => {
|
||||
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>
|
||||
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
|
||||
</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">
|
||||
{(
|
||||
[
|
||||
|
|
|
|||
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