""" Turneringsoppsett: turneringer, lag, roster, økter. Autorisasjonsgrense (se plan): kun org-medlemskap (get_authorized_org) kreves her — dette er organisator-arbeid. Å kreve at brukeren allerede er rostret på et lag ville vært sirkulært for lag-/roster-opprettelse (ingen kan være rostret på et lag som ikke finnes ennå). """ import json from datetime import date, datetime from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from ..auth import get_authorized_org from ..blind_draw import locked_team_ids from ..db import org_connection from ..errors import app_error, translate_db_errors router = APIRouter() # --------------------------------------------------------------------------- # Turneringer # --------------------------------------------------------------------------- class TournamentCreate(BaseModel): name: str = Field(min_length=1, max_length=200) start_date: date | None = None end_date: date | None = None class Tournament(BaseModel): id: str name: str status: str start_date: date | None end_date: date | None @router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament]) async def list_tournaments( organization_id: str = Depends(get_authorized_org), ) -> list[Tournament]: async with org_connection(organization_id) as conn: rows = await conn.fetch( """ SELECT id::text, name, status::text, start_date, end_date FROM tournament ORDER BY created_at DESC """ ) return [Tournament(**dict(r)) for r in rows] @router.post("/orgs/{organization_id}/tournaments", response_model=Tournament, status_code=201) async def create_tournament( body: TournamentCreate, organization_id: str = Depends(get_authorized_org), ) -> Tournament: async with org_connection(organization_id) as conn, translate_db_errors(): row = await conn.fetchrow( """ INSERT INTO tournament (organization_id, name, start_date, end_date) VALUES ($1, $2, $3, $4) RETURNING id::text, name, status::text, start_date, end_date """, organization_id, body.name, body.start_date, body.end_date, ) return Tournament(**dict(row)) # --------------------------------------------------------------------------- # Lag (ADR-011: maks to lag per turnering, håndhevet her — ikke i skjemaet — # slik at match-modellen kan holdes generell for fremtidige turneringstyper) # --------------------------------------------------------------------------- class TeamCreate(BaseModel): name: str = Field(min_length=1, max_length=200) color: str | None = None class Team(BaseModel): id: str name: str color: str | None @router.get("/orgs/{organization_id}/tournaments/{tournament_id}/teams", response_model=list[Team]) async def list_teams( tournament_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[Team]: async with org_connection(organization_id) as conn: rows = await conn.fetch( "SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at", tournament_id, ) return [Team(**dict(r)) for r in rows] @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/teams", response_model=Team, status_code=201, ) async def create_team( tournament_id: str, body: TeamCreate, organization_id: str = Depends(get_authorized_org), ) -> Team: async with org_connection(organization_id) as conn, translate_db_errors(): # Lås turneringsraden slik at to samtidige forespørsler ikke begge kan # lese "1 lag" og begge sette inn et andre/tredje lag (TOCTOU). locked = await conn.fetchrow( "SELECT id FROM tournament WHERE id = $1 AND organization_id = $2 FOR UPDATE", tournament_id, organization_id, ) if locked is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") existing = await conn.fetchval( "SELECT count(*) FROM team WHERE tournament_id = $1", tournament_id ) if existing >= 2: raise app_error( 409, "LIMIT_REACHED", "Turneringen har allerede to lag (ADR-011: nøyaktig to lag)." ) row = await conn.fetchrow( """ INSERT INTO team (organization_id, tournament_id, name, color) VALUES ($1, $2, $3, $4) RETURNING id::text, name, color """, organization_id, tournament_id, body.name, body.color, ) return Team(**dict(row)) # --------------------------------------------------------------------------- # Roster (spiller-pool per lag, ADR-007) # --------------------------------------------------------------------------- class RosterEntryCreate(BaseModel): player_id: str is_captain: bool = False # None => fryses fra player.handicap_index ved innsetting (ADR-007). handicap_index_snapshot: float | None = None class RosterEntry(BaseModel): id: str player_id: str display_name: str handicap_index_snapshot: float | None is_captain: bool @router.get("/orgs/{organization_id}/teams/{team_id}/roster", response_model=list[RosterEntry]) async def list_roster( team_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[RosterEntry]: async with org_connection(organization_id) as conn: rows = await conn.fetch( """ SELECT tr.id::text, tr.player_id::text, tr.handicap_index_snapshot::float AS handicap_index_snapshot, tr.is_captain, p.display_name FROM team_roster tr JOIN player p ON p.id = tr.player_id WHERE tr.team_id = $1 ORDER BY p.display_name """, team_id, ) return [RosterEntry(**dict(r)) for r in rows] @router.post( "/orgs/{organization_id}/teams/{team_id}/roster", response_model=RosterEntry, status_code=201, ) async def add_roster_entry( team_id: str, body: RosterEntryCreate, organization_id: str = Depends(get_authorized_org), ) -> RosterEntry: async with org_connection(organization_id) as conn, translate_db_errors(): team = await conn.fetchrow("SELECT tournament_id FROM team WHERE id = $1", team_id) if team is None: raise app_error(404, "NOT_FOUND", "Laget finnes ikke.") # Skjemaet håndhever kun unikhet PER lag (UNIQUE(team_id, player_id)). # Uten denne sjekken kunne en spiller rostres på BEGGE lag i samme # turnering, og own_team_ids() (blind draw-filteret i matches.py) ville # da regnet hen som "eget lag" på begge sider — hen ville sett # motstanderens skjulte oppstilling før lås (ADR-013). already_elsewhere = await conn.fetchval( """ SELECT EXISTS ( SELECT 1 FROM team_roster tr JOIN team t ON t.id = tr.team_id WHERE t.tournament_id = $1 AND tr.player_id = $2 AND tr.team_id <> $3 ) """, team["tournament_id"], body.player_id, team_id, ) if already_elsewhere: raise app_error( 409, "DUPLICATE", "Spilleren er allerede rostret på det andre laget i denne turneringen.", ) snapshot = body.handicap_index_snapshot if snapshot is None: snapshot = await conn.fetchval( "SELECT handicap_index FROM player WHERE id = $1", body.player_id ) row = await conn.fetchrow( """ WITH inserted AS ( INSERT INTO team_roster (organization_id, team_id, player_id, handicap_index_snapshot, is_captain) VALUES ($1, $2, $3, $4, $5) RETURNING id, player_id, handicap_index_snapshot, is_captain ) SELECT inserted.id::text, inserted.player_id::text, inserted.handicap_index_snapshot::float AS handicap_index_snapshot, inserted.is_captain, p.display_name FROM inserted JOIN player p ON p.id = inserted.player_id """, organization_id, team_id, body.player_id, snapshot, body.is_captain, ) return RosterEntry(**dict(row)) # --------------------------------------------------------------------------- # Økter (ADR-007: turnering = ordnet sekvens av økter) # --------------------------------------------------------------------------- class SessionCreate(BaseModel): sequence: int name: str | None = None format: str hole_config: str = "full_18" course_id: str points_per_match: float = 1.0 allowance_override: dict | None = None # ADR-012: 'stroke' (hole_score, motoren utleder netto) eller 'hole_result' # (match_hole_result, bare hvem som vant hullet). DB-default er 'stroke'. scoring_mode: str = "stroke" # Klokkeslett-modell (se plan): én starttid + ett intervall per økt. # match.tee_time utledes av disse i matches.py, IKKE lagret per match. scheduled_at: datetime | None = None tee_interval_minutes: int | None = None start_hole: int = 1 class SessionOut(BaseModel): id: str sequence: int name: str | None format: str hole_config: str course_id: str points_per_match: float allowance_override: dict | None scoring_mode: str scheduled_at: datetime | None tee_interval_minutes: int | None start_hole: int locked_team_ids: list[str] revealed: bool def _session_out(row, locked: set[str]) -> SessionOut: return SessionOut( id=row["id"], sequence=row["sequence"], name=row["name"], format=row["format"], hole_config=row["hole_config"], course_id=row["course_id"], points_per_match=row["points_per_match"], allowance_override=json.loads(row["allowance_override"]) if row["allowance_override"] else None, scoring_mode=row["scoring_mode"], scheduled_at=row["scheduled_at"], tee_interval_minutes=row["tee_interval_minutes"], start_hole=row["start_hole"], locked_team_ids=sorted(locked), revealed=len(locked) >= 2, ) @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/sessions", response_model=list[SessionOut], ) async def list_sessions( tournament_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[SessionOut]: async with org_connection(organization_id) as conn: rows = await conn.fetch( """ SELECT id::text, sequence, name, format, hole_config::text AS hole_config, course_id::text, points_per_match::float AS points_per_match, allowance_override::text AS allowance_override, scoring_mode, scheduled_at, tee_interval_minutes, start_hole FROM session WHERE tournament_id = $1 ORDER BY sequence """, tournament_id, ) result = [] for r in rows: locked = await locked_team_ids(conn, r["id"]) result.append(_session_out(r, locked)) return result @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/sessions", response_model=SessionOut, status_code=201, ) async def create_session( tournament_id: str, body: SessionCreate, organization_id: str = Depends(get_authorized_org), ) -> SessionOut: allowance_json = json.dumps(body.allowance_override) if body.allowance_override is not None else None async with org_connection(organization_id) as conn, translate_db_errors(): row = await conn.fetchrow( """ INSERT INTO session (organization_id, tournament_id, sequence, name, format, hole_config, course_id, points_per_match, allowance_override, scoring_mode, scheduled_at, tee_interval_minutes, start_hole) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13) RETURNING id::text, sequence, name, format, hole_config::text AS hole_config, course_id::text, points_per_match::float AS points_per_match, allowance_override::text AS allowance_override, scoring_mode, scheduled_at, tee_interval_minutes, start_hole """, organization_id, tournament_id, body.sequence, body.name, body.format, body.hole_config, body.course_id, body.points_per_match, allowance_json, body.scoring_mode, body.scheduled_at, body.tee_interval_minutes, body.start_hole, ) return _session_out(row, set())