teecup/app/routers/tournaments.py
Erol Haagenrud b3659cc953 Scoring-runden er ferdig og verifisert for ekte (samme scratch-container-mønster, denne gangen med hele /opt/teecup montert siden handicap_engine.py er et toppnivå-søskenmodul til app-pakken). Alle 10 planlagte tester besto, inkludert fourball better-ball-aggregering, poeng-caching ved tidlig avgjørelse, den sammenhengende-prefiks-regelen, og ADR-014-bryteren. Fant og fikset underveis at SessionCreate manglet scoring_mode helt — hole_result-modus kunne aldri vært brukt via API-et uten den fiksen.
Spørsmålene dine avdekket en reell bug (ikke bare et åpent spørsmål): siden ingenting eksplisitt lukker en match, kan noen fortsette å legge inn hull etter at matchen matematisk er avgjort, og det kan endre den cachede marginen ved neste omregning. Fanget i FEATURE_BACKLOG.md.

Basert på svarene dine:

Match-lås bygges snart — neste lille runde, tetter spøkelses-hull-buggen.
Walkover/konsesjon venter til brukerroller (kaptein/organisator) er avgjort.
Alt er dokumentert i FEATURE_BACKLOG.md under en ny seksjon, og CLAUDE.md er oppdatert.
2026-07-16 14:38:42 +02:00

352 lines
12 KiB
Python

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