teecup/app/routers/tournament_round_shots.py

328 lines
14 KiB
Python
Raw Permalink Normal View History

"""
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), 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`), en spiller 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)