ackend for the join-code, leading_side, and projected-standings work is fully verified against a fresh scratch database (migrations 001–011, test_isolation.sql 12/12, and live end-to-end checks: join-code generation/uniqueness, code resolution, visibility bypass on both the tournament view and registration — including case-insensitivity and rejection of a wrong code — plus a full hole-by-hole match simulation confirming leading_side/status_text stay in sync through "1 UP" → "AS" → a decided "9&7", with the leaderboard's projected points matching at each stage: 1.0/0.0 while A led, 0.5/0.5 at all-square, and settling to equal actual/projected once decided).

Ready to deploy to the real system:

Migration: 011_join_code_and_leading_side.sql against real teecup_db (adds tournament.join_code — backfills existing tournaments with generated codes — and match.leading_side, plus the public_tournament_by_code() function).
Redeploy: teecup_api only (no frontend changes yet — those come next).
This commit is contained in:
Erol Haagenrud 2026-07-19 09:23:35 +02:00
parent 8a95ef8af2
commit 8821bbd448
8 changed files with 317 additions and 34 deletions

View file

@ -272,7 +272,11 @@
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/patch_cookies.txt)", "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/patch_cookies.txt)",
"Bash(awk 'NR==499,/^---$/{print NR\": \"$0}' /opt/teecup/FEATURE_BACKLOG.md)", "Bash(awk 'NR==499,/^---$/{print NR\": \"$0}' /opt/teecup/FEATURE_BACKLOG.md)",
"Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-9)", "Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-9)",
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/ui_cookies.txt)" "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/ui_cookies.txt)",
"Bash(grep -v '^$')",
"Bash(grep -rl \"POSTGRES_PASSWORD\" /opt/teeoff --include='*.yml' --include='*.env*' 2>/dev/null)",
"Bash(grep -n \"^ [a-zA-Z0-9_-]*:$\" /opt/teeoff/docker-compose.prod.yml)",
"Bash(set -e)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",

View file

@ -0,0 +1,75 @@
-- Migrasjon 011: invitasjonskode (oppdagelse) + cachet matchledelse (ADR-020).
--
-- To uavhengige, additive endringer:
-- 1. tournament.join_code -- kort, menneske-skrivbar kode som løses til
-- riktig turnering FØR org-kontekst er kjent (samme "løs kontekst FØR
-- RLS kan håndheve noe"-problem som public_tournament_org, 007).
-- Bevisst OVERSTYRER visibility ved bruk (se ADR-020 Beslutning A) --
-- dette er ikke noe skjemaet håndhever, kun API-laget.
-- 2. match.leading_side -- cachet fortegn av MatchState.lead (ADR-020
-- Beslutning C), samme mønster som status_text/points_side_a/b.
-- ---------------------------------------------------------------------
-- 1. tournament.join_code
-- ---------------------------------------------------------------------
ALTER TABLE tournament ADD COLUMN join_code text;
-- Backfyll eksisterende rader (typisk 1-2 i prod på dette tidspunktet) med
-- en tilfeldig 6-tegns kode fra et alfabet uten forvekslingsbare tegn
-- (0/O, 1/I droppet). Kollisjonssjekk mot allerede-tildelte koder i samme
-- løkke -- radantallet er lite nok til at en enkel retry-løkke er trygg.
DO $$
DECLARE
r RECORD;
alphabet text := 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
candidate text;
attempt int;
i int;
BEGIN
FOR r IN SELECT id FROM tournament WHERE join_code IS NULL LOOP
attempt := 0;
LOOP
candidate := '';
FOR i IN 1..6 LOOP
candidate := candidate || substr(alphabet, 1 + floor(random() * length(alphabet))::int, 1);
END LOOP;
attempt := attempt + 1;
EXIT WHEN attempt > 20 OR NOT EXISTS (
SELECT 1 FROM tournament WHERE join_code = candidate
);
END LOOP;
UPDATE tournament SET join_code = candidate WHERE id = r.id;
END LOOP;
END $$;
ALTER TABLE tournament ALTER COLUMN join_code SET NOT NULL;
ALTER TABLE tournament ADD CONSTRAINT tournament_join_code_unique UNIQUE (join_code);
-- ---------------------------------------------------------------------
-- 2. match.leading_side
-- ---------------------------------------------------------------------
ALTER TABLE match ADD COLUMN leading_side text CHECK (leading_side IN ('a', 'b'));
-- ---------------------------------------------------------------------
-- 3. public_tournament_by_code() -- fjerde SECURITY DEFINER-bro i
-- prosjektet (etter public_tournament_org 007, link_player_by_email 008,
-- public_org_by_slug 009), samme herding (SET search_path = public mot
-- kapring). Eksponerer KUN uuid<-text-koblingen, ingenting annet fra
-- tournament-raden. NULL for ukjent kode -- ingen egen anti-
-- enumereringsbekymring her siden koden er MENT å deles (se ADR-020
-- Beslutning A), men samme returform som de tre andre broene uansett.
-- ---------------------------------------------------------------------
CREATE FUNCTION public_tournament_by_code(p_code text)
RETURNS uuid
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
STABLE
AS $$
SELECT id FROM tournament WHERE upper(join_code) = upper(p_code);
$$;
GRANT EXECUTE ON FUNCTION public_tournament_by_code(text) TO teecup_app;

View file

@ -643,6 +643,84 @@ ref.sql`.
--- ---
## ADR-020 — Invitasjonskode (oppdagelse), matchledelse og projisert stilling
**Kontekst:** Reist av brukeren 2026-07-19, rett etter at "bygg i rekkefølgen
ting brukes"-serien var ferdig. Tre relaterte, men separate mangler:
1. Den eneste veien inn til en turnering er en direkte lenke (`/t/[id]`) eller
org-ens klubbside (kun `public`-synlige turneringer). En spiller som bare
har fått muntlig beskjed ("du spiller lørdag") har i dag ingen vei inn i
det hele tatt — dette var reelt ikke gjennomtenkt tidligere.
2. Leaderboardet viser kun FAKTISK opptjente poeng. Ingen visning av hva
stillingen ville blitt om pågående, ikke-avgjorte matcher holder seg som
de står nå (vanlig i profesjonell golf-TV-dekning av Ryder Cup).
3. Ingen visuell indikasjon i matchlister på hvem som leder en pågående
match — brukeren viste et skjermbilde av referanseproduktets fargekodede
matchrader (rød/beige etter ledende side) som ønsket retning.
**Beslutning A — Kort, menneske-skrivbar invitasjonskode per turnering, som
OVERSTYRER `tournament.visibility`.** Ny `tournament.join_code` (6 tegn, fra
et alfabet uten forvekslingsbare tegn — `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`,
altså uten `0/O/1/I`), generert automatisk ved opprettelse, globalt unik
(kodeoppslag skjer FØR org-kontekst er kjent, samme problem som
`public_tournament_org()` løste for ADR-017). Login-skjermet får et eget
kode-felt (fungerer FØR innlogging) som løser koden til riktig turnering og
sender brukeren til `/t/{id}?code=...`.
**Bevisst valgt fremfor å la koden respektere synlighet:** en kode gitt
muntlig eller på en lapp ER selve invitasjonen — å likevel kreve org-
medlemskap eller deltakerstatus for en `org`/`participants`-synlig turnering
ville gjort koden verdiløs for akkurat den situasjonen den er ment å løse.
Koden er ikke hemmelig i sikkerhetsforstand (den er MENT å deles), men
6 tegn fra et 33-tegns alfabet (≈1,3 milliarder kombinasjoner) gjør blind
gjetting upraktisk uten separat rate-limiting — akseptabelt for et
tillitsbasert klubb-/vennegjeng-verktøy (samme trusselmodell-resonnement som
`team_authz.py`, se «Brukerroller» i FEATURE_BACKLOG.md).
**Konsekvens:** `GET /public/tournaments/{id}` og
`POST /public/tournaments/{id}/register` godtar en valgfri `code`-parameter;
matcher den turneringens `join_code` (case-insensitivt), hoppes den vanlige
`_check_visibility()`-sjekken helt over. Ny SECURITY DEFINER-bro
`public_tournament_by_code(code) RETURNS uuid` (tournament_id) — fjerde
instans av samme mønster som `public_tournament_org()` (007),
`link_player_by_email()` (008), `public_org_by_slug()` (009). Ingen
kode-regenerering bygget denne runden (organisator kan i dag ikke bytte ut
en lekket kode) — egen, senere sak i FEATURE_BACKLOG.md om det blir
etterspurt.
**Beslutning B — Projisert stilling: pågående matchers nåværende leder får
full poengsum, uavgjort/ikke-startet splittes likt.** For hver IKKE avgjort
match brukes `match.leading_side` (se Beslutning C) til å tildele hele
øktens `points_per_match` til den ledende siden i den PROJISERTE summen —
"AS" (all square) eller en match som ennå ikke har noen registrerte hull
splittes 0,5/0,5, samme regel som en faktisk halvert match. Avgjorte
matcher bidrar likt til både faktisk og projisert sum (de er jo allerede
det de blir). Speiler hvordan TV-dekning av Ryder Cup vanligvis viser
"hvis det sluttet nå"-tavler.
**Beslutning C — Ny cachet kolonne `match.leading_side`, samme mønster som
`status_text`/`points_side_a/b`.** `recompute_and_cache_match_state()`
(`app/routers/scoring.py`) beregner den allerede tilgjengelige
`MatchState.lead`-verdien (fortegn = ledende side) ved HVER hull-innsending
uansett om matchen er avgjort ennå — lagres nå også i en egen kolonne i
stedet for kun å ligge innbakt i den menneskelesbare `status_text`-strengen
("2 UP (A)"), slik at frontend kan style etter et strukturert felt
(`"a" | "b" | null`) i stedet for å parse norsk/engelsk tekst. Brukes til
BÅDE projisert stilling (Beslutning B) og fargekoding av matchlister
(Beslutning D).
**Beslutning D — Fargekoding er ren frontend-presentasjon, ingen ny
backend-modell.** Matchlister (blind draw sin avslørte visning, ev. flere
steder senere) farger den ledende sidens kant/bakgrunn med lagets EKSISTERENDE
`team.color` når `leading_side` er satt og matchen ikke er avgjort — samme
fargekilde som resten av appen (leaderboard, roster) allerede bruker, ingen
ny fargemodell innført.
**Migrasjon:** `011_join_code_and_leading_side.sql`.
---
## Åpne spørsmål (ikke besluttet ennå) ## Åpne spørsmål (ikke besluttet ennå)
Disse må avklares før eller under de relevante fasene: Disse må avklares før eller under de relevante fasene:

View file

@ -70,6 +70,7 @@ class MatchOut(BaseModel):
status_text: str | None status_text: str | None
points_side_a: float | None points_side_a: float | None
points_side_b: float | None points_side_b: float | None
leading_side: str | None
tee_time: datetime | None tee_time: datetime | None
participants: list[MatchParticipantOut] participants: list[MatchParticipantOut]
@ -112,7 +113,7 @@ async def create_match(
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING id::text, sequence, team_a_id::text, team_b_id::text, RETURNING id::text, sequence, team_a_id::text, team_b_id::text,
status_text, points_side_a::float AS points_side_a, status_text, points_side_a::float AS points_side_a,
points_side_b::float AS points_side_b, tee_time_override points_side_b::float AS points_side_b, leading_side, tee_time_override
""", """,
organization_id, organization_id,
session_id, session_id,
@ -150,7 +151,7 @@ async def list_matches(
""" """
SELECT id::text, sequence, team_a_id::text, team_b_id::text, SELECT id::text, sequence, team_a_id::text, team_b_id::text,
status_text, points_side_a::float AS points_side_a, status_text, points_side_a::float AS points_side_a,
points_side_b::float AS points_side_b, tee_time_override points_side_b::float AS points_side_b, leading_side, tee_time_override
FROM match FROM match
WHERE session_id = $1 WHERE session_id = $1
ORDER BY sequence ORDER BY sequence
@ -207,6 +208,7 @@ async def list_matches(
status_text=m["status_text"], status_text=m["status_text"],
points_side_a=m["points_side_a"], points_side_a=m["points_side_a"],
points_side_b=m["points_side_b"], points_side_b=m["points_side_b"],
leading_side=m["leading_side"],
tee_time=_compute_tee_time( tee_time=_compute_tee_time(
session["scheduled_at"], session["tee_interval_minutes"], m["sequence"], m["tee_time_override"] session["scheduled_at"], session["tee_interval_minutes"], m["sequence"], m["tee_time_override"]
), ),

View file

@ -67,6 +67,10 @@ async def _is_participant(conn, user_id: str, organization_id: str, tournament_i
) )
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( async def _check_visibility(
conn, conn,
visibility: str, visibility: str,
@ -91,6 +95,35 @@ async def _check_visibility(
raise app_error(403, "NOT_VISIBLE", "Du har ikke tilgang til denne turneringen.") 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): class SponsorOut(BaseModel):
id: str id: str
name: str name: str
@ -118,6 +151,7 @@ class PublicTournamentInfo(BaseModel):
@router.get("/{tournament_id}", response_model=PublicTournamentInfo) @router.get("/{tournament_id}", response_model=PublicTournamentInfo)
async def get_public_tournament( async def get_public_tournament(
tournament_id: str, tournament_id: str,
code: str | None = None,
user: CurrentUser | None = Depends(get_current_user_optional), user: CurrentUser | None = Depends(get_current_user_optional),
) -> PublicTournamentInfo: ) -> PublicTournamentInfo:
organization_id = await _resolve_org(tournament_id) organization_id = await _resolve_org(tournament_id)
@ -125,7 +159,7 @@ async def get_public_tournament(
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
SELECT t.id::text, t.name, o.name AS organization_name, t.status::text, SELECT t.id::text, t.name, o.name AS organization_name, t.status::text,
t.visibility, t.description, t.hero_image_key, t.visibility, t.description, t.hero_image_key, t.join_code,
t.start_date, t.end_date, t.registration_deadline, t.start_date, t.end_date, t.registration_deadline,
t.registration_capacity, t.registration_capacity,
(SELECT count(*)::int FROM tournament_registration tr (SELECT count(*)::int FROM tournament_registration tr
@ -140,6 +174,8 @@ async def get_public_tournament(
if row is None: if row is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") 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) await _check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
sponsor_rows = await conn.fetch( sponsor_rows = await conn.fetch(
@ -178,14 +214,18 @@ async def get_public_tournament(
@router.get("/{tournament_id}/sessions", response_model=list[SessionOut]) @router.get("/{tournament_id}/sessions", response_model=list[SessionOut])
async def get_public_sessions( async def get_public_sessions(
tournament_id: str, tournament_id: str,
code: str | None = None,
user: CurrentUser | None = Depends(get_current_user_optional), user: CurrentUser | None = Depends(get_current_user_optional),
) -> list[SessionOut]: ) -> list[SessionOut]:
organization_id = await _resolve_org(tournament_id) organization_id = await _resolve_org(tournament_id)
async with org_connection(organization_id) as conn: async with org_connection(organization_id) as conn:
visibility = await conn.fetchval("SELECT visibility FROM tournament WHERE id = $1", tournament_id) row = await conn.fetchrow(
if visibility is None: "SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
)
if row is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
await _check_visibility(conn, visibility, organization_id, tournament_id, user) 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) return await _fetch_sessions(conn, tournament_id)
@ -202,6 +242,8 @@ class RegisterRequest(BaseModel):
handicap_index: float | None = None handicap_index: float | None = None
# API-et krever eksplisitt True -- ingen implisitt samtykke (ADR-017). # API-et krever eksplisitt True -- ingen implisitt samtykke (ADR-017).
consent: bool consent: bool
# ADR-020 Beslutning A: en matchende invitasjonskode overstyrer visibility.
code: str | None = None
class RegistrationResult(BaseModel): class RegistrationResult(BaseModel):
@ -226,7 +268,7 @@ async def register_for_tournament(
# mønster som to-lags-grensen i tournaments.py sin create_team. # mønster som to-lags-grensen i tournaments.py sin create_team.
tournament = await conn.fetchrow( tournament = await conn.fetchrow(
""" """
SELECT visibility, registration_deadline, registration_capacity, SELECT visibility, join_code, registration_deadline, registration_capacity,
registration_overflow_policy, registration_requires_approval registration_overflow_policy, registration_requires_approval
FROM tournament WHERE id = $1 FOR UPDATE FROM tournament WHERE id = $1 FOR UPDATE
""", """,
@ -236,7 +278,9 @@ async def register_for_tournament(
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
# ADR-018 Beslutning D: registrering følger samme synlighetsgrense # ADR-018 Beslutning D: registrering følger samme synlighetsgrense
# som selve landingssiden -- ingen særbehandling. # 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) await _check_visibility(conn, tournament["visibility"], organization_id, tournament_id, user)
deadline = tournament["registration_deadline"] deadline = tournament["registration_deadline"]

View file

@ -165,6 +165,18 @@ async def recompute_and_cache_match_state(conn, match_id: str) -> None:
played = _played_hole_numbers(match["hole_config"]) played = _played_hole_numbers(match["hole_config"])
state = compute_match_state(results, total_holes=len(played)) state = compute_match_state(results, total_holes=len(played))
# ADR-020 Beslutning C: fortegnet av state.lead cachet i egen kolonne
# (i tillegg til den menneskelesbare status_text), slik at frontend kan
# style etter et strukturert felt i stedet for å parse tekst. Gjelder
# BÅDE pågående og avgjorte matcher -- vinnersiden i en avgjort match
# er alltid den samme som lead sitt fortegn allerede pekte på.
if state.lead > 0:
leading_side = "a"
elif state.lead < 0:
leading_side = "b"
else:
leading_side = None
complete = state.is_closed or state.holes_remaining == 0 complete = state.is_closed or state.holes_remaining == 0
if complete: if complete:
ppm = match["points_per_match"] ppm = match["points_per_match"]
@ -175,16 +187,22 @@ async def recompute_and_cache_match_state(conn, match_id: str) -> None:
else: else:
points_a = points_b = ppm / 2 points_a = points_b = ppm / 2
await conn.execute( await conn.execute(
"UPDATE match SET status_text = $1, points_side_a = $2, points_side_b = $3 WHERE id = $4", """
UPDATE match SET status_text = $1, points_side_a = $2, points_side_b = $3,
leading_side = $4
WHERE id = $5
""",
state.describe(), state.describe(),
points_a, points_a,
points_b, points_b,
leading_side,
match_id, match_id,
) )
else: else:
await conn.execute( await conn.execute(
"UPDATE match SET status_text = $1 WHERE id = $2", "UPDATE match SET status_text = $1, leading_side = $2 WHERE id = $3",
state.describe(), state.describe(),
leading_side,
match_id, match_id,
) )

View file

@ -8,8 +8,10 @@ rostret på et lag som ikke finnes ennå).
""" """
import json import json
import secrets
from datetime import date, datetime from datetime import date, datetime
import asyncpg
from fastapi import APIRouter, Depends, UploadFile from fastapi import APIRouter, Depends, UploadFile
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -37,9 +39,17 @@ class TournamentCreate(BaseModel):
_TOURNAMENT_COLUMNS = """ _TOURNAMENT_COLUMNS = """
id::text, name, status::text, start_date, end_date, id::text, name, status::text, start_date, end_date,
visibility, description, registration_deadline, registration_capacity, visibility, description, registration_deadline, registration_capacity,
registration_overflow_policy, registration_requires_approval, hero_image_key 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): class Tournament(BaseModel):
id: str id: str
@ -54,6 +64,7 @@ class Tournament(BaseModel):
registration_overflow_policy: str registration_overflow_policy: str
registration_requires_approval: bool registration_requires_approval: bool
hero_image_key: str | None hero_image_key: str | None
join_code: str
@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament]) @router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
@ -73,17 +84,30 @@ async def create_tournament(
organization_id: str = Depends(get_authorized_org), organization_id: str = Depends(get_authorized_org),
) -> Tournament: ) -> Tournament:
async with org_connection(organization_id) as conn, translate_db_errors(): 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( row = await conn.fetchrow(
f""" f"""
INSERT INTO tournament (organization_id, name, start_date, end_date) INSERT INTO tournament (organization_id, name, start_date, end_date, join_code)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
RETURNING {_TOURNAMENT_COLUMNS} RETURNING {_TOURNAMENT_COLUMNS}
""", """,
organization_id, organization_id,
body.name, body.name,
body.start_date, body.start_date,
body.end_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.")
return Tournament(**dict(row)) return Tournament(**dict(row))
@ -904,6 +928,10 @@ class TeamStanding(BaseModel):
team_name: str team_name: str
color: str | None color: str | None
points: float 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): class SessionStanding(BaseModel):
@ -911,6 +939,7 @@ class SessionStanding(BaseModel):
sequence: int sequence: int
name: str | None name: str | None
points_by_team: dict[str, float] points_by_team: dict[str, float]
projected_points_by_team: dict[str, float]
matches_total: int matches_total: int
matches_decided: int matches_decided: int
@ -941,8 +970,10 @@ async def get_leaderboard(
rows = await conn.fetch( rows = await conn.fetch(
""" """
SELECT s.id::text AS session_id, s.sequence, s.name, 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.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.points_side_a::float AS points_side_a, m.points_side_b::float AS points_side_b,
m.leading_side
FROM match m FROM match m
JOIN session s ON s.id = m.session_id JOIN session s ON s.id = m.session_id
WHERE s.tournament_id = $1 WHERE s.tournament_id = $1
@ -952,6 +983,7 @@ async def get_leaderboard(
) )
team_totals: dict[str, float] = {t["id"]: 0.0 for t in teams} 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] = [] session_order: list[str] = []
sessions: dict[str, dict] = {} sessions: dict[str, dict] = {}
matches_total = 0 matches_total = 0
@ -967,11 +999,35 @@ async def get_leaderboard(
"sequence": r["sequence"], "sequence": r["sequence"],
"name": r["name"], "name": r["name"],
"points_by_team": {t["id"]: 0.0 for t in teams}, "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_total": 0,
"matches_decided": 0, "matches_decided": 0,
} }
sessions[sid]["matches_total"] += 1 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: if r["points_side_a"] is None:
continue continue
matches_decided += 1 matches_decided += 1
@ -986,7 +1042,13 @@ async def get_leaderboard(
) )
team_standings = [ team_standings = [
TeamStanding(team_id=t["id"], team_name=t["name"], color=t["color"], points=team_totals[t["id"]]) 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 for t in teams
] ]

View file

@ -25,9 +25,9 @@ INSERT INTO organization (id, name) VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Org A'), ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Org A'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Org B'); ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Org B');
INSERT INTO tournament (organization_id, name) VALUES INSERT INTO tournament (organization_id, name, join_code) VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'A-Cup'), ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'A-Cup', 'TESTA1'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'B-Cup'); ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'B-Cup', 'TESTB1');
-- --- Seed nok struktur til å teste match_hole_result og lineup_lock --- -- --- Seed nok struktur til å teste match_hole_result og lineup_lock ---
-- (ADR-012/013, migrasjon 003). Fortsatt privilegert rolle, RLS omgås her. -- (ADR-012/013, migrasjon 003). Fortsatt privilegert rolle, RLS omgås her.
@ -106,8 +106,8 @@ END $$;
DO $$ DO $$
BEGIN BEGIN
BEGIN BEGIN
INSERT INTO tournament (organization_id, name) INSERT INTO tournament (organization_id, name, join_code)
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'kryss-org-forsøk'); VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'kryss-org-forsøk', 'TESTX01');
-- Kommer vi hit, ble det tillatt -> isolasjonen er brutt. -- Kommer vi hit, ble det tillatt -> isolasjonen er brutt.
RAISE EXCEPTION 'ISOLASJON FEILET (skriving): fikk sette inn data for annen org'; RAISE EXCEPTION 'ISOLASJON FEILET (skriving): fikk sette inn data for annen org';
EXCEPTION EXCEPTION
@ -228,8 +228,8 @@ END $$;
DO $$ DO $$
BEGIN BEGIN
BEGIN BEGIN
INSERT INTO tournament (organization_id, name) INSERT INTO tournament (organization_id, name, join_code)
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'tomstreng-forsøk'); VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'tomstreng-forsøk', 'TESTX02');
RAISE EXCEPTION 'ISOLASJON FEILET (tomstreng-skriving): fikk sette inn data uten gyldig org-kontekst'; RAISE EXCEPTION 'ISOLASJON FEILET (tomstreng-skriving): fikk sette inn data uten gyldig org-kontekst';
EXCEPTION EXCEPTION
WHEN insufficient_privilege THEN WHEN insufficient_privilege THEN