teecup/app/routers/registration.py

539 lines
22 KiB
Python
Raw Normal View History

"""
Offentlig, uautentisert registrerings- og landingsside-API (ADR-017, ADR-018).
Bevisst egen fil med prefiks /public/..., atskilt fra /orgs/... -- gjør
sikkerhetsgrensen eksplisitt i koden: disse endepunktene krever VERKEN
innlogging (get_current_user) ELLER org-medlemskap (get_authorized_org) for
å svare i det hele tatt.
To SECURITY DEFINER-broer (public_tournament_org, public_org_by_slug) er den
ENESTE måten disse endepunktene får vite riktig RLS-kontekst FØR den er
kjent -- se ADR-017 Beslutning E / ADR-018 Beslutning F.
VIKTIG (ADR-018 Beslutning B): RLS (org_isolation) beskytter kun TENANT-
grenser (org A ser aldri org B), ikke INNHOLDS-synlighet innenfor riktig
org-kontekst. `tournament.visibility` håndheves derfor eksplisitt her i
applikasjonslaget, hvert lese- OG skrive-endepunkt (registrering følger
samme grense som lesing, ADR-018 Beslutning D) -- RLS alene løser det ikke.
"""
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends
from pydantic import BaseModel, EmailStr, Field
from ..auth import CurrentUser, get_current_user_optional
from ..blind_draw import locked_team_ids
from ..db import org_connection, plain_connection
from ..errors import app_error, translate_db_errors
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
from .matches import MatchOut, fetch_matches
from .scoring import Scorecard, fetch_scorecard
from .tournaments import Leaderboard, SessionOut, _fetch_sessions, fetch_leaderboard
router = APIRouter(prefix="/public/tournaments", tags=["public-registration"])
org_router = APIRouter(prefix="/public/orgs", tags=["public-registration"])
async def resolve_org(tournament_id: str) -> str:
async with plain_connection() as conn:
org_id = await conn.fetchval("SELECT public_tournament_org($1)", tournament_id)
if org_id is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
return str(org_id)
async def is_participant(conn, user_id: str, organization_id: str, tournament_id: str) -> bool:
"""Har brukerens koblede spillerprofil (ADR-017 Beslutning B) en
registrering ELLER en rostret plass i NØYAKTIG denne turneringen?"""
return await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM player p
WHERE p.organization_id = $1 AND p.user_id = $2
AND (
EXISTS (
SELECT 1 FROM tournament_registration tr
WHERE tr.player_id = p.id AND tr.tournament_id = $3
)
OR EXISTS (
SELECT 1 FROM team_roster tro
JOIN team t ON t.id = tro.team_id
WHERE tro.player_id = p.id AND t.tournament_id = $3
)
)
)
""",
organization_id,
user_id,
tournament_id,
)
def code_matches(actual_join_code: str, provided_code: str | None) -> bool:
return provided_code is not None and provided_code.strip().lower() == actual_join_code.lower()
async def check_visibility(
conn,
visibility: str,
organization_id: str,
tournament_id: str,
user: CurrentUser | None,
) -> None:
if visibility == "public":
return
if user is not None:
is_member = await conn.fetchval(
"SELECT EXISTS (SELECT 1 FROM organization_membership WHERE user_id = $1 AND organization_id = $2)",
user.user_id,
organization_id,
)
if is_member:
return
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
# ADR-031: en faktisk DELTAKER skal aldri stenges ute av øktens
# egen turnering, uansett hvilken av de to ikke-offentlige tierene
# som er valgt -- visibility styrer eksponering mot UTENFORSTÅENDE,
# ikke mot spillere som faktisk er med. Utvidet fra kun
# visibility == "participants" (opprinnelig ADR-018 Beslutning C)
# da "Mine runder"-dashbordet gjorde det tydelig at 'org' (som er
# DEFAULT for enhver ny turnering) ellers ville stengt ute enhver
# spiller uten organisasjonsmedlemskap fra sin egen turnering.
if await is_participant(conn, user.user_id, organization_id, tournament_id):
return
raise app_error(403, "NOT_VISIBLE", "Du har ikke tilgang til denne turneringen.")
class JoinCodeResult(BaseModel):
tournament_id: str
organization_id: str
tournament_name: str
@router.get("/by-code/{code}", response_model=JoinCodeResult)
async def resolve_join_code(code: str) -> JoinCodeResult:
"""Løser en invitasjonskode til riktig turnering (ADR-020 Beslutning A) --
registreres FØR /{tournament_id} under, ellers ville "by-code" blitt
tolket som en tournament_id (ugyldig UUID -> 500, ikke 404).
Bevisst UAVHENGIG av tournament.visibility -- ingen check_visibility()-
kall her, i motsetning til alle andre endepunkter i denne filen. Koden
ER selve invitasjonen; å likevel kreve org-medlemskap/deltakerstatus
ville gjort den verdiløs nettopp for personer som mangler det."""
async with plain_connection() as conn:
tournament_id = await conn.fetchval("SELECT public_tournament_by_code($1)", code)
if tournament_id is None:
raise app_error(404, "NOT_FOUND", "Fant ingen turnering med denne koden.")
organization_id = await resolve_org(str(tournament_id))
async with org_connection(organization_id) as conn:
name = await conn.fetchval("SELECT name FROM tournament WHERE id = $1", tournament_id)
return JoinCodeResult(
tournament_id=str(tournament_id), organization_id=organization_id, tournament_name=name
)
class SponsorOut(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_url: str | None
class PublicTournamentInfo(BaseModel):
id: str
name: str
organization_name: str
status: str
visibility: str
description: str | None
start_date: date | None
end_date: date | None
registration_open: bool
registration_deadline: datetime | None
registration_capacity: int | None
confirmed_count: int
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_url: str | None
sponsors: list[SponsorOut]
@router.get("/{tournament_id}", response_model=PublicTournamentInfo)
async def get_public_tournament(
tournament_id: str,
code: str | None = None,
user: CurrentUser | None = Depends(get_current_user_optional),
) -> PublicTournamentInfo:
organization_id = await resolve_org(tournament_id)
async with org_connection(organization_id) as conn:
row = await conn.fetchrow(
"""
SELECT t.id::text, t.name, o.name AS organization_name, t.status::text,
t.visibility, t.description, t.hero_image_key, t.join_code,
t.start_date, t.end_date, t.registration_deadline,
t.registration_capacity,
(SELECT count(*)::int FROM tournament_registration tr
WHERE tr.tournament_id = t.id AND tr.status IN ('confirmed', 'pending')
) AS confirmed_count
FROM tournament t
JOIN organization o ON o.id = t.organization_id
WHERE t.id = $1
""",
tournament_id,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
# ADR-020 Beslutning A: en matchende kode overstyrer visibility helt.
if not code_matches(row["join_code"], code):
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
sponsor_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
"SELECT id::text, name, url, logo_key FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at",
tournament_id,
)
deadline = row["registration_deadline"]
deadline_passed = deadline is not None and deadline < datetime.now(timezone.utc)
return PublicTournamentInfo(
id=row["id"],
name=row["name"],
organization_name=row["organization_name"],
status=row["status"],
visibility=row["visibility"],
description=row["description"],
start_date=row["start_date"],
end_date=row["end_date"],
registration_open=not deadline_passed,
registration_deadline=deadline,
registration_capacity=row["registration_capacity"],
confirmed_count=row["confirmed_count"],
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_url=storage.public_url(row["hero_image_key"]) if row["hero_image_key"] else None,
sponsors=[
SponsorOut(
id=r["id"],
name=r["name"],
url=r["url"],
logo_url=storage.public_url(r["logo_key"]) if r["logo_key"] else None,
)
for r in sponsor_rows
],
)
@router.get("/{tournament_id}/sessions", response_model=list[SessionOut])
async def get_public_sessions(
tournament_id: str,
code: str | None = None,
user: CurrentUser | None = Depends(get_current_user_optional),
) -> list[SessionOut]:
organization_id = await resolve_org(tournament_id)
async with org_connection(organization_id) as conn:
row = await conn.fetchrow(
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
)
if row is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
if not code_matches(row["join_code"], code):
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
return await _fetch_sessions(conn, tournament_id)
# --- Tilskuer-rolle (ADR-026): leaderboard, matcher, scorekort -------------
#
# Samme synlighets-/kode-mønster som info-/sesjon-kallene over -- ingen ny
# mekanisme. Leaderboard har ingen spillernivå-data i det hele tatt (kun
# lag-id/-navn/-farge/poeng), så INGEN ekstra reveal-sjekk trengs der.
# Match-listen arver blind draw-skjulingen (ADR-013) automatisk via
# fetch_matches() sin egen own_team_ids()-håndtering (tom mengde for en
# anonym/uinnloggede leser -- ser bare avslørte matcher, akkurat som en
# tilfeldig org-medlem uten roster ville gjort). Scorekortet trenger en
# EKSPLISITT reveal-sjekk her (matchen kan i prinsippet ha registrerte hull
# før begge lag har låst -- ikke normal flyt, men ikke teknisk umulig).
@router.get("/{tournament_id}/leaderboard", response_model=Leaderboard)
async def get_public_leaderboard(
tournament_id: str,
code: str | None = None,
user: CurrentUser | None = Depends(get_current_user_optional),
) -> Leaderboard:
organization_id = await resolve_org(tournament_id)
async with org_connection(organization_id) as conn:
row = await conn.fetchrow(
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
)
if row is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
if not code_matches(row["join_code"], code):
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
return await fetch_leaderboard(conn, tournament_id)
@router.get(
"/{tournament_id}/sessions/{session_id}/matches", response_model=list[MatchOut]
)
async def get_public_matches(
tournament_id: str,
session_id: str,
code: str | None = None,
user: CurrentUser | None = Depends(get_current_user_optional),
) -> list[MatchOut]:
organization_id = await resolve_org(tournament_id)
async with org_connection(organization_id) as conn:
row = await conn.fetchrow(
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
)
if row is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
if not code_matches(row["join_code"], code):
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
# Sikkerhetskritisk: session_id må faktisk høre til DENNE turneringen
# -- org_connection() setter kun tenant-grensen (RLS), ikke at stiens
# to id-er faktisk henger sammen. Uten denne sjekken kunne noen med
# kode/tilgang til én offentlig turnering lese en HVILKEN SOM HELST
# økt i samme organisasjon (inkl. en privat en) ved å gjette id-en.
session_tournament = await conn.fetchval(
"SELECT tournament_id::text FROM session WHERE id = $1", session_id
)
if session_tournament != tournament_id:
raise app_error(404, "NOT_FOUND", "Økten finnes ikke i denne turneringen.")
user_id = user.user_id if user is not None else None
return await fetch_matches(conn, organization_id, session_id, user_id)
@router.get("/{tournament_id}/matches/{match_id}/scorecard", response_model=Scorecard)
async def get_public_scorecard(
tournament_id: str,
match_id: str,
code: str | None = None,
user: CurrentUser | None = Depends(get_current_user_optional),
) -> Scorecard:
organization_id = await resolve_org(tournament_id)
async with org_connection(organization_id) as conn:
row = await conn.fetchrow(
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
)
if row is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
if not code_matches(row["join_code"], code):
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
# Samme tenant-vs-sti-konsistenssjekk som matchlisten over, PLUSS en
# eksplisitt reveal-sjekk (blind draw, ADR-013) -- scorekortet skal
# aldri kunne leses offentlig før laget selv har avslørt oppstillingen.
match_row = await conn.fetchrow(
"""
SELECT s.tournament_id::text AS tournament_id, m.session_id::text AS session_id
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match_row is None or match_row["tournament_id"] != tournament_id:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke i denne turneringen.")
locked = await locked_team_ids(conn, match_row["session_id"])
if len(locked) < 2:
raise app_error(403, "NOT_VISIBLE", "Denne matchen er ikke avslørt ennå.")
return await fetch_scorecard(conn, match_id)
class RegisterRequest(BaseModel):
display_name: str = Field(min_length=1, max_length=200)
email: EmailStr | None = None
mobile: str | None = None
birth_date: date | None = None
nickname: str | None = None
country: str | None = None
club: str | None = None
club_member_number: str | None = None
gender: str | None = Field(default=None, pattern="^[mfx]$")
handicap_index: float | None = None
# API-et krever eksplisitt True -- ingen implisitt samtykke (ADR-017).
consent: bool
# ADR-020 Beslutning A: en matchende invitasjonskode overstyrer visibility.
code: str | None = None
class RegistrationResult(BaseModel):
id: str
status: str
@router.post("/{tournament_id}/register", response_model=RegistrationResult, status_code=201)
async def register_for_tournament(
tournament_id: str,
body: RegisterRequest,
user: CurrentUser | None = Depends(get_current_user_optional),
) -> RegistrationResult:
if not body.consent:
raise app_error(400, "VALIDATION_FAILED", "Samtykke må gis for å melde seg på.")
organization_id = await resolve_org(tournament_id)
async with org_connection(organization_id) as conn, translate_db_errors():
# Lås turneringsraden: forhindrer at to samtidige påmeldinger begge
# leser "under kapasitet" og begge kommer inn (TOCTOU) -- samme
# mønster som to-lags-grensen i tournaments.py sin create_team.
tournament = await conn.fetchrow(
"""
SELECT visibility, join_code, registration_deadline, registration_capacity,
registration_overflow_policy, registration_requires_approval
FROM tournament WHERE id = $1 FOR UPDATE
""",
tournament_id,
)
if tournament is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
# ADR-018 Beslutning D: registrering følger samme synlighetsgrense
# som selve landingssiden -- ingen særbehandling. ADR-020 Beslutning A:
# unntaket er en matchende invitasjonskode, som overstyrer dette helt.
if not code_matches(tournament["join_code"], body.code):
await check_visibility(conn, tournament["visibility"], organization_id, tournament_id, user)
deadline = tournament["registration_deadline"]
if deadline is not None and deadline < datetime.now(timezone.utc):
raise app_error(409, "REGISTRATION_CLOSED", "Påmeldingen er stengt.")
# E-post-matching (ADR-017 Beslutning B): finn en allerede
# organisator-opprettet spillerrad med samme e-post og fyll inn
# manglende felt der, i stedet for å opprette en duplikat.
player_id = None
if body.email:
existing = await conn.fetchrow(
"SELECT id FROM player WHERE organization_id = $1 AND lower(email) = lower($2)",
organization_id,
body.email,
)
if existing is not None:
player_id = existing["id"]
await conn.execute(
"""
UPDATE player SET
mobile = COALESCE(mobile, $2),
birth_date = COALESCE(birth_date, $3),
nickname = COALESCE(nickname, $4),
country = COALESCE(country, $5),
club = COALESCE(club, $6),
club_member_number = COALESCE(club_member_number, $7),
gender = COALESCE(gender, $8),
handicap_index = COALESCE(handicap_index, $9)
WHERE id = $1
""",
player_id,
body.mobile,
body.birth_date,
body.nickname,
body.country,
body.club,
body.club_member_number,
body.gender,
body.handicap_index,
)
if player_id is None:
player_row = await conn.fetchrow(
"""
INSERT INTO player
(organization_id, display_name, handicap_index, gender,
mobile, email, birth_date, nickname, country, club, club_member_number)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id
""",
organization_id,
body.display_name,
body.handicap_index,
body.gender,
body.mobile,
body.email.lower() if body.email else None,
body.birth_date,
body.nickname,
body.country,
body.club,
body.club_member_number,
)
player_id = player_row["id"]
already = await conn.fetchval(
"SELECT id FROM tournament_registration WHERE tournament_id = $1 AND player_id = $2",
tournament_id,
player_id,
)
if already is not None:
raise app_error(409, "DUPLICATE", "Denne spilleren er allerede påmeldt.")
# Rekkefølge fra ADR-017 Beslutning C: frist -> kapasitet -> godkjenning.
status = "confirmed"
if tournament["registration_capacity"] is not None:
active_count = await conn.fetchval(
"""
SELECT count(*) FROM tournament_registration
WHERE tournament_id = $1 AND status IN ('confirmed', 'pending')
""",
tournament_id,
)
if active_count >= tournament["registration_capacity"]:
if tournament["registration_overflow_policy"] == "waitlist":
status = "waitlisted"
else:
raise app_error(409, "LIMIT_REACHED", "Turneringen er full.")
if status != "waitlisted" and tournament["registration_requires_approval"]:
status = "pending"
row = await conn.fetchrow(
"""
INSERT INTO tournament_registration
(organization_id, tournament_id, player_id, status, consent_given_at)
VALUES ($1, $2, $3, $4, now())
RETURNING id::text, status
""",
organization_id,
tournament_id,
player_id,
status,
)
return RegistrationResult(id=row["id"], status=row["status"])
class PublicOrgTournament(BaseModel):
id: str
name: str
status: str
start_date: date | None
end_date: date | None
class PublicOrgInfo(BaseModel):
name: str
tournaments: list[PublicOrgTournament]
@org_router.get("/{slug}", response_model=PublicOrgInfo)
async def get_public_org(slug: str) -> PublicOrgInfo:
async with plain_connection() as conn:
organization_id = await conn.fetchval("SELECT public_org_by_slug($1)", slug)
# NULL for BÅDE "finnes ikke" og "finnes, men er privat" -- samme
# anti-enumerering som magic-link (ADR-009), se ADR-018 Beslutning F.
if organization_id is None:
raise app_error(404, "NOT_FOUND", "Organisasjonen finnes ikke.")
async with org_connection(str(organization_id)) as conn:
org_row = await conn.fetchrow("SELECT name FROM organization WHERE id = $1", organization_id)
tournament_rows = await conn.fetch(
"""
SELECT id::text, name, status::text, start_date, end_date
FROM tournament
WHERE visibility = 'public'
ORDER BY start_date DESC NULLS LAST, created_at DESC
""",
)
return PublicOrgInfo(
name=org_row["name"],
tournaments=[PublicOrgTournament(**dict(r)) for r in tournament_rows],
)