teecup/app/routers/tournaments.py

1167 lines
44 KiB
Python
Raw Normal View History

"""
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
et lag ville vært sirkulært for lag-/roster-opprettelse (ingen kan være
rostret et lag som ikke finnes ennå).
"""
import json
import secrets
2026-07-17 21:40:42 +02:00
from datetime import date, datetime
import asyncpg
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend. Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
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
2026-07-17 21:40:42 +02:00
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
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend. Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
from .. import storage
router = APIRouter()
# ---------------------------------------------------------------------------
2026-07-17 21:40:42 +02:00
# Turneringer
# ---------------------------------------------------------------------------
class TournamentCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
2026-07-17 21:40:42 +02:00
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
2026-07-17 21:40:42 +02:00
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
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend. Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
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]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
f"SELECT {_TOURNAMENT_COLUMNS} FROM tournament ORDER BY created_at DESC"
)
2026-07-17 21:40:42 +02:00
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:
2026-07-17 21:40:42 +02:00
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.")
2026-07-17 21:40:42 +02:00
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
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend. Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
# 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))
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend. Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
@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:
2026-07-17 21:40:42 +02:00
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:
2026-07-17 21:40:42 +02:00
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:
2026-07-17 21:40:42 +02:00
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:
2026-07-17 21:40:42 +02:00
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
)
Nå dokumentasjonen er oppdatert. Klar for deploy — jeg viser planen først: Update Todos Legge til visuell hint på øktkort ("Sett opp flights") i program-skjermen Avklare designbeslutninger for Brukerroller med bruker Skrive team_authz.py på nytt (kaptein-only + match-deltaker-sjekk) Oppdatere matches.py til å bruke user_is_team_captain Oppdatere scoring.py til å bruke user_is_match_participant Håndheve 'kun én kaptein per lag' i tournaments.py PATCH/POST roster Sjekke ekte teecup_db for lag uten kaptein / med flere kapteiner (kun lesing) Skrive ADR-023 for Brukerroller-beslutningene Sette opp scratch MinIO + engangs API-container Kjøre autorisasjonstester (kaptein/deltaker) mot scratch-API-et Rydde opp scratch-infrastruktur Ekte typesjekket frontend-build (øktkort-hint) Deploy etter eksplisitt bekreftelse + oppdatere .md-filer Alt er scratch-verifisert. Klar for utrulling — planen: Ingen migrasjon (ren autorisasjonslogikk-endring, ingen skjema rørt). docker compose up -d --build teecup_api teecup_frontend mot ekte teeoff_default-nettverk — bygger og bytter ut begge containere. Endringen har reell konsekvens for ekte data akkurat nå: "De Unge" i "De Gamle er Eldst" har 0 kapteiner — de vil fortsette å kunne sette opp/føre score som før (fallback-regelen dekker dette), men fra nå av bør du utpeke en kaptein på det laget for at kaptein-rollen skal bety noe der òg. Etter deploy: sjekke /health + /dashboard fortsatt 200, teeoff.no upåvirket (samme som alle tidligere runder).
2026-07-19 11:41:57 +02:00
# 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:
Nå dokumentasjonen er oppdatert. Klar for deploy — jeg viser planen først: Update Todos Legge til visuell hint på øktkort ("Sett opp flights") i program-skjermen Avklare designbeslutninger for Brukerroller med bruker Skrive team_authz.py på nytt (kaptein-only + match-deltaker-sjekk) Oppdatere matches.py til å bruke user_is_team_captain Oppdatere scoring.py til å bruke user_is_match_participant Håndheve 'kun én kaptein per lag' i tournaments.py PATCH/POST roster Sjekke ekte teecup_db for lag uten kaptein / med flere kapteiner (kun lesing) Skrive ADR-023 for Brukerroller-beslutningene Sette opp scratch MinIO + engangs API-container Kjøre autorisasjonstester (kaptein/deltaker) mot scratch-API-et Rydde opp scratch-infrastruktur Ekte typesjekket frontend-build (øktkort-hint) Deploy etter eksplisitt bekreftelse + oppdatere .md-filer Alt er scratch-verifisert. Klar for utrulling — planen: Ingen migrasjon (ren autorisasjonslogikk-endring, ingen skjema rørt). docker compose up -d --build teecup_api teecup_frontend mot ekte teeoff_default-nettverk — bygger og bytter ut begge containere. Endringen har reell konsekvens for ekte data akkurat nå: "De Unge" i "De Gamle er Eldst" har 0 kapteiner — de vil fortsette å kunne sette opp/føre score som før (fallback-regelen dekker dette), men fra nå av bør du utpeke en kaptein på det laget for at kaptein-rollen skal bety noe der òg. Etter deploy: sjekke /health + /dashboard fortsatt 200, teeoff.no upåvirket (samme som alle tidligere runder).
2026-07-19 11:41:57 +02:00
# 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:
Nå dokumentasjonen er oppdatert. Klar for deploy — jeg viser planen først: Update Todos Legge til visuell hint på øktkort ("Sett opp flights") i program-skjermen Avklare designbeslutninger for Brukerroller med bruker Skrive team_authz.py på nytt (kaptein-only + match-deltaker-sjekk) Oppdatere matches.py til å bruke user_is_team_captain Oppdatere scoring.py til å bruke user_is_match_participant Håndheve 'kun én kaptein per lag' i tournaments.py PATCH/POST roster Sjekke ekte teecup_db for lag uten kaptein / med flere kapteiner (kun lesing) Skrive ADR-023 for Brukerroller-beslutningene Sette opp scratch MinIO + engangs API-container Kjøre autorisasjonstester (kaptein/deltaker) mot scratch-API-et Rydde opp scratch-infrastruktur Ekte typesjekket frontend-build (øktkort-hint) Deploy etter eksplisitt bekreftelse + oppdatere .md-filer Alt er scratch-verifisert. Klar for utrulling — planen: Ingen migrasjon (ren autorisasjonslogikk-endring, ingen skjema rørt). docker compose up -d --build teecup_api teecup_frontend mot ekte teeoff_default-nettverk — bygger og bytter ut begge containere. Endringen har reell konsekvens for ekte data akkurat nå: "De Unge" i "De Gamle er Eldst" har 0 kapteiner — de vil fortsette å kunne sette opp/føre score som før (fallback-regelen dekker dette), men fra nå av bør du utpeke en kaptein på det laget for at kaptein-rollen skal bety noe der òg. Etter deploy: sjekke /health + /dashboard fortsatt 200, teeoff.no upåvirket (samme som alle tidligere runder).
2026-07-19 11:41:57 +02:00
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"
2026-07-17 21:40:42 +02:00
# 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
2026-07-17 21:40:42 +02:00
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"],
2026-07-17 21:40:42 +02:00
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,
2026-07-17 21:40:42 +02:00
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,
2026-07-17 21:40:42 +02:00
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,
2026-07-17 21:40:42 +02:00
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 flyttes til en tilsvarende tee den
nye for at handicap-beregningen skal bli riktig videre.
Matcher tee-navn + kjønn (case-insensitive). Finnes ingen tilsvarende
tee 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, t.gender AS tee_gender
FROM match_participant mp
JOIN match m ON m.id = mp.match_id
JOIN tee t ON t.id = mp.tee_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 id::text FROM tee
WHERE course_id = $1 AND lower(name) = lower($2) AND gender IS NOT DISTINCT FROM $3
""",
new_course_id,
p["tee_name"],
p["tee_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, hole_config: 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_, hole_config, 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, 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.")
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"], session["hole_config"], 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) 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)
# ---------------------------------------------------------------------------
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend. Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
# 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
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend. Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
_SPONSOR_COLUMNS = "id::text, name, url, logo_key"
class Sponsor(BaseModel):
id: str
name: str
url: str | None
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend. Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
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(
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend. Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
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(
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend. Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
f"""
INSERT INTO tournament_sponsor (organization_id, tournament_id, name, url)
VALUES ($1, $2, $3, $4)
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend. Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
RETURNING {_SPONSOR_COLUMNS}
""",
organization_id,
tournament_id,
body.name,
body.url,
)
return Sponsor(**dict(row))
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend. Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
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), 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)