Fullfører ADR-098. Ny components/public-individual-live.tsx (V0-bygget,
presentasjonslaget uendret fra eksporten) koblet til de ekte endepunktene
fra forrige commit. Rettet en reell modellmismatch underveis: V0-mock-en
modellerte hver runde som sitt eget separate leaderboard, den ekte
backend-beregningen er ett samlet, kumulativt turnering-leaderboard --
rundevelgeren styrer nå kun hvilken rundes hull-for-hull som lastes.
Ny offentlig GET .../rounds/{round_id}/participants/{tournament_participant_id}
/holes for hull-for-hull-utvidelsen (egen rute, ikke gjenbruk av
frittstående-runders tilsvarende -- ulik datamodell).
app/t/[id]/live/page.tsx avgjør nå format SERVER-side før noe rendres,
unngår klient-side-feilklassen fra forrige commit helt for denne siden.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
765 lines
31 KiB
Python
765 lines
31 KiB
Python
"""
|
|
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, på 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
|
|
from .. import storage
|
|
from ..team_authz import user_is_tournament_participant
|
|
from .individual_tournaments import LeaderboardEntry, RoundCellOut, _compute_individual_standings
|
|
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:
|
|
"""Tynn wrapper -- selve sjekken flyttet til team_authz.py 2026-07-21
|
|
(`user_is_tournament_participant`) slik at org-scopede endepunkter
|
|
(matches.py/scoring.py/tournaments.py) kan gjenbruke den uten sirkulær
|
|
import. Beholder denne funksjonens navn/argumentrekkefølge uendret her
|
|
for å unngå å røre alle kallsteder i denne filen."""
|
|
return await user_is_tournament_participant(conn, organization_id, tournament_id, user_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
|
|
# 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) --
|
|
MÅ 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
|
|
logo_url: str | None
|
|
|
|
|
|
class PublicTournamentInfo(BaseModel):
|
|
id: str
|
|
name: str
|
|
organization_name: str
|
|
status: str
|
|
# 2026-08-21: manglet her -- landingssiden/"Følg live"-lenken kunne
|
|
# derfor ikke skille individuell fra Cup-format offentlig, og rutet
|
|
# ALLTID til Cup-sidens /t/{id}/live (som feilet stille for en
|
|
# individuell turnering, se ARCHITECTURE_DECISIONS.md/CHANGELOG.md
|
|
# samme dag).
|
|
format_type: 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
|
|
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.format_type::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(
|
|
"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"],
|
|
format_type=row["format_type"],
|
|
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"],
|
|
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)
|
|
|
|
|
|
# --- Individuelle turneringer, offentlig "Følg live" (2026-08-21) ---------
|
|
#
|
|
# Cup-formatets tilskuer-rolle over har eksistert siden ADR-026 -- denne
|
|
# siden manglet helt for individuelle turneringer (bevisst utsatt,
|
|
# FEATURE_BACKLOG.md "Flaggturnering Del B", 2026-08-14: "egen, større
|
|
# oppgave hvis/når etterspurt"). Samme synlighets-/kode-mønster som resten
|
|
# av filen, ingen ny mekanisme.
|
|
|
|
|
|
class PublicTournamentRound(BaseModel):
|
|
id: str
|
|
sequence: int
|
|
name: str
|
|
course_name: str
|
|
scheduled_at: datetime | None
|
|
# Utledet (ingen status-kolonne finnes på tournament_round selv) --
|
|
# samme "har noen påbegynt et hull ennå"-logikk som
|
|
# _attach_stroke_play_columns sin today_sequence-utregning bruker
|
|
# internt, generalisert til å gjelde HVER runde (ikke bare den seneste)
|
|
# og lagt til en "alle ferdig"-gren for "completed".
|
|
status: str # "not_started" | "in_progress" | "completed"
|
|
|
|
|
|
_HOLES_BY_CONFIG = {"full_18": 18, "front_9": 9, "back_9": 9}
|
|
|
|
|
|
@router.get("/{tournament_id}/rounds", response_model=list[PublicTournamentRound])
|
|
async def get_public_rounds(
|
|
tournament_id: str,
|
|
code: str | None = None,
|
|
user: CurrentUser | None = Depends(get_current_user_optional),
|
|
) -> list[PublicTournamentRound]:
|
|
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)
|
|
|
|
rounds = await conn.fetch(
|
|
"""
|
|
SELECT tr.id::text, tr.sequence, tr.name, tr.hole_config::text,
|
|
c.name AS course_name, tr.scheduled_at
|
|
FROM tournament_round tr
|
|
JOIN course c ON c.id = tr.course_id
|
|
WHERE tr.tournament_id = $1
|
|
ORDER BY tr.sequence
|
|
""",
|
|
tournament_id,
|
|
)
|
|
progress = await conn.fetch(
|
|
"""
|
|
SELECT trs.tournament_round_id::text,
|
|
count(*) FILTER (WHERE trs.holes_played > 0) AS started,
|
|
count(*) FILTER (WHERE trs.holes_played >= h.hole_count) AS completed,
|
|
count(*) AS total
|
|
FROM tournament_round_score trs
|
|
JOIN tournament_round tr ON tr.id = trs.tournament_round_id
|
|
JOIN LATERAL (SELECT count(*) AS hole_count FROM hole WHERE hole.course_id = tr.course_id) h ON true
|
|
WHERE tr.tournament_id = $1
|
|
GROUP BY trs.tournament_round_id
|
|
""",
|
|
tournament_id,
|
|
)
|
|
progress_by_round = {r["tournament_round_id"]: r for r in progress}
|
|
|
|
def status_for(round_id: str) -> str:
|
|
p = progress_by_round.get(round_id)
|
|
if p is None or p["started"] == 0:
|
|
return "not_started"
|
|
if p["completed"] == p["total"] and p["total"] > 0:
|
|
return "completed"
|
|
return "in_progress"
|
|
|
|
return [
|
|
PublicTournamentRound(
|
|
id=r["id"],
|
|
sequence=r["sequence"],
|
|
name=r["name"] or f"Runde {r['sequence']}",
|
|
course_name=r["course_name"],
|
|
scheduled_at=r["scheduled_at"],
|
|
status=status_for(r["id"]),
|
|
)
|
|
for r in rounds
|
|
]
|
|
|
|
|
|
class PublicLeaderboardEntry(BaseModel):
|
|
tournament_participant_id: str
|
|
player_name: str
|
|
rounds_played: int
|
|
gross_total: int | None
|
|
net_total: int | None
|
|
stableford_total: int | None
|
|
copenhagen_total: int | None
|
|
bbb_total: int | None
|
|
class_id: str | None
|
|
class_name: str | None
|
|
position: str | None
|
|
is_leader: bool
|
|
today_label: str | None
|
|
thru_label: str | None
|
|
total_label: str | None
|
|
rank: int | None
|
|
cut: bool
|
|
status: str
|
|
eclectic_total: int | None
|
|
handicap_index: float | None
|
|
rounds: list[RoundCellOut]
|
|
|
|
|
|
@router.get(
|
|
"/{tournament_id}/individual-leaderboard", response_model=list[PublicLeaderboardEntry]
|
|
)
|
|
async def get_public_individual_leaderboard(
|
|
tournament_id: str,
|
|
code: str | None = None,
|
|
user: CurrentUser | None = Depends(get_current_user_optional),
|
|
) -> list[PublicLeaderboardEntry]:
|
|
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)
|
|
|
|
_, entries = await _compute_individual_standings(conn, tournament_id)
|
|
|
|
# HCP-indeks hentes separat (bevisst valgt synlig her, jf. samme
|
|
# presedens som frittstående runders offentlige leaderboard) --
|
|
# LeaderboardEntry selv har den ikke, kun tournament_participant.
|
|
hcp_rows = await conn.fetch(
|
|
"SELECT id::text, handicap_index_snapshot FROM tournament_participant WHERE tournament_id = $1",
|
|
tournament_id,
|
|
)
|
|
hcp_by_participant = {r["id"]: r["handicap_index_snapshot"] for r in hcp_rows}
|
|
|
|
return [
|
|
PublicLeaderboardEntry(
|
|
tournament_participant_id=e.tournament_participant_id,
|
|
player_name=e.player_name,
|
|
rounds_played=e.rounds_played,
|
|
gross_total=e.gross_total,
|
|
net_total=e.net_total,
|
|
stableford_total=e.stableford_total,
|
|
copenhagen_total=e.copenhagen_total,
|
|
bbb_total=e.bbb_total,
|
|
class_id=e.class_id,
|
|
class_name=e.class_name,
|
|
position=e.position,
|
|
is_leader=e.is_leader,
|
|
today_label=e.today_label,
|
|
thru_label=e.thru_label,
|
|
total_label=e.total_label,
|
|
rank=e.rank,
|
|
cut=e.cut,
|
|
status=e.status,
|
|
eclectic_total=e.eclectic_total,
|
|
handicap_index=float(hcp_by_participant[e.tournament_participant_id])
|
|
if hcp_by_participant.get(e.tournament_participant_id) is not None
|
|
else None,
|
|
rounds=e.rounds,
|
|
)
|
|
for e in entries
|
|
]
|
|
|
|
|
|
class PublicRoundHole(BaseModel):
|
|
hole_number: int
|
|
par: int
|
|
gross_strokes: int | None
|
|
|
|
|
|
@router.get(
|
|
"/{tournament_id}/rounds/{round_id}/participants/{tournament_participant_id}/holes",
|
|
response_model=list[PublicRoundHole],
|
|
)
|
|
async def get_public_round_participant_holes(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
tournament_participant_id: str,
|
|
code: str | None = None,
|
|
user: CurrentUser | None = Depends(get_current_user_optional),
|
|
) -> list[PublicRoundHole]:
|
|
"""Hull-for-hull for ÉN spiller i ÉN runde -- brukt av den offentlige
|
|
leaderboard-radens utvidbare rutenett. Nøkkelen er bevisst
|
|
tournament_participant_id (samme id som i PublicLeaderboardEntry over),
|
|
IKKE tournament_round_participant_id (den er runde-spesifikk og aldri
|
|
eksponert offentlig) -- slår opp riktig runde-deltaker-rad internt."""
|
|
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)
|
|
|
|
round_row = await conn.fetchrow(
|
|
"SELECT course_id::text AS course_id FROM tournament_round WHERE id = $1 AND tournament_id = $2",
|
|
round_id,
|
|
tournament_id,
|
|
)
|
|
if round_row is None:
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke i denne turneringen.")
|
|
|
|
round_participant_id = await conn.fetchval(
|
|
"SELECT id FROM tournament_round_participant WHERE tournament_round_id = $1 AND tournament_participant_id = $2",
|
|
round_id,
|
|
tournament_participant_id,
|
|
)
|
|
if round_participant_id is None:
|
|
return []
|
|
|
|
holes = await conn.fetch(
|
|
"""
|
|
SELECT h.hole_number, h.par, trh.gross_strokes
|
|
FROM hole h
|
|
LEFT JOIN tournament_round_hole trh
|
|
ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
|
|
WHERE h.course_id = $2
|
|
ORDER BY h.hole_number
|
|
""",
|
|
round_participant_id,
|
|
round_row["course_id"],
|
|
)
|
|
return [PublicRoundHole(**dict(h)) for h in holes]
|
|
|
|
|
|
# --- 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],
|
|
)
|