""" Offentlig, uautentisert registrerings-API (ADR-017). Bevisst egen fil med prefiks /public/tournaments, atskilt fra /orgs/... -- gjør sikkerhetsgrensen eksplisitt i koden: disse to endepunktene krever VERKEN innlogging (get_current_user) ELLER org-medlemskap (get_authorized_org), i motsetning til absolutt alt annet i API-et. `public_tournament_org()` (migrasjon 007, SECURITY DEFINER) er den ENESTE broen fra en turnering-id til riktig RLS-kontekst FØR den er kjent -- se ADR-017 Beslutning E for hvorfor dette er trygt (smalt unntak, ikke en generell RLS-omgåelse). """ from datetime import date, datetime, timezone from fastapi import APIRouter from pydantic import BaseModel, EmailStr, Field from ..db import org_connection, plain_connection from ..errors import app_error, translate_db_errors router = APIRouter(prefix="/public/tournaments", 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) class PublicTournamentInfo(BaseModel): id: str name: str organization_name: str status: str start_date: date | None end_date: date | None registration_open: bool registration_deadline: datetime | None registration_capacity: int | None confirmed_count: int @router.get("/{tournament_id}", response_model=PublicTournamentInfo) async def get_public_tournament(tournament_id: str) -> 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.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.") 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"], 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"], ) 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 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) -> 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 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.") 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"])