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:
parent
8a95ef8af2
commit
8821bbd448
8 changed files with 317 additions and 34 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
75
011_join_code_and_leading_side.sql
Normal file
75
011_join_code_and_leading_side.sql
Normal 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;
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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) --
|
||||||
|
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):
|
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"]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue