teecup/app/routers/tournaments.py
Erol Haagenrud 16f3a163c1 Update Todos
Diagnostisere dashboard-dato-bug (allerede kjent, bekreftet på nytt)

Fiks: utled turnering-datospenn fra øktenes scheduled_at i list_tournaments

Bygg PATCH /orgs/{id}/players/{id} (rediger spillerpool)

Bygg frontend: Rediger spiller-skjema i tournament-detail.tsx

Scratch-verifisere begge fiksene

Typesjekket frontend-build

Oppdatere .md-filer + be om utrullingsbekreftelse
Logikken bak "Ingen datoer satt": dashbord-kortet leser et helt eget felt på selve turneringen (start_date/end_date), atskilt fra øktenes scheduled_at som Program-fanen viser. Ingen UI har noensinne satt det feltet, så det var alltid tomt — uavhengig av at "11. juli" faktisk var satt på øktene. Fikset: kortet henter nå datospennet fra øktene i stedet (11.–12. juli her, siden du har to økter samme dag).

Rediger spiller: bygget. Ny "Rediger spiller" i "⋮"-menyen på hver spiller — navn, HCP og kjønn kan endres. Én viktig presisering vist rett i skjemaet: dette endrer spilleren i poolen (gjelder fremtidig rostring), men endrer ikke HCP-tallet som allerede er frosset på et lag i en turnering (det er bevisst, for at et allerede spilt/pågående oppgjør ikke skal endre seg i etterkant). Skal du rette et tall som allerede vises på et lag, må du fortsatt fjerne og legge til spilleren på nytt — si fra om du vil at jeg bygger en direkte "rett dette laget sitt tall her og nå"-vei også.

Begge deler er scratch-verifisert (9 automatiserte sjekker, inkludert at frysing-regelen fortsatt holder) og typesjekket bygget. Ingen migrasjon.
2026-07-20 10:01:44 +02:00

1197 lines
45 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
import secrets
from datetime import date, datetime
import asyncpg
from fastapi import APIRouter, Depends, UploadFile
from pydantic import BaseModel, Field
from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..blind_draw import locked_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
from .scoring import apply_concession, recompute_and_cache_match_state
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,
join_code
"""
# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A).
_JOIN_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
def _generate_join_code() -> str:
return "".join(secrets.choice(_JOIN_CODE_ALPHABET) for _ in range(6))
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
join_code: str
@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
async def list_tournaments(
organization_id: str = Depends(get_authorized_org),
) -> list[Tournament]:
# ADR-030: dashbord-kortet skal vise turneringens FAKTISKE datospenn.
# `tournament.start_date`/`end_date` er egne, eksplisitte felt (ADR-015)
# som ingen UI noensinne har hatt en vei til å SETTE -- alltid NULL i
# praksis. Eksplisitt satt verdi vinner hvis den noensinne blir satt
# (fremtidig PATCH-vei), ellers utledes spennet fra øktenes
# `scheduled_at` -- informasjonen finnes jo allerede der.
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
"""
SELECT t.id::text, t.name, t.status::text,
COALESCE(t.start_date, s.min_date) AS start_date,
COALESCE(t.end_date, s.max_date) AS end_date,
t.visibility, t.description, t.registration_deadline,
t.registration_capacity, t.registration_overflow_policy,
t.registration_requires_approval, t.hero_image_key,
t.join_code
FROM tournament t
LEFT JOIN (
SELECT tournament_id,
min(scheduled_at)::date AS min_date,
max(scheduled_at)::date AS max_date
FROM session
WHERE scheduled_at IS NOT NULL
GROUP BY tournament_id
) s ON s.tournament_id = t.id
ORDER BY t.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():
# join_code er globalt unikt (migrasjon 011) -- kollisjon er
# astronomisk usannsynlig (33^6 ≈ 1,3 mrd. kombinasjoner mot et
# lite antall turneringer totalt), men en liten retry-løkke koster
# ingenting og gjør oppførselen presis i stedet for å anta.
row = None
for _ in range(5):
try:
row = await conn.fetchrow(
f"""
INSERT INTO tournament (organization_id, name, start_date, end_date, join_code)
VALUES ($1, $2, $3, $4, $5)
RETURNING {_TOURNAMENT_COLUMNS}
""",
organization_id,
body.name,
body.start_date,
body.end_date,
_generate_join_code(),
)
break
except asyncpg.UniqueViolationError:
continue
if row is None:
raise app_error(500, "VALIDATION_FAILED", "Klarte ikke å generere en unik invitasjonskode. Prøv igjen.")
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)."""
status: str | None = Field(default=None, pattern="^(draft|active|completed|archived)$")
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.
# `status` er -- ulikt de andre feltene her -- en EKTE Postgres ENUM-type
# (tournament_status, migrasjon 001), ikke text+CHECK. asyncpg sender
# parameteren som ukjent/text; et eksplisitt cast trengs for at
# tildelingen skal løses riktig.
set_clauses = [
f"{key} = ${i}::tournament_status" if key == "status" else 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))
# ---------------------------------------------------------------------------
# Turnering-nivå walkover/konsesjon (ADR-024)
#
# v1 er låst til nøyaktig to lag (ADR-011), så det finnes bare ÉN motstander
# uansett hvor mange sesjoner/matcher turneringen har -- "gi opp turneringen"
# er derfor bare "gi opp hver ikke-avgjorte match laget har" i én operasjon,
# gjenbruker match-nivå-logikken (apply_concession, scoring.py) per match.
# Samme autorisasjon som enkelt-match-konsesjon: kaptein for laget som gir
# seg selv, eller org-eier/admin.
# ---------------------------------------------------------------------------
class ConcedeTournamentRequest(BaseModel):
conceding_team_id: str
class ConcedeTournamentOut(BaseModel):
matches_conceded: int
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/concede",
response_model=ConcedeTournamentOut,
status_code=201,
)
async def concede_tournament(
tournament_id: str,
body: ConcedeTournamentRequest,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> ConcedeTournamentOut:
async with org_connection(organization_id) as conn, translate_db_errors():
team = await conn.fetchrow(
"SELECT tournament_id::text AS tournament_id FROM team WHERE id = $1",
body.conceding_team_id,
)
if team is None or team["tournament_id"] != tournament_id:
raise app_error(404, "NOT_FOUND", "Laget finnes ikke i denne turneringen.")
if not await user_is_team_captain(conn, organization_id, body.conceding_team_id, user.user_id):
raise app_error(
403,
"NOT_TEAM_CAPTAIN",
"Du må være kaptein for laget som gir seg (eller organisasjonsadministrator) for å gi opp turneringen.",
)
# FOR UPDATE OF m: låser alle berørte match-rader FØR de endres, samme
# race-vern som enkelt-match-konsesjon og recompute_and_cache_match_state.
matches = await conn.fetch(
"""
SELECT m.id::text AS id, m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
s.points_per_match::float AS points_per_match
FROM match m
JOIN session s ON s.id = m.session_id
WHERE s.tournament_id = $1
AND m.points_side_a IS NULL
AND (m.team_a_id = $2 OR m.team_b_id = $2)
FOR UPDATE OF m
""",
tournament_id,
body.conceding_team_id,
)
for m in matches:
side = "a" if m["team_a_id"] == body.conceding_team_id else "b"
await apply_concession(conn, m["id"], side, m["points_per_match"], tournament_id)
return ConcedeTournamentOut(matches_conceded=len(matches))
# ---------------------------------------------------------------------------
# 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
)
# Samme "kun én kaptein per lag"-invariant som update_roster_entry
# (ADR-023) -- håndhevet her også, selv om dagens frontend aldri
# sender is_captain=true ved opprettelse, for at API-et er korrekt
# uavhengig av klient.
if body.is_captain:
await conn.execute(
"UPDATE team_roster SET is_captain = false WHERE team_id = $1", team_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:
# 2026-07-19 (Brukerroller-runden, ADR-023): kaptein er nå en reell
# autorisasjonsrolle (se app/team_authz.py sin user_is_team_captain), så
# "kun én kaptein per lag" håndheves eksplisitt her -- å sette en NY
# kaptein fjerner automatisk merket fra en ev. tidligere kaptein på
# SAMME lag, i samme transaksjon (org_connection åpner allerede én).
# Å fjerne kapteinmerket (is_captain=false) rører ingen andre rader.
async with org_connection(organization_id) as conn:
if body.is_captain:
await conn.execute(
"UPDATE team_roster SET is_captain = false WHERE team_id = $1 AND id <> $2",
team_id,
roster_id,
)
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())
class SessionUpdate(BaseModel):
"""Bevisst IKKE format/scoring_mode/hole_config ennå -- disse har dypere
strukturelle konsekvenser for allerede registrerte scores (bytter man
scoring_mode havner gamle oppføringer i feil tabell; bytter man format
endres reglene for match_participant) som fortjener egen, senere design.
course_id er den ENESTE feltet som krever spesialhåndtering her (se
_remap_course) -- resten er en vanlig exclude_unset-oppdatering."""
name: str | None = None
scheduled_at: datetime | None = None
tee_interval_minutes: int | None = None
start_hole: int | None = Field(default=None, ge=1, le=18)
points_per_match: float | None = None
allowance_override: dict | None = None
course_id: str | None = None
async def _remap_course(conn, session_id: str, new_course_id: str) -> None:
"""Reist av brukeren (2026-07-18): en organisator oppdager MIDT I RUNDEN
at feil bane var satt opp -- de faktisk registrerte slagene er ekte og
skal IKKE røres, men `match_participant.tee_id` peker til en tee som
hører til DEN GAMLE banen, og må flyttes til en tilsvarende tee på den
nye for at handicap-beregningen skal bli riktig videre.
Matcher på tee-navn (case-insensitive) -- kjønn hører til RATINGEN, ikke
selve utslaget (ADR-029), så remap-en sjekker i tillegg at den nye
tee-en faktisk har en full_18-rating for hver berørte spillers kjønn.
Finnes ingen tilsvarende, gyldig tee på den nye banen for én eller
flere spillere, avvises HELE bane-byttet tydelig FØR noe skrives --
heller stoppe enn å gjette feil (samme "fail loudly" prinsipp som
ADR-019 sin importvalidering).
"""
course_exists = await conn.fetchval("SELECT id FROM course WHERE id = $1", new_course_id)
if course_exists is None:
raise app_error(404, "NOT_FOUND", "Banen finnes ikke.")
participants = await conn.fetch(
"""
SELECT mp.id::text AS id, t.name AS tee_name, p.gender AS player_gender
FROM match_participant mp
JOIN match m ON m.id = mp.match_id
JOIN tee t ON t.id = mp.tee_id
JOIN team_roster tr ON tr.id = mp.team_roster_id
JOIN player p ON p.id = tr.player_id
WHERE m.session_id = $1
""",
session_id,
)
remap: dict[str, str] = {}
missing: set[str] = set()
for p in participants:
new_tee_id = await conn.fetchval(
"""
SELECT t.id::text FROM tee t
JOIN tee_rating tr ON tr.tee_id = t.id AND tr.scope = 'full_18'
AND tr.gender IS NOT DISTINCT FROM $3
WHERE t.course_id = $1 AND lower(t.name) = lower($2)
""",
new_course_id,
p["tee_name"],
p["player_gender"],
)
if new_tee_id is None:
missing.add(p["tee_name"])
else:
remap[p["id"]] = new_tee_id
if missing:
raise app_error(
400,
"VALIDATION_FAILED",
"Den nye banen mangler en tee som matcher: "
+ ", ".join(sorted(missing))
+ ". Legg til tilsvarende tee på den nye banen før du bytter.",
)
for participant_id, new_tee_id in remap.items():
await conn.execute("UPDATE match_participant SET tee_id = $1 WHERE id = $2", new_tee_id, participant_id)
await conn.execute("UPDATE session SET course_id = $1 WHERE id = $2", new_course_id, session_id)
async def _recompute_session_matches(
conn, session_id: str, format_: str, allowance_override: dict | None
) -> None:
"""Regner om handicap + matchstatus for HVER match i økten -- brukt etter
et bane- eller allowance-bytte. Bevisst UAVHENGIG av om en match allerede
er avgjort (brukeren bekreftet eksplisitt: en bane-korrigering skal kunne
endre et allerede cachet resultat, ikke bare blokkeres av den vanlige
ALREADY_DECIDED-sperren som gjelder NYE slag-innsendinger)."""
config = parse_allowance_config(format_, allowance_override)
matches = await conn.fetch("SELECT id::text AS id FROM match WHERE session_id = $1", session_id)
for m in matches:
for side in ("a", "b"):
await compute_and_store_side_handicaps(conn, m["id"], side, format_, config)
await recompute_and_cache_match_state(conn, m["id"])
@router.patch("/orgs/{organization_id}/sessions/{session_id}", response_model=SessionOut)
async def update_session(
session_id: str,
body: SessionUpdate,
organization_id: str = Depends(get_authorized_org),
) -> SessionOut:
updates = body.model_dump(exclude_unset=True)
if not updates:
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
async with org_connection(organization_id) as conn, translate_db_errors():
session = await conn.fetchrow(
"SELECT format, course_id::text AS course_id FROM session WHERE id = $1",
session_id,
)
if session is None:
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
new_course_id = updates.pop("course_id", None)
allowance_or_points_changed = "allowance_override" in updates or "points_per_match" in updates
if updates:
set_clauses = []
values: list = []
for i, (key, value) in enumerate(updates.items(), start=1):
if key == "allowance_override":
set_clauses.append(f"allowance_override = ${i}::jsonb")
values.append(json.dumps(value) if value is not None else None)
else:
set_clauses.append(f"{key} = ${i}")
values.append(value)
values.append(session_id)
await conn.execute(
f"UPDATE session SET {', '.join(set_clauses)} WHERE id = ${len(values)}",
*values,
)
if new_course_id is not None and new_course_id != session["course_id"]:
await _remap_course(conn, session_id, new_course_id)
allowance_or_points_changed = True # banebytte krever alltid omregning
if allowance_or_points_changed:
current = await conn.fetchrow(
"SELECT allowance_override::text AS allowance_override FROM session WHERE id = $1",
session_id,
)
current_override = json.loads(current["allowance_override"]) if current["allowance_override"] else None
await _recompute_session_matches(
conn, session_id, session["format"], current_override
)
row = await conn.fetchrow(
"""
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 id = $1
""",
session_id,
)
locked = await locked_team_ids(conn, session_id)
return _session_out(row, locked)
@router.delete("/orgs/{organization_id}/sessions/{session_id}", status_code=204)
async def delete_session(
session_id: str,
organization_id: str = Depends(get_authorized_org),
) -> None:
"""Kun tomme økter kan slettes (ingen matcher opprettet ennå) -- dekker
"jeg satte opp feil økt, fjern den" FØR den er tatt i bruk. En økt med
matcher (og dermed potensielt registrerte scores/poeng) må rettes med
PATCH (bane-/felt-korrigering) i stedet for å kunne slettes -- hindrer
at noen ved et uhell sletter ekte kamper/resultater."""
async with org_connection(organization_id) as conn:
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.")
match_count = await conn.fetchval("SELECT count(*) FROM match WHERE session_id = $1", session_id)
if match_count > 0:
raise app_error(
409,
"VALIDATION_FAILED",
"Økten har allerede matcher og kan ikke slettes -- bruk PATCH for å korrigere den i stedet.",
)
await conn.execute("DELETE FROM session WHERE id = $1", session_id)
# ---------------------------------------------------------------------------
# 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.")
# ---------------------------------------------------------------------------
# Leaderboard (turnering-total, summert på tvers av alle økter/matcher)
#
# `match.team_a_id`/`team_b_id` settes PER MATCH ved opprettelse (matches.py),
# ikke garantert konsistent på tvers av matcher -- en organisator kunne i
# prinsippet opprettet match 1 med team_a=Rød og match 3 med team_a=Blå. Derfor
# summeres ALT (både total og per-økt) på det EKTE lag-id-et, aldri på
# "a"/"b"-labelen -- se points_by_team under.
# ---------------------------------------------------------------------------
class TeamStanding(BaseModel):
team_id: str
team_name: str
color: str | None
points: float
# ADR-020 Beslutning B: hvis pågående matcher holder seg som de står nå --
# ikke-avgjorte matcher bidrar med full poengsum til leading_side (delt
# likt ved "AS"/ikke startet). Avgjorte matcher bidrar likt til begge.
projected_points: float
class SessionStanding(BaseModel):
session_id: str
sequence: int
name: str | None
points_by_team: dict[str, float]
projected_points_by_team: dict[str, float]
matches_total: int
matches_decided: int
class Leaderboard(BaseModel):
teams: list[TeamStanding]
matches_total: int
matches_decided: int
sessions: list[SessionStanding]
async def fetch_leaderboard(conn, tournament_id: str) -> Leaderboard:
"""Delt mellom den innloggede (get_leaderboard under) og den offentlige
/public/tournaments/{id}/leaderboard (registration.py, tilskuer-rolle) --
ingen spiller-nivå-data i responsmodellene (kun lag-id/-navn/-farge/
poeng), så ingen ekstra synlighetsfiltrering trengs utover den vanlige
tournament.visibility-sjekken den offentlige ruten selv gjør FØR denne
kalles."""
teams = await conn.fetch(
"SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at",
tournament_id,
)
if len(teams) != 2:
raise app_error(400, "VALIDATION_FAILED", "Turneringen har ikke nøyaktig to lag ennå.")
rows = await conn.fetch(
"""
SELECT s.id::text AS session_id, s.sequence, s.name,
s.points_per_match::float AS points_per_match,
m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
m.points_side_a::float AS points_side_a, m.points_side_b::float AS points_side_b,
m.leading_side
FROM match m
JOIN session s ON s.id = m.session_id
WHERE s.tournament_id = $1
ORDER BY s.sequence, m.sequence
""",
tournament_id,
)
team_totals: dict[str, float] = {t["id"]: 0.0 for t in teams}
team_projected: dict[str, float] = {t["id"]: 0.0 for t in teams}
session_order: list[str] = []
sessions: dict[str, dict] = {}
matches_total = 0
matches_decided = 0
for r in rows:
matches_total += 1
sid = r["session_id"]
if sid not in sessions:
session_order.append(sid)
sessions[sid] = {
"session_id": sid,
"sequence": r["sequence"],
"name": r["name"],
"points_by_team": {t["id"]: 0.0 for t in teams},
"projected_points_by_team": {t["id"]: 0.0 for t in teams},
"matches_total": 0,
"matches_decided": 0,
}
sessions[sid]["matches_total"] += 1
# Projisert bidrag: avgjorte matcher gir samme tall som faktisk
# (de ER allerede det de blir). Ikke-avgjorte matcher gir hele
# øktens poengsum til den som leder NÅ, delt 0,5/0,5 ved "AS"
# eller en match som ennå ikke har noen registrerte hull.
if r["points_side_a"] is not None:
proj_a, proj_b = r["points_side_a"], r["points_side_b"]
else:
ppm = r["points_per_match"]
if r["leading_side"] == "a":
proj_a, proj_b = ppm, 0.0
elif r["leading_side"] == "b":
proj_a, proj_b = 0.0, ppm
else:
proj_a = proj_b = ppm / 2
team_projected[r["team_a_id"]] = team_projected.get(r["team_a_id"], 0.0) + proj_a
team_projected[r["team_b_id"]] = team_projected.get(r["team_b_id"], 0.0) + proj_b
sessions[sid]["projected_points_by_team"][r["team_a_id"]] = (
sessions[sid]["projected_points_by_team"].get(r["team_a_id"], 0.0) + proj_a
)
sessions[sid]["projected_points_by_team"][r["team_b_id"]] = (
sessions[sid]["projected_points_by_team"].get(r["team_b_id"], 0.0) + proj_b
)
if r["points_side_a"] is None:
continue
matches_decided += 1
sessions[sid]["matches_decided"] += 1
team_totals[r["team_a_id"]] = team_totals.get(r["team_a_id"], 0.0) + r["points_side_a"]
team_totals[r["team_b_id"]] = team_totals.get(r["team_b_id"], 0.0) + r["points_side_b"]
sessions[sid]["points_by_team"][r["team_a_id"]] = (
sessions[sid]["points_by_team"].get(r["team_a_id"], 0.0) + r["points_side_a"]
)
sessions[sid]["points_by_team"][r["team_b_id"]] = (
sessions[sid]["points_by_team"].get(r["team_b_id"], 0.0) + r["points_side_b"]
)
team_standings = [
TeamStanding(
team_id=t["id"],
team_name=t["name"],
color=t["color"],
points=team_totals[t["id"]],
projected_points=team_projected[t["id"]],
)
for t in teams
]
return Leaderboard(
teams=team_standings,
matches_total=matches_total,
matches_decided=matches_decided,
sessions=[SessionStanding(**sessions[sid]) for sid in sorted(session_order, key=lambda s: sessions[s]["sequence"])],
)
@router.get(
"/orgs/{organization_id}/tournaments/{tournament_id}/leaderboard",
response_model=Leaderboard,
)
async def get_leaderboard(
tournament_id: str,
organization_id: str = Depends(get_authorized_org),
) -> Leaderboard:
async with org_connection(organization_id) as conn:
return await fetch_leaderboard(conn, tournament_id)