""" 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 fastapi import APIRouter, Depends, HTTPException, status 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 translate_db_errors router = APIRouter() # --------------------------------------------------------------------------- # Turneringer (flyttet uendret fra tidligere main.py) # --------------------------------------------------------------------------- class TournamentCreate(BaseModel): name: str = Field(min_length=1, max_length=200) class Tournament(BaseModel): id: str name: str status: str @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 FROM tournament ORDER BY created_at DESC" ) return [Tournament(id=r["id"], name=r["name"], status=r["status"]) 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: row = await conn.fetchrow( """ INSERT INTO tournament (organization_id, name) VALUES ($1, $2) RETURNING id::text, name, status::text """, organization_id, body.name, ) return Tournament(id=row["id"], name=row["name"], status=row["status"]) # --------------------------------------------------------------------------- # 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 HTTPException(status.HTTP_404_NOT_FOUND, detail="Turneringen finnes ikke.") existing = await conn.fetchval( "SELECT count(*) FROM team WHERE tournament_id = $1", tournament_id ) if existing >= 2: raise HTTPException( status.HTTP_409_CONFLICT, detail="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 HTTPException(status.HTTP_404_NOT_FOUND, detail="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 HTTPException( status.HTTP_409_CONFLICT, detail="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" 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 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"], 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 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10) 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 """, 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, ) return _session_out(row, set())