teecup/app/routers/tournaments.py
Erol Haagenrud fa6a1fa78d Legg til turnering-presentasjon: hero-bilde, sponsorer, synlighet, påmelding
Backend for dette (hero_image_key, sponsor-CRUD, visibility/description/
registrering) har vært klart og live siden ADR-018 (2026-07-18), men ingen
organisator-skjerm satte noensinne disse feltene - alt var 100% API-only.
Ny tournament-presentation.tsx dekker begge turneringstyper (delt
tournament-tabell, ikke format_type-spesifikt): full side for
lagturneringer (ny rute), embeddet som fjerde fane for individuelle
turneringer. Lagt til i alle fire eksisterende nav-rader.

To små backend-tillegg uten migrasjon: hero_image_url/logo_url som
beregnede felt (samme mønster som avatar_url), og en DELETE-endepunkt for
hero-bilde (fantes fra før kun opplasting).

Fant og rettet en ekte mobil-layoutbug under scratch-verifisering:
sponsor-raden klemte navnet til nesten ingenting på 390px viewport -
samme klasse trunkeringsdefekt som leaderboard-omskrivingen tidligere
denne uken, men på et annet sted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 19:03:53 +02:00

1665 lines
65 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
import traceback
from datetime import date, datetime, timedelta, timezone
import asyncpg
from fastapi import APIRouter, Depends, UploadFile
from pydantic import BaseModel, Field
from handicap_engine import allocate_over_played_holes
from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..blind_draw import locked_team_ids, own_team_ids
from ..config import settings
from ..db import org_connection
from ..email import send_session_result_email
from ..errors import app_error, translate_db_errors
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
from .auth import _hash_secret
from ..team_authz import is_org_member, user_is_team_captain, user_is_tournament_participant
from .scoring import apply_concession, played_hole_numbers, 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
# ADR-037: 'team' (dagens Ryder Cup-modell, uendret default) eller
# 'individual' (flatt felt, egen tabellstruktur -- se
# 040_individual_tournaments.sql). scoring_method er kun meningsfullt
# for individual, håndhevet i app-laget når runder/score bygges på den.
format_type: str = Field(default="team", pattern="^(team|individual)$")
scoring_method: str | None = Field(
default=None, pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag)$"
)
# KUN meningsfullt når scoring_method='bingo_bango_bongo' -- valgfri
# organisator-innstilling (samme som frittstående runders round.
# bbb_sweep_bonus_enabled), ikke fast påslått.
bbb_sweep_bonus_enabled: bool = False
_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, format_type, scoring_method, bbb_sweep_bonus_enabled
"""
# 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
hero_image_url: str | None
join_code: str
format_type: str
scoring_method: str | None
bbb_sweep_bonus_enabled: bool
def _tournament_from_row(row) -> Tournament:
"""Samme mønster som auth.py sin avatar_url: DB-kolonnen forblir
`hero_image_key` (den nøytrale, lagrings-uavhengige verdien), men
responsen får i tillegg en ferdig-konstruert URL -- organisator-
frontend skal aldri selv måtte kjenne til MinIO-bucket/base-URL."""
data = dict(row)
key = data["hero_image_key"]
return Tournament(**data, hero_image_url=storage.public_url(key) if key else None)
@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, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled
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_from_row(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, format_type,
scoring_method, bbb_sweep_bonus_enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING {_TOURNAMENT_COLUMNS}
""",
organization_id,
body.name,
body.start_date,
body.end_date,
_generate_join_code(),
body.format_type,
body.scoring_method,
body.bbb_sweep_bonus_enabled,
)
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_from_row(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)$")
format_type: str | None = Field(default=None, pattern="^(team|individual)$")
scoring_method: str | None = Field(
default=None, pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag)$"
)
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
bbb_sweep_bonus_enabled: 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_from_row(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_from_row(row)
@router.delete(
"/orgs/{organization_id}/tournaments/{tournament_id}/hero-image",
response_model=Tournament,
)
async def delete_hero_image(
tournament_id: str,
organization_id: str = Depends(get_authorized_org),
) -> Tournament:
"""Samme mønster som DELETE /auth/profile/avatar -- nullstiller kun
referansen, sletter ikke selve MinIO-objektet (samme aksepterte
forenkling som avatar-fjerning)."""
async with org_connection(organization_id) as conn:
row = await conn.fetchrow(
f"UPDATE tournament SET hero_image_key = NULL WHERE id = $1 RETURNING {_TOURNAMENT_COLUMNS}",
tournament_id,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
return _tournament_from_row(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,
user: CurrentUser = Depends(get_current_user),
) -> list[Team]:
"""2026-07-21 (deltaker-tilgang-runden): org-medlem ELLER deltaker i
denne turneringen, samme mønster som scoring.py sin get_scorecard --
laste team-navn/-farge er en forutsetning for at scorekort-siden i
det hele tatt kan rendres for en deltaker uten org-medlemskap."""
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.")
if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
conn, organization_id, tournament_id, user.user_id
):
raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
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,
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))
class RosterMove(BaseModel):
target_team_id: str
@router.post(
"/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}/move",
response_model=RosterEntry,
)
async def move_roster_entry(
team_id: str,
roster_id: str,
body: RosterMove,
organization_id: str = Depends(get_authorized_org),
) -> RosterEntry:
"""FEATURE_BACKLOG.md "Turneringsoppsett: flytte/slette spillere mellom
lag" (notert 2026-07-25) -- atomisk flytting i én operasjon i stedet for
to separate DELETE+POST-kall (unngår en mellomtilstand der spilleren
midlertidig ikke er rostret noe sted)."""
async with org_connection(organization_id) as conn, translate_db_errors():
current = await conn.fetchrow(
"SELECT tr.id, t.tournament_id::text AS tournament_id "
"FROM team_roster tr JOIN team t ON t.id = tr.team_id "
"WHERE tr.id = $1 AND tr.team_id = $2",
roster_id,
team_id,
)
if current is None:
raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.")
if body.target_team_id == team_id:
raise app_error(400, "VALIDATION_FAILED", "Spilleren er allerede på dette laget.")
target_team = await conn.fetchrow(
"SELECT id FROM team WHERE id = $1 AND tournament_id = $2",
body.target_team_id,
current["tournament_id"],
)
if target_team is None:
raise app_error(404, "NOT_FOUND", "Mållaget finnes ikke i denne turneringen.")
# Roster-raden er referert av match_participant.team_roster_id (ON
# DELETE RESTRICT) -- en spiller som allerede er satt opp i minst én
# match kan ikke flyttes trygt (matchens team_side ville da ikke
# lenger stemme med spillerens faktiske lag). Krev at spilleren
# fjernes fra matchen(e) FØRST, i stedet for å tillate en
# inkonsistent mellomtilstand.
already_in_match = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM match_participant WHERE team_roster_id = $1)",
roster_id,
)
if already_in_match:
raise app_error(
409,
"ALREADY_IN_MATCH",
"Spilleren er allerede lagt til i en match og kan ikke flyttes -- "
"fjern spilleren fra matchen(e) først.",
)
# Kaptein-merket følger IKKE med til det nye laget -- "kun én
# kaptein per lag" (ADR-023) håndheves allerede ELLERS via
# update_roster_entry, men en flyttet spiller skal aldri bli
# kaptein på et nytt lag uten et eksplisitt valg der.
row = await conn.fetchrow(
"""
WITH moved AS (
UPDATE team_roster
SET team_id = $1, is_captain = false
WHERE id = $2
RETURNING id, player_id, handicap_index_snapshot, is_captain
)
SELECT moved.id::text, moved.player_id::text,
moved.handicap_index_snapshot::float AS handicap_index_snapshot,
moved.is_captain, p.display_name
FROM moved JOIN player p ON p.id = moved.player_id
""",
body.target_team_id,
roster_id,
)
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
# KUN meningsfullt (og påkrevd) når format='shamble' -- antall av
# sidens individuelle resultater som teller per hull ("beste N av M").
shamble_best_n: int | None = Field(default=None, ge=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
shamble_best_n: int | None
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"],
shamble_best_n=row["shamble_best_n"],
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, shamble_best_n
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,
user: CurrentUser = Depends(get_current_user),
) -> list[SessionOut]:
"""2026-07-21 (deltaker-tilgang-runden): org-medlem ELLER deltaker i
denne turneringen, samme mønster som list_teams over -- innholdet er
for øvrig identisk med det offentlige `/public/.../sessions`-
endepunktet (ADR-018) for enhver som allerede kan se turneringen, så
dette utvider ikke hva som er synlig, kun HVEM som kan nå det via
denne org-scopede stien."""
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.")
if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
conn, organization_id, tournament_id, user.user_id
):
raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
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, shamble_best_n)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13, $14)
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, shamble_best_n
""",
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,
body.shamble_best_n,
)
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, shamble_best_n
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)
# ---------------------------------------------------------------------------
# Midlertidige spillere: etter-runde scorekort + innloggingsinvitasjon,
# økt-nivå (FEATURE_BACKLOG.md, avklart 2026-07-28, migrasjon 034).
#
# Eksplisitt organisator-handling (ikke en automatisk bakgrunnsjobb, se
# begrunnelse i backloggen) -- sender KUN til spillere UTEN egen konto ennå
# (player.user_id IS NULL) og med en registrert e-post, og KUN én gang per
# (økt, deltaker) via match_participant.invitation_sent_at. Locale er
# bevisst alltid 'nb' -- en spiller uten konto har ingen lagret
# språkpreferanse å lese.
# ---------------------------------------------------------------------------
class SendInvitationsOut(BaseModel):
sent: int
skipped_has_account: int
skipped_no_email: int
skipped_already_sent: int
@router.post(
"/orgs/{organization_id}/sessions/{session_id}/send-scorecard-invitations",
response_model=SendInvitationsOut,
)
async def send_scorecard_invitations(
session_id: str,
organization_id: str = Depends(get_authorized_org),
) -> SendInvitationsOut:
async with org_connection(organization_id) as conn:
session = await conn.fetchrow(
"SELECT tournament_id::text AS tournament_id, name FROM session WHERE id = $1",
session_id,
)
if session is None:
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
tournament_name = await conn.fetchval(
"SELECT name FROM tournament WHERE id = $1", session["tournament_id"]
)
session_label = (session["name"] or "").strip() or "en økt"
rows = await conn.fetch(
"""
SELECT mp.id::text AS participant_id, mp.invitation_sent_at,
p.display_name, p.email, p.user_id::text AS user_id,
m.status_text
FROM match_participant mp
JOIN match m ON m.id = mp.match_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,
)
sent = 0
skipped_has_account = 0
skipped_no_email = 0
skipped_already_sent = 0
for r in rows:
if r["user_id"] is not None:
skipped_has_account += 1
continue
if not r["email"]:
skipped_no_email += 1
continue
if r["invitation_sent_at"] is not None:
skipped_already_sent += 1
continue
# Kun meningsfull for individuell-ball-formater (singles/
# fourball) -- for delt-ball-formater er match_participant_id
# ALDRI satt i hole_score (ADR/scoring.py), så SUM blir naturlig
# NULL der uten noen egen format-sjekk her.
gross = await conn.fetchval(
"SELECT SUM(gross_strokes) FROM hole_score WHERE match_participant_id = $1",
r["participant_id"],
)
if r["status_text"]:
summary = f"Du spilte i {session_label} i {tournament_name} -- resultatet ble {r['status_text']}."
else:
summary = f"Du var satt opp for {session_label} i {tournament_name}."
if gross is not None:
summary += f" Du hadde {gross} slag totalt."
email = r["email"].strip().lower()
now = datetime.now(timezone.utc)
raw_token = secrets.token_urlsafe(32)
expires_at = now + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
# Samme "ugyldiggjør eldre uforbrukte lenker"-mønster som
# request_magic_link (routers/auth.py) -- begrenser vinduet en
# eldre, lekket lenke kan misbrukes i.
await conn.execute(
"UPDATE magic_link_token SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL",
email,
)
await conn.execute(
"INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, 'nb')",
email,
_hash_secret(raw_token),
expires_at,
)
if settings.SMTP_CONFIGURED:
try:
await send_session_result_email(
email, r["display_name"], tournament_name, summary, raw_token, "nb"
)
except Exception:
# Se app/email.py sitt mønster -- driftsfeil i
# utsendingen skal aldri hindre resten av utsendingen
# (token-raden består uansett, lenken virker fortsatt
# hvis spilleren ber om en ny normalt).
traceback.print_exc()
elif settings.DEV_LOG_MAGIC_LINKS:
# Samme "[DEV] Magic link for ..."-linjeformat som
# request_magic_link (routers/auth.py) FØRST -- slik at
# eksisterende dev-/test-verktøy som harvester token derfra
# fungerer uendret her også -- pluss en egen, lesbar
# oppsummeringslinje.
print(f"[DEV] Magic link for {email} (nb): {raw_token}", flush=True)
print(f"[DEV] Scorekort-invitasjon til {email}: {summary}", flush=True)
await conn.execute(
"UPDATE match_participant SET invitation_sent_at = now() WHERE id = $1",
r["participant_id"],
)
sent += 1
return SendInvitationsOut(
sent=sent,
skipped_has_account=skipped_has_account,
skipped_no_email=skipped_no_email,
skipped_already_sent=skipped_already_sent,
)
# ---------------------------------------------------------------------------
# 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
logo_url: str | None
def _sponsor_from_row(row) -> Sponsor:
data = dict(row)
key = data["logo_key"]
return Sponsor(**data, logo_url=storage.public_url(key) if key else 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_from_row(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_from_row(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_from_row(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_from_row(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)
# ---------------------------------------------------------------------------
# Individuell rangering PER ØKT (FEATURE_BACKLOG.md "Leaderboard for runder
# og turneringer", 2026-07-25/28 oppfølging) -- ved siden av, ikke i stedet
# for, lag-poeng-leaderboardet over. Bevisst AVGRENSET til én økt (ikke
# summert på tvers av turneringen, se avklaring i CLAUDE.md) -- og KUN
# meningsfullt for scoring_mode='stroke'-økter i individuell-ball-format
# (singles/fourball, se scoring.py sin _INDIVIDUAL_FORMATS) siden delt-ball-
# formater og hole_result-modus ikke har noen individuell brutto-score.
# ---------------------------------------------------------------------------
class IndividualLeaderboardEntry(BaseModel):
participant_id: str
display_name: str
team_id: str
team_name: str
team_color: str | None
thru: int
gross_score: int | None
gross_to_par: int | None
net_score: int | None
net_to_par: int | None
total_points: int | None
class IndividualLeaderboard(BaseModel):
session_id: str
format: str
entries: list[IndividualLeaderboardEntry]
async def fetch_individual_leaderboard(
conn, organization_id: str, session_id: str, viewer_user_id: str | None
) -> IndividualLeaderboard:
session = await conn.fetchrow(
"""
SELECT tournament_id::text AS tournament_id, format, scoring_mode,
hole_config::text AS hole_config, 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.")
if session["scoring_mode"] != "stroke" or session["format"] not in ("singles", "fourball"):
raise app_error(
400,
"VALIDATION_FAILED",
"Individuell rangering er kun tilgjengelig for slagspill-økter i format singles eller fourball.",
)
hole_rows = await conn.fetch(
"SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
session["course_id"],
)
if len(hole_rows) != 18:
raise app_error(400, "VALIDATION_FAILED", "Banen mangler registrerte hull.")
par_by_hole = {r["hole_number"]: r["par"] for r in hole_rows}
all_18_si = [r["stroke_index"] for r in hole_rows]
played = played_hole_numbers(session["hole_config"])
# Samme reveal-respekterende synlighet som fetch_matches (ADR-013/026):
# egen side FØR reveal, hele feltet ETTER.
locked = await locked_team_ids(conn, session_id)
revealed = len(locked) >= 2
own = (
set()
if revealed
else await own_team_ids(conn, organization_id, session["tournament_id"], viewer_user_id)
)
participant_rows = await conn.fetch(
"""
SELECT mp.id::text AS participant_id, mp.playing_handicap,
tr.team_id::text AS team_id, t.name AS team_name, t.color AS team_color,
p.display_name
FROM match_participant mp
JOIN match m ON m.id = mp.match_id
JOIN team_roster tr ON tr.id = mp.team_roster_id
JOIN team t ON t.id = tr.team_id
JOIN player p ON p.id = tr.player_id
WHERE m.session_id = $1 AND ($2 OR tr.team_id::text = ANY($3::text[]))
ORDER BY p.display_name
""",
session_id,
revealed,
list(own),
)
entries: list[IndividualLeaderboardEntry] = []
for r in participant_rows:
score_rows = await conn.fetch(
"SELECT hole_number, gross_strokes FROM hole_score "
"WHERE match_participant_id = $1 ORDER BY hole_number",
r["participant_id"],
)
gross_score = gross_to_par = net_score = net_to_par = total_points = None
if score_rows:
gross_score = sum(s["gross_strokes"] for s in score_rows)
par_total = sum(par_by_hole[s["hole_number"]] for s in score_rows)
gross_to_par = gross_score - par_total
if r["playing_handicap"] is not None:
strokes_list = allocate_over_played_holes(r["playing_handicap"], all_18_si, played)
strokes_by_hole = dict(zip(played, strokes_list))
strokes_received_total = sum(strokes_by_hole[s["hole_number"]] for s in score_rows)
net_score = gross_score - strokes_received_total
net_to_par = gross_to_par - strokes_received_total
# Samme stableford-formel som round-leaderboardets
# total_points (rounds.py) -- 2 poeng for netto par, +/-1
# per slag, gulvet på 0.
total_points = sum(
max(
0,
par_by_hole[s["hole_number"]]
- (s["gross_strokes"] - strokes_by_hole[s["hole_number"]])
+ 2,
)
for s in score_rows
)
entries.append(
IndividualLeaderboardEntry(
participant_id=r["participant_id"],
display_name=r["display_name"],
team_id=r["team_id"],
team_name=r["team_name"],
team_color=r["team_color"],
thru=len(score_rows),
gross_score=gross_score,
gross_to_par=gross_to_par,
net_score=net_score,
net_to_par=net_to_par,
total_points=total_points,
)
)
return IndividualLeaderboard(session_id=session_id, format=session["format"], entries=entries)
@router.get(
"/orgs/{organization_id}/sessions/{session_id}/individual-leaderboard",
response_model=IndividualLeaderboard,
)
async def get_individual_leaderboard(
session_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> IndividualLeaderboard:
async with org_connection(organization_id) as conn:
return await fetch_individual_leaderboard(conn, organization_id, session_id, user.user_id)