328 lines
14 KiB
Python
328 lines
14 KiB
Python
|
|
"""
|
||
|
|
Slag-for-slag GPS-avstandsmåling for org-turneringer (ADR-103) --
|
||
|
|
speiler `rounds.py` sin slag-seksjon (ADR-048) for frittstående runder,
|
||
|
|
men org-scopet (RLS) og forenklet: turneringsmodellen har ALDRI en
|
||
|
|
side/lag-XOR (kun individuelt), så kun participant-varianten trengs
|
||
|
|
(ingen `/sides/`-speilet endepunktsett).
|
||
|
|
|
||
|
|
Eierskap: `tournament_round_shot` (migrasjon 091) kjenner
|
||
|
|
`tournament_round_participant_id` + `hole_number` DIREKTE -- IKKE
|
||
|
|
`tournament_round_hole_id`. `tournament_round_hole` opprettes (ulikt
|
||
|
|
frittstående `round_hole`) først ved FØRSTE score-innsending
|
||
|
|
(`update_hole`), så en spiller må kunne måle et slag FØR hullets score
|
||
|
|
er registrert -- en FK mot en hull-rad som kanskje ikke finnes ennå
|
||
|
|
ville vært feil. Samme LEFT JOIN-tolerante oppløsning som
|
||
|
|
`get_tournament_round_hole_target_points`/`list_round_participant_holes`
|
||
|
|
allerede bruker.
|
||
|
|
|
||
|
|
Autorisasjon, bevisst STRENGERE enn ADR-102s kommentartråd: skriving
|
||
|
|
(POST/DELETE) krever `user_is_own_tournament_participant` (self ELLER
|
||
|
|
org-admin) -- samme sensitivitetsklasse som selve SCORE-registreringen
|
||
|
|
(`update_hole`), ikke oppsett-klassen kommentarer hører til. Lesing
|
||
|
|
(GET) krever kun org-medlemskap, samme mønster som
|
||
|
|
`list_round_participant_holes`.
|
||
|
|
|
||
|
|
Deling: reuser den rundespesifikke kommentartråden (ADR-102,
|
||
|
|
`tournament_round_message`) i stedet for Banter Board, speiler
|
||
|
|
`rounds.py::share_shot` sitt Mapbox Static Images-snippet-mønster.
|
||
|
|
`_mapbox_static_snippet_url`/`_encode_polyline` er en bevisst LOKAL kopi
|
||
|
|
(ikke importert fra rounds.py) -- org-turnering-subsystemet importerer
|
||
|
|
aldri fra det frittstående-runde-subsystemet (samme presedens ADR-102
|
||
|
|
selv fulgte for `_resolve_author_name`).
|
||
|
|
"""
|
||
|
|
|
||
|
|
import urllib.parse
|
||
|
|
from typing import Literal
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
from fastapi import APIRouter, Depends
|
||
|
|
from pydantic import BaseModel, Field
|
||
|
|
|
||
|
|
from .. import storage
|
||
|
|
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
||
|
|
from ..config import settings
|
||
|
|
from ..db import org_connection
|
||
|
|
from ..errors import app_error, translate_db_errors
|
||
|
|
from ..team_authz import user_is_own_tournament_participant
|
||
|
|
from .tournament_round_messages import _resolve_author_name
|
||
|
|
|
||
|
|
router = APIRouter(tags=["tournament-round-shots"])
|
||
|
|
|
||
|
|
_SHOT_SELECT = """
|
||
|
|
SELECT trs.id::text AS id, trs.shot_number, trs.club, trs.distance_meters, trs.start_method,
|
||
|
|
trs.start_lat, trs.start_lng, trs.end_method, trs.end_lat, trs.end_lng,
|
||
|
|
trs.shared_tournament_round_message_id::text AS shared_tournament_round_message_id,
|
||
|
|
trs.captured_at, trm.image_key AS shared_image_key
|
||
|
|
FROM tournament_round_shot trs
|
||
|
|
LEFT JOIN tournament_round_message trm ON trm.id = trs.shared_tournament_round_message_id
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
class ShotIn(BaseModel):
|
||
|
|
club: str = Field(max_length=50)
|
||
|
|
distance_meters: float = Field(gt=0, lt=500)
|
||
|
|
start_method: Literal["gps", "map_tap"]
|
||
|
|
start_lat: float = Field(ge=-90, le=90)
|
||
|
|
start_lng: float = Field(ge=-180, le=180)
|
||
|
|
end_method: Literal["gps", "map_tap"] = "gps"
|
||
|
|
end_lat: float = Field(ge=-90, le=90)
|
||
|
|
end_lng: float = Field(ge=-180, le=180)
|
||
|
|
|
||
|
|
|
||
|
|
class ShotOut(BaseModel):
|
||
|
|
id: str
|
||
|
|
shot_number: int
|
||
|
|
club: str
|
||
|
|
distance_meters: float
|
||
|
|
start_method: str
|
||
|
|
start_lat: float
|
||
|
|
start_lng: float
|
||
|
|
end_method: str
|
||
|
|
end_lat: float
|
||
|
|
end_lng: float
|
||
|
|
shared_tournament_round_message_id: str | None
|
||
|
|
shared_image_url: str | None
|
||
|
|
captured_at: str
|
||
|
|
|
||
|
|
|
||
|
|
def _shot_out(row) -> ShotOut:
|
||
|
|
return ShotOut(
|
||
|
|
id=row["id"],
|
||
|
|
shot_number=row["shot_number"],
|
||
|
|
club=row["club"],
|
||
|
|
distance_meters=float(row["distance_meters"]),
|
||
|
|
start_method=row["start_method"],
|
||
|
|
start_lat=row["start_lat"],
|
||
|
|
start_lng=row["start_lng"],
|
||
|
|
end_method=row["end_method"],
|
||
|
|
end_lat=row["end_lat"],
|
||
|
|
end_lng=row["end_lng"],
|
||
|
|
shared_tournament_round_message_id=row["shared_tournament_round_message_id"],
|
||
|
|
shared_image_url=storage.public_url(row["shared_image_key"]) if row["shared_image_key"] else None,
|
||
|
|
captured_at=row["captured_at"].isoformat(),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def _resolve_participant_hole(
|
||
|
|
conn, tournament_id: str, round_id: str, round_participant_id: str, hole_number: int
|
||
|
|
) -> str:
|
||
|
|
"""LEFT JOIN-tolerant: bekrefter at deltakeren/runden/turneringen
|
||
|
|
faktisk henger sammen, men krever IKKE at tournament_round_hole
|
||
|
|
finnes ennå (spilleren kan måle et slag før de fullfører hullets
|
||
|
|
score). Returnerer tournament_participant_id (for autorisasjon)."""
|
||
|
|
row = await conn.fetchrow(
|
||
|
|
"""
|
||
|
|
SELECT trp.tournament_participant_id::text AS tournament_participant_id, tr.tournament_id::text AS tournament_id
|
||
|
|
FROM tournament_round_participant trp
|
||
|
|
JOIN tournament_round tr ON tr.id = trp.tournament_round_id
|
||
|
|
WHERE trp.id = $1 AND trp.tournament_round_id = $2
|
||
|
|
""",
|
||
|
|
round_participant_id, round_id,
|
||
|
|
)
|
||
|
|
if row is None or row["tournament_id"] != tournament_id:
|
||
|
|
raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
|
||
|
|
if hole_number < 1 or hole_number > 18:
|
||
|
|
raise app_error(400, "VALIDATION_FAILED", "Ugyldig hullnummer.")
|
||
|
|
return row["tournament_participant_id"]
|
||
|
|
|
||
|
|
|
||
|
|
async def _list_shots(conn, round_participant_id: str, hole_number: int) -> list[ShotOut]:
|
||
|
|
rows = await conn.fetch(
|
||
|
|
_SHOT_SELECT + " WHERE trs.tournament_round_participant_id = $1 AND trs.hole_number = $2 ORDER BY trs.shot_number",
|
||
|
|
round_participant_id, hole_number,
|
||
|
|
)
|
||
|
|
return [_shot_out(r) for r in rows]
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(
|
||
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
||
|
|
"/participants/{round_participant_id}/holes/{hole_number}/shots",
|
||
|
|
response_model=list[ShotOut],
|
||
|
|
)
|
||
|
|
async def list_tournament_round_shots(
|
||
|
|
tournament_id: str, round_id: str, round_participant_id: str, hole_number: int,
|
||
|
|
organization_id: str = Depends(get_authorized_org),
|
||
|
|
) -> list[ShotOut]:
|
||
|
|
async with org_connection(organization_id) as conn:
|
||
|
|
await _resolve_participant_hole(conn, tournament_id, round_id, round_participant_id, hole_number)
|
||
|
|
return await _list_shots(conn, round_participant_id, hole_number)
|
||
|
|
|
||
|
|
|
||
|
|
@router.post(
|
||
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
||
|
|
"/participants/{round_participant_id}/holes/{hole_number}/shots",
|
||
|
|
response_model=ShotOut,
|
||
|
|
status_code=201,
|
||
|
|
)
|
||
|
|
async def create_tournament_round_shot(
|
||
|
|
tournament_id: str, round_id: str, round_participant_id: str, hole_number: int, body: ShotIn,
|
||
|
|
organization_id: str = Depends(get_authorized_org),
|
||
|
|
user: CurrentUser = Depends(get_current_user),
|
||
|
|
) -> ShotOut:
|
||
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||
|
|
tournament_participant_id = await _resolve_participant_hole(
|
||
|
|
conn, tournament_id, round_id, round_participant_id, hole_number
|
||
|
|
)
|
||
|
|
if not await user_is_own_tournament_participant(conn, organization_id, tournament_participant_id, user.user_id):
|
||
|
|
raise app_error(
|
||
|
|
403, "NOT_TOURNAMENT_PARTICIPANT",
|
||
|
|
"Du kan kun måle slag for deg selv (eller være organisasjonsadministrator).",
|
||
|
|
)
|
||
|
|
row = await conn.fetchrow(
|
||
|
|
"""
|
||
|
|
INSERT INTO tournament_round_shot (
|
||
|
|
organization_id, tournament_round_participant_id, hole_number, recorded_by_user_id,
|
||
|
|
shot_number, club, distance_meters, start_method, start_lat, start_lng, end_method, end_lat, end_lng
|
||
|
|
)
|
||
|
|
VALUES (
|
||
|
|
$1, $2, $3, $4,
|
||
|
|
COALESCE((SELECT MAX(shot_number) FROM tournament_round_shot
|
||
|
|
WHERE tournament_round_participant_id = $2 AND hole_number = $3), 0) + 1,
|
||
|
|
$5, $6, $7, $8, $9, $10, $11, $12
|
||
|
|
)
|
||
|
|
RETURNING id::text AS id
|
||
|
|
""",
|
||
|
|
organization_id, round_participant_id, hole_number, user.user_id,
|
||
|
|
body.club, body.distance_meters, body.start_method, body.start_lat, body.start_lng,
|
||
|
|
body.end_method, body.end_lat, body.end_lng,
|
||
|
|
)
|
||
|
|
full_row = await conn.fetchrow(_SHOT_SELECT + " WHERE trs.id = $1", row["id"])
|
||
|
|
return _shot_out(full_row)
|
||
|
|
|
||
|
|
|
||
|
|
@router.delete(
|
||
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/shots/{shot_id}",
|
||
|
|
status_code=204,
|
||
|
|
)
|
||
|
|
async def delete_tournament_round_shot(
|
||
|
|
tournament_id: str, round_id: str, shot_id: str,
|
||
|
|
organization_id: str = Depends(get_authorized_org),
|
||
|
|
user: CurrentUser = Depends(get_current_user),
|
||
|
|
) -> None:
|
||
|
|
async with org_connection(organization_id) as conn:
|
||
|
|
row = await conn.fetchrow(
|
||
|
|
"""
|
||
|
|
SELECT trs.tournament_round_participant_id::text AS round_participant_id,
|
||
|
|
trp.tournament_participant_id::text AS tournament_participant_id
|
||
|
|
FROM tournament_round_shot trs
|
||
|
|
JOIN tournament_round_participant trp ON trp.id = trs.tournament_round_participant_id
|
||
|
|
JOIN tournament_round tr ON tr.id = trp.tournament_round_id
|
||
|
|
WHERE trs.id = $1 AND tr.id = $2 AND tr.tournament_id = $3
|
||
|
|
""",
|
||
|
|
shot_id, round_id, tournament_id,
|
||
|
|
)
|
||
|
|
if row is None:
|
||
|
|
raise app_error(404, "NOT_FOUND", "Slaget finnes ikke på denne runden.")
|
||
|
|
if not await user_is_own_tournament_participant(
|
||
|
|
conn, organization_id, row["tournament_participant_id"], user.user_id
|
||
|
|
):
|
||
|
|
raise app_error(
|
||
|
|
403, "NOT_TOURNAMENT_PARTICIPANT",
|
||
|
|
"Du kan kun slette dine egne målte slag (eller være organisasjonsadministrator).",
|
||
|
|
)
|
||
|
|
await conn.execute("DELETE FROM tournament_round_shot WHERE id = $1", shot_id)
|
||
|
|
|
||
|
|
|
||
|
|
def _encode_polyline(points: list[tuple[float, float]]) -> str:
|
||
|
|
"""Google sin polyline-algoritme (5 desimaler) -- lokal kopi av
|
||
|
|
rounds.py sin, se modulens docstring for hvorfor den ikke importeres."""
|
||
|
|
result: list[str] = []
|
||
|
|
prev_lat = prev_lng = 0
|
||
|
|
for lat, lng in points:
|
||
|
|
lat_i = round(lat * 1e5)
|
||
|
|
lng_i = round(lng * 1e5)
|
||
|
|
for value, prev in ((lat_i, prev_lat), (lng_i, prev_lng)):
|
||
|
|
delta = value - prev
|
||
|
|
shifted = ~(delta << 1) if delta < 0 else (delta << 1)
|
||
|
|
chunk = ""
|
||
|
|
while shifted >= 0x20:
|
||
|
|
chunk += chr((0x20 | (shifted & 0x1F)) + 63)
|
||
|
|
shifted >>= 5
|
||
|
|
chunk += chr(shifted + 63)
|
||
|
|
result.append(chunk)
|
||
|
|
prev_lat, prev_lng = lat_i, lng_i
|
||
|
|
return "".join(result)
|
||
|
|
|
||
|
|
|
||
|
|
def _mapbox_static_snippet_url(start_lat: float, start_lng: float, end_lat: float, end_lng: float) -> str:
|
||
|
|
encoded = urllib.parse.quote(_encode_polyline([(start_lat, start_lng), (end_lat, end_lng)]), safe="")
|
||
|
|
overlay = (
|
||
|
|
f"pin-s-a+ff5a1f({start_lng},{start_lat}),"
|
||
|
|
f"pin-s-b+2f7a3f({end_lng},{end_lat}),"
|
||
|
|
f"path-4+ff5a1f-0.9({encoded})"
|
||
|
|
)
|
||
|
|
return (
|
||
|
|
f"https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/{overlay}/auto/600x400@2x"
|
||
|
|
f"?padding=60&access_token={settings.MAPBOX_SECRET_TOKEN}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class ShotShareIn(BaseModel):
|
||
|
|
body: str = Field(max_length=2000)
|
||
|
|
|
||
|
|
|
||
|
|
@router.post(
|
||
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/shots/{shot_id}/share",
|
||
|
|
response_model=ShotOut,
|
||
|
|
)
|
||
|
|
async def share_tournament_round_shot(
|
||
|
|
tournament_id: str, round_id: str, shot_id: str, body: ShotShareIn,
|
||
|
|
organization_id: str = Depends(get_authorized_org),
|
||
|
|
user: CurrentUser = Depends(get_current_user),
|
||
|
|
) -> ShotOut:
|
||
|
|
async with org_connection(organization_id) as conn:
|
||
|
|
shot_row = await conn.fetchrow(
|
||
|
|
"""
|
||
|
|
SELECT trs.id::text AS id, trs.start_lat, trs.start_lng, trs.end_lat, trs.end_lng,
|
||
|
|
trp.tournament_participant_id::text AS tournament_participant_id
|
||
|
|
FROM tournament_round_shot trs
|
||
|
|
JOIN tournament_round_participant trp ON trp.id = trs.tournament_round_participant_id
|
||
|
|
JOIN tournament_round tr ON tr.id = trp.tournament_round_id
|
||
|
|
WHERE trs.id = $1 AND tr.id = $2 AND tr.tournament_id = $3
|
||
|
|
""",
|
||
|
|
shot_id, round_id, tournament_id,
|
||
|
|
)
|
||
|
|
if shot_row is None:
|
||
|
|
raise app_error(404, "NOT_FOUND", "Slaget finnes ikke på denne runden.")
|
||
|
|
if not await user_is_own_tournament_participant(
|
||
|
|
conn, organization_id, shot_row["tournament_participant_id"], user.user_id
|
||
|
|
):
|
||
|
|
raise app_error(
|
||
|
|
403, "NOT_TOURNAMENT_PARTICIPANT",
|
||
|
|
"Du kan kun dele dine egne målte slag (eller være organisasjonsadministrator).",
|
||
|
|
)
|
||
|
|
|
||
|
|
author_display_name = await _resolve_author_name(conn, user.user_id)
|
||
|
|
|
||
|
|
image_key = None
|
||
|
|
if settings.MAPBOX_SECRET_TOKEN:
|
||
|
|
url = _mapbox_static_snippet_url(
|
||
|
|
shot_row["start_lat"], shot_row["start_lng"], shot_row["end_lat"], shot_row["end_lng"]
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as client:
|
||
|
|
resp = await client.get(url)
|
||
|
|
resp.raise_for_status()
|
||
|
|
image_key = await storage.upload_image("tournament_round_messages", round_id, resp.content)
|
||
|
|
except httpx.HTTPError:
|
||
|
|
# Grasiøs degradering, samme prinsipp som rounds.py::share_shot
|
||
|
|
# -- ren tekst uten satellittutsnitt fremfor å blokkere delingen.
|
||
|
|
image_key = None
|
||
|
|
|
||
|
|
async with translate_db_errors():
|
||
|
|
message_row = await conn.fetchrow(
|
||
|
|
"""
|
||
|
|
INSERT INTO tournament_round_message
|
||
|
|
(organization_id, tournament_round_id, author_user_id, author_display_name, body, image_key)
|
||
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||
|
|
RETURNING id::text AS id
|
||
|
|
""",
|
||
|
|
organization_id, round_id, user.user_id, author_display_name, body.body, image_key,
|
||
|
|
)
|
||
|
|
await conn.execute(
|
||
|
|
"UPDATE tournament_round_shot SET shared_tournament_round_message_id = $1 WHERE id = $2",
|
||
|
|
message_row["id"], shot_id,
|
||
|
|
)
|
||
|
|
full_row = await conn.fetchrow(_SHOT_SELECT + " WHERE trs.id = $1", shot_id)
|
||
|
|
return _shot_out(full_row)
|