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.
538 lines
22 KiB
Python
538 lines
22 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 .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
|
|
# 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
|
|
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.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"],
|
|
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)
|
|
|
|
|
|
# --- 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],
|
|
)
|