""" Matcher, deltakere og blind draw-lås (ADR-013). Autorisasjonsgrense (se plan): å opprette en match er organisator-arbeid (kun org-medlemskap, som tournaments.py). Å legge til en DELTAKER eller LÅSE et lags oppstilling krever i tillegg at brukeren er KAPTEIN for DET laget (eller org-eier/admin) -- se app/team_authz.py sin user_is_team_captain for den fulle begrunnelsen, inkl. fallback for lag uten utpekt kaptein ennå. Byttet fra "hvem som helst rostret" 2026-07-19 (Brukerroller-runden, ADR-023). Synlighet (ADR-013): motstanderens deltakere er skjult i app-laget (ikke RLS — begge lag er i samme organisasjon) til BEGGE lag har låst for økten. Filteret er lagt i SQL (WHERE ... AND ($revealed OR team_id = ANY($egne_lag))), ikke et etterfølgende Python-filter, slik at lekkasje er strukturelt umulig. Klokkeslett (se plan): match.tee_time er UTLEDET, ikke lagret -- session sin scheduled_at + (sequence-1) * tee_interval_minutes, med en valgfri tee_time_override som vinner hvis satt. Beregnes i Python i både create_match og list_matches, som allerede henter økten for andre formål. """ import json from datetime import datetime, timedelta from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from ..auth import CurrentUser, get_authorized_org, get_current_user from ..blind_draw import locked_team_ids, own_team_ids from ..db import org_connection from ..errors import app_error, translate_db_errors from ..handicap import compute_and_store_side_handicaps, parse_allowance_config from ..team_authz import user_is_team_captain router = APIRouter() def _compute_tee_time( scheduled_at: datetime | None, tee_interval_minutes: int | None, sequence: int, override: datetime | None, ) -> datetime | None: if override is not None: return override if scheduled_at is None or tee_interval_minutes is None: return None return scheduled_at + timedelta(minutes=(sequence - 1) * tee_interval_minutes) class MatchParticipantOut(BaseModel): id: str team_side: str team_roster_id: str player_name: str tee_id: str tee_name: str class MatchCreate(BaseModel): sequence: int team_a_id: str team_b_id: str class MatchOut(BaseModel): id: str sequence: int team_a_id: str team_b_id: str status_text: str | None points_side_a: float | None points_side_b: float | None leading_side: str | None tee_time: datetime | None participants: list[MatchParticipantOut] @router.post( "/orgs/{organization_id}/sessions/{session_id}/matches", response_model=MatchOut, status_code=201, ) async def create_match( session_id: str, body: MatchCreate, organization_id: str = Depends(get_authorized_org), ) -> MatchOut: async with org_connection(organization_id) as conn, translate_db_errors(): session = await conn.fetchrow( """ SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes FROM session WHERE id = $1 """, session_id, ) if session is None: raise app_error(404, "NOT_FOUND", "Økten finnes ikke.") # Matchens FK garanterer bare at lagene er gyldige lag i ORG-en, ikke i # DENNE turneringen — sjekk det eksplisitt her. Stoler på DB CHECK # (team_a_id <> team_b_id) for distinkthet, dupliserer den ikke. team_count = await conn.fetchval( "SELECT count(*) FROM team WHERE tournament_id = $1 AND id = ANY($2::uuid[])", session["tournament_id"], [body.team_a_id, body.team_b_id], ) if team_count != 2: raise app_error(400, "VALIDATION_FAILED", "Begge lagene må tilhøre øktens turnering.") row = await conn.fetchrow( """ INSERT INTO match (organization_id, session_id, sequence, team_a_id, team_b_id) VALUES ($1, $2, $3, $4, $5) RETURNING id::text, sequence, team_a_id::text, team_b_id::text, status_text, points_side_a::float AS points_side_a, points_side_b::float AS points_side_b, leading_side, tee_time_override """, organization_id, session_id, body.sequence, body.team_a_id, body.team_b_id, ) tee_time = _compute_tee_time( session["scheduled_at"], session["tee_interval_minutes"], row["sequence"], row["tee_time_override"] ) return MatchOut(**dict(row), tee_time=tee_time, participants=[]) async def fetch_matches( conn, organization_id: str, session_id: str, user_id: str | None ) -> list[MatchOut]: """Delt mellom den innloggede list_matches under og den offentlige /public/tournaments/{id}/sessions/{id}/matches (registration.py, tilskuer-rolle/ADR-026). `user_id=None` for en anonym/ikke-tilknyttet offentlig leser -- own_team_ids() håndterer det (tom mengde, ser kun avslørte matcher, se blind_draw.py).""" session = await conn.fetchrow( """ SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes FROM session WHERE id = $1 """, session_id, ) if session is None: raise app_error(404, "NOT_FOUND", "Økten finnes ikke.") matches = await conn.fetch( """ SELECT id::text, sequence, team_a_id::text, team_b_id::text, status_text, points_side_a::float AS points_side_a, points_side_b::float AS points_side_b, leading_side, tee_time_override FROM match WHERE session_id = $1 ORDER BY sequence """, session_id, ) match_ids = [m["id"] for m in matches] locked = await locked_team_ids(conn, session_id) revealed = len(locked) >= 2 # Tomt sett hvis revealed (ubrukt da $2=true gjør ANY-leddet irrelevant). own = ( set() if revealed else await own_team_ids(conn, organization_id, session["tournament_id"], user_id) ) participant_rows = await conn.fetch( """ SELECT mp.match_id::text AS match_id, mp.id::text, mp.team_side::text, mp.team_roster_id::text, p.display_name AS player_name, mp.tee_id::text, tee.name AS tee_name FROM match_participant mp JOIN team_roster tr ON tr.id = mp.team_roster_id JOIN player p ON p.id = tr.player_id JOIN tee ON tee.id = mp.tee_id WHERE mp.match_id = ANY($1::uuid[]) AND ($2 OR tr.team_id::text = ANY($3::text[])) """, match_ids, revealed, list(own), ) by_match: dict[str, list[MatchParticipantOut]] = {mid: [] for mid in match_ids} for r in participant_rows: by_match[r["match_id"]].append( MatchParticipantOut( id=r["id"], team_side=r["team_side"], team_roster_id=r["team_roster_id"], player_name=r["player_name"], tee_id=r["tee_id"], tee_name=r["tee_name"], ) ) return [ MatchOut( id=m["id"], sequence=m["sequence"], team_a_id=m["team_a_id"], team_b_id=m["team_b_id"], status_text=m["status_text"], points_side_a=m["points_side_a"], points_side_b=m["points_side_b"], leading_side=m["leading_side"], tee_time=_compute_tee_time( session["scheduled_at"], session["tee_interval_minutes"], m["sequence"], m["tee_time_override"] ), participants=by_match[m["id"]], ) for m in matches ] @router.get( "/orgs/{organization_id}/sessions/{session_id}/matches", response_model=list[MatchOut], ) async def list_matches( session_id: str, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> list[MatchOut]: async with org_connection(organization_id) as conn: return await fetch_matches(conn, organization_id, session_id, user.user_id) class ParticipantCreate(BaseModel): team_side: str = Field(pattern="^[ab]$") team_roster_id: str tee_id: str @router.post( "/orgs/{organization_id}/matches/{match_id}/participants", response_model=MatchParticipantOut, status_code=201, ) async def add_participant( match_id: str, body: ParticipantCreate, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> MatchParticipantOut: async with org_connection(organization_id) as conn, translate_db_errors(): match = await conn.fetchrow( """ SELECT session_id::text AS session_id, team_a_id::text AS team_a_id, team_b_id::text AS team_b_id FROM match WHERE id = $1 """, match_id, ) if match is None: raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.") expected_team_id = match["team_a_id"] if body.team_side == "a" else match["team_b_id"] roster_team_id = await conn.fetchval( "SELECT team_id::text FROM team_roster WHERE id = $1", body.team_roster_id ) if roster_team_id is None: raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.") if roster_team_id != expected_team_id: raise app_error( 400, "MISMATCHED_SIDE", "team_roster_id tilhører ikke laget på angitt side i denne matchen." ) session = await conn.fetchrow( """ SELECT course_id::text AS course_id, format, hole_config::text AS hole_config, allowance_override::text AS allowance_override FROM session WHERE id = $1 """, match["session_id"], ) tee_course_id = await conn.fetchval( "SELECT course_id::text FROM tee WHERE id = $1", body.tee_id ) if tee_course_id is None or tee_course_id != session["course_id"]: raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke øktens bane.") if not await user_is_team_captain(conn, organization_id, expected_team_id, user.user_id): raise app_error( 403, "NOT_TEAM_CAPTAIN", "Du må være kaptein for laget (eller organisasjonsadministrator) for å gjøre dette." ) locked = await locked_team_ids(conn, match["session_id"]) if expected_team_id in locked: raise app_error( 409, "ALREADY_LOCKED", "Laget har allerede låst oppstillingen for denne økten." ) # Beregnet HELT FØR innsetting -- se app/handicap.py sin # compute_and_store_side_handicaps, som ellers krasjer rått # (TypeError i handicap_engine.py sin course_handicap_raw) på en # spiller uten handicap_index når use_handicap er på (standard). # Fant under scratch-testing av blind draw-skjermen -- reell, # preeksisterende feil, ikke noe denne runden introduserte. allowance_override = ( json.loads(session["allowance_override"]) if session["allowance_override"] else None ) config = parse_allowance_config(session["format"], allowance_override) if config.use_handicap: handicap_index_snapshot = await conn.fetchval( "SELECT handicap_index_snapshot FROM team_roster WHERE id = $1", body.team_roster_id ) if handicap_index_snapshot is None: raise app_error( 400, "VALIDATION_FAILED", "Denne spilleren mangler handicap-indeks -- sett en verdi på spilleren " "før hen kan legges til i en handicap-basert økt.", ) row = await conn.fetchrow( """ WITH inserted AS ( INSERT INTO match_participant (organization_id, match_id, team_side, team_roster_id, tee_id) VALUES ($1, $2, $3, $4, $5) RETURNING id, team_side, team_roster_id, tee_id ) SELECT inserted.id::text, inserted.team_side::text, inserted.team_roster_id::text, p.display_name AS player_name, inserted.tee_id::text, tee.name AS tee_name FROM inserted JOIN team_roster tr ON tr.id = inserted.team_roster_id JOIN player p ON p.id = tr.player_id JOIN tee ON tee.id = inserted.tee_id """, organization_id, match_id, body.team_side, body.team_roster_id, body.tee_id, ) # Beregn/lagre course_handicap/playing_handicap nå som deltakeren er # satt inn. Funksjonen avgjør selv om siden er "klar" (singles/ # fourball: alltid; foursome/greensome/scramble: kun når siden er # komplett) -- se app/handicap.py. `config` allerede parset over. await compute_and_store_side_handicaps( conn, match_id, body.team_side, session["format"], session["hole_config"], config ) return MatchParticipantOut(**dict(row)) @router.delete( "/orgs/{organization_id}/matches/{match_id}/participants/{participant_id}", status_code=204, ) async def remove_participant( match_id: str, participant_id: str, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> None: """Fjern et deltaker-valg FØR laget har låst -- lar en kaptein endre seg (velge en annen spiller/tee for samme plass) uten å etterlate en foreldreløs rad, siden det ikke finnes noen PATCH på match_participant. Reist av brukeren rett før blind draw-skjermen (som lar spiller/tee endres fritt i UI-et helt frem til låsing).""" async with org_connection(organization_id) as conn, translate_db_errors(): row = await conn.fetchrow( """ SELECT mp.team_side::text AS team_side, m.session_id::text AS session_id, m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id FROM match_participant mp JOIN match m ON m.id = mp.match_id WHERE mp.id = $1 AND mp.match_id = $2 """, participant_id, match_id, ) if row is None: raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.") team_id = row["team_a_id"] if row["team_side"] == "a" else row["team_b_id"] if not await user_is_team_captain(conn, organization_id, team_id, user.user_id): raise app_error( 403, "NOT_TEAM_CAPTAIN", "Du må være kaptein for laget (eller organisasjonsadministrator) for å gjøre dette." ) locked = await locked_team_ids(conn, row["session_id"]) if team_id in locked: raise app_error( 409, "ALREADY_LOCKED", "Laget har allerede låst oppstillingen for denne økten." ) await conn.execute("DELETE FROM match_participant WHERE id = $1", participant_id) class LockCreate(BaseModel): team_id: str class LockOut(BaseModel): team_id: str locked_at: str @router.post( "/orgs/{organization_id}/sessions/{session_id}/lock", response_model=LockOut, status_code=201, ) async def lock_lineup( session_id: str, body: LockCreate, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> LockOut: async with org_connection(organization_id) as conn, translate_db_errors(): session = await conn.fetchrow("SELECT id FROM session WHERE id = $1", session_id) if session is None: raise app_error(404, "NOT_FOUND", "Økten finnes ikke.") if not await user_is_team_captain(conn, organization_id, body.team_id, user.user_id): raise app_error( 403, "NOT_TEAM_CAPTAIN", "Du må være kaptein for laget (eller organisasjonsadministrator) for å gjøre dette." ) # UNIQUE(session_id, team_id) gir 409 via translate_db_errors ved # dobbel-lås — ingen manuell sjekk nødvendig. row = await conn.fetchrow( """ INSERT INTO lineup_lock (organization_id, session_id, team_id, locked_by) VALUES ($1, $2, $3, $4) RETURNING team_id::text, locked_at::text """, organization_id, session_id, body.team_id, user.user_id, ) return LockOut(**dict(row))