""" 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, UploadFile 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 from .. import storage 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 _TOURNAMENT_COLUMNS = """ id::text, name, status::text, start_date, end_date, visibility, description, registration_deadline, registration_capacity, registration_overflow_policy, registration_requires_approval, hero_image_key """ class Tournament(BaseModel): id: str name: str status: str start_date: date | None end_date: date | None visibility: str description: str | None registration_deadline: datetime | None registration_capacity: int | None registration_overflow_policy: str registration_requires_approval: bool hero_image_key: str | 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( f"SELECT {_TOURNAMENT_COLUMNS} 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( f""" INSERT INTO tournament (organization_id, name, start_date, end_date) VALUES ($1, $2, $3, $4) RETURNING {_TOURNAMENT_COLUMNS} """, organization_id, body.name, body.start_date, body.end_date, ) return Tournament(**dict(row)) class TournamentUpdate(BaseModel): """Alle felt valgfrie -- kun de som faktisk sendes med blir endret (PATCH-semantikk via Pydantic sin exclude_unset, se update_tournament).""" visibility: str | None = Field(default=None, pattern="^(public|org|participants)$") description: str | None = None registration_deadline: datetime | None = None registration_capacity: int | None = Field(default=None, gt=0) registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$") registration_requires_approval: bool | None = None # hero_image_key er BEVISST IKKE med her -- den settes kun via # upload_hero_image under, aldri som en fritekst-streng klienten sender, # siden den nå alltid kommer fra en ekte, server-konvertert opplasting. @router.patch("/orgs/{organization_id}/tournaments/{tournament_id}", response_model=Tournament) async def update_tournament( tournament_id: str, body: TournamentUpdate, organization_id: str = Depends(get_authorized_org), ) -> Tournament: # exclude_unset: kun felt klienten FAKTISK sendte skal skrives -- et # utelatt felt skal IKKE nullstilles (ekte PATCH-semantikk, ikke PUT). updates = body.model_dump(exclude_unset=True) if not updates: raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.") # Trygt mot injeksjon: nøklene kommer KUN fra TournamentUpdate sine egne # feltnavn (en fast, kjent hviteliste), aldri fra en rå streng brukeren # sender inn. set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)] values = list(updates.values()) values.append(tournament_id) async with org_connection(organization_id) as conn, translate_db_errors(): row = await conn.fetchrow( f""" UPDATE tournament SET {', '.join(set_clauses)} WHERE id = ${len(values)} RETURNING {_TOURNAMENT_COLUMNS} """, *values, ) if row is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") return Tournament(**dict(row)) @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/hero-image", response_model=Tournament, ) async def upload_hero_image( tournament_id: str, file: UploadFile, organization_id: str = Depends(get_authorized_org), ) -> Tournament: """Ekte multipart-opplasting (IKKE en presignert nettleser-URL): filen går gjennom API-et, som konverterer den til AVIF før den lagres i MinIO -- se app/storage.py sin moduldocstring for hvorfor.""" if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES: raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.") raw = await file.read(storage.MAX_UPLOAD_BYTES + 1) if len(raw) > storage.MAX_UPLOAD_BYTES: raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).") async with org_connection(organization_id) as conn: exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id) if exists is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") try: key = await storage.upload_image("tournaments", tournament_id, raw) except storage.InvalidImageError: raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.") row = await conn.fetchrow( f"UPDATE tournament SET hero_image_key = $1 WHERE id = $2 RETURNING {_TOURNAMENT_COLUMNS}", key, tournament_id, ) 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)) class RosterEntryUpdate(BaseModel): is_captain: bool @router.patch( "/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}", response_model=RosterEntry, ) async def update_roster_entry( team_id: str, roster_id: str, body: RosterEntryUpdate, organization_id: str = Depends(get_authorized_org), ) -> RosterEntry: # Bevisst enkelt: setter/fjerner kapteinmerket på NØYAKTIG denne raden, # håndhever ikke "kun én kaptein per lag" -- kaptein er i dag bare et # merke, ikke en egen autorisasjonsrolle (se FEATURE_BACKLOG). async with org_connection(organization_id) as conn: row = await conn.fetchrow( """ WITH updated AS ( UPDATE team_roster SET is_captain = $1 WHERE id = $2 AND team_id = $3 RETURNING id, player_id, handicap_index_snapshot, is_captain ) SELECT updated.id::text, updated.player_id::text, updated.handicap_index_snapshot::float AS handicap_index_snapshot, updated.is_captain, p.display_name FROM updated JOIN player p ON p.id = updated.player_id """, body.is_captain, roster_id, team_id, ) if row is None: raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.") return RosterEntry(**dict(row)) @router.delete( "/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}", status_code=204, ) async def delete_roster_entry( team_id: str, roster_id: str, organization_id: str = Depends(get_authorized_org), ) -> None: async with org_connection(organization_id) as conn: deleted = await conn.fetchval( "DELETE FROM team_roster WHERE id = $1 AND team_id = $2 RETURNING id", roster_id, team_id, ) if deleted is None: raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.") # --------------------------------------------------------------------------- # Ø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, ) async def _fetch_sessions(conn, tournament_id: str) -> list[SessionOut]: """Delt mellom den innloggede org-ruten under og det offentlige landingsside-endepunktet (app/routers/registration.py, ADR-018) -- samme spørring, samme blind draw-låsing, ikke duplisert to steder.""" 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.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: return await _fetch_sessions(conn, tournament_id) @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()) # --------------------------------------------------------------------------- # Sponsorer (ADR-018) -- navn+lenke+logo, alle aktive fra MinIO-runden. # --------------------------------------------------------------------------- class SponsorCreate(BaseModel): name: str = Field(min_length=1, max_length=200) url: str | None = None _SPONSOR_COLUMNS = "id::text, name, url, logo_key" class Sponsor(BaseModel): id: str name: str url: str | None logo_key: str | None @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/sponsors", response_model=list[Sponsor], ) async def list_sponsors( tournament_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[Sponsor]: async with org_connection(organization_id) as conn: rows = await conn.fetch( f"SELECT {_SPONSOR_COLUMNS} FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at", tournament_id, ) return [Sponsor(**dict(r)) for r in rows] @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/sponsors", response_model=Sponsor, status_code=201, ) async def add_sponsor( tournament_id: str, body: SponsorCreate, organization_id: str = Depends(get_authorized_org), ) -> Sponsor: async with org_connection(organization_id) as conn, translate_db_errors(): tournament = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id) if tournament is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") row = await conn.fetchrow( f""" INSERT INTO tournament_sponsor (organization_id, tournament_id, name, url) VALUES ($1, $2, $3, $4) RETURNING {_SPONSOR_COLUMNS} """, organization_id, tournament_id, body.name, body.url, ) return Sponsor(**dict(row)) class SponsorUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=200) url: str | None = None # logo_key er BEVISST IKKE med -- settes kun via upload_sponsor_logo # under, samme begrunnelse som tournament.hero_image_key over. @router.patch( "/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}", response_model=Sponsor, ) async def update_sponsor( tournament_id: str, sponsor_id: str, body: SponsorUpdate, organization_id: str = Depends(get_authorized_org), ) -> Sponsor: updates = body.model_dump(exclude_unset=True) if not updates: raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.") set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)] values = list(updates.values()) values.append(sponsor_id) values.append(tournament_id) async with org_connection(organization_id) as conn, translate_db_errors(): row = await conn.fetchrow( f""" UPDATE tournament_sponsor SET {', '.join(set_clauses)} WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)} RETURNING {_SPONSOR_COLUMNS} """, *values, ) if row is None: raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.") return Sponsor(**dict(row)) @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}/logo", response_model=Sponsor, ) async def upload_sponsor_logo( tournament_id: str, sponsor_id: str, file: UploadFile, organization_id: str = Depends(get_authorized_org), ) -> Sponsor: if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES: raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.") raw = await file.read(storage.MAX_UPLOAD_BYTES + 1) if len(raw) > storage.MAX_UPLOAD_BYTES: raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).") async with org_connection(organization_id) as conn: exists = await conn.fetchval( "SELECT id FROM tournament_sponsor WHERE id = $1 AND tournament_id = $2", sponsor_id, tournament_id, ) if exists is None: raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.") try: key = await storage.upload_image("sponsors", sponsor_id, raw) except storage.InvalidImageError: raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.") row = await conn.fetchrow( f"UPDATE tournament_sponsor SET logo_key = $1 WHERE id = $2 RETURNING {_SPONSOR_COLUMNS}", key, sponsor_id, ) return Sponsor(**dict(row)) @router.delete( "/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}", status_code=204, ) async def delete_sponsor( tournament_id: str, sponsor_id: str, organization_id: str = Depends(get_authorized_org), ) -> None: async with org_connection(organization_id) as conn: deleted = await conn.fetchval( "DELETE FROM tournament_sponsor WHERE id = $1 AND tournament_id = $2 RETURNING id", sponsor_id, tournament_id, ) if deleted is None: raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.")