""" 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)