Compare commits

...

2 commits

Author SHA1 Message Date
c71479351b Alle "oppsett"-endepunktene fra planen er bygget og verifisert for ekte mot scratch-databasen (fersk teecup_scratch, 001→002→003, container med pip install + uvicorn, curl gjennom hele flyten, deretter fullstendig opprydding). 2026-07-16 09:16:22 +02:00
576ae64fa3 Fix migration 002: psql does not interpolate :'var' inside DO $$ blocks
CREATE ROLE ... PASSWORD :'teecup_app_password' silently failed with
"syntax error at or near ':'" because the whole DO $$ ... $$ body is a
dollar-quoted string and psql skips variable substitution inside it.
Replaced the idempotency check with \gset + \if/\else so the PASSWORD
line is plain top-level SQL, where interpolation does work. Verified
against a scratch database (see test_isolation.sql run).
2026-07-16 08:24:48 +02:00
10 changed files with 917 additions and 71 deletions

View file

@ -15,7 +15,50 @@
"Bash(echo \"EXIT:$?\")", "Bash(echo \"EXIT:$?\")",
"Read(//tmp/**)", "Read(//tmp/**)",
"Bash(git add *)", "Bash(git add *)",
"Bash(git commit *)" "Bash(git commit *)",
"Bash(git commit -m ' *)",
"Bash(curl -sI --max-time 5 https://pypi.org)",
"Bash(echo \"exit: $?\")",
"Bash(timeout 8 docker pull python:3.12-slim)",
"Bash(python3 -m json.tool)",
"Bash(docker inspect *)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"name\":\"Høstcupen 2026\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -H 'X-Debug-User-Id: 33333333-3333-3333-3333-333333333333' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' http://127.0.0.1:8099/orgs/44444444-4444-4444-4444-444444444444/tournaments)",
"Bash(docker rm *)",
"Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/db_url.txt)",
"Bash(rm -f /tmp/health.json)",
"Bash(echo \"exit code: $?\")",
"Bash(docker network *)",
"Bash(python3 -m py_compile app/main.py app/errors.py app/blind_draw.py app/routers/players.py app/routers/tournaments.py app/routers/matches.py)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"name\":\"Team Rød\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/teams)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"name\":\"Team Blå\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/teams)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"name\":\"Team Grønn\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/teams)",
"Bash(python3 -m py_compile /opt/teecup/app/errors.py)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/teams)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"player_id\":\"aaaa1111-1111-1111-1111-111111111111\",\"is_captain\":true}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/teams/0e300504-185e-452f-9828-0804d0961d37/roster)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"player_id\":\"bbbb2222-2222-2222-2222-222222222222\",\"is_captain\":true}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/teams/3d8986d7-4467-426d-91c5-081974b320d5/roster)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"player_id\":\"aaaa1111-1111-1111-1111-111111111111\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/teams/3d8986d7-4467-426d-91c5-081974b320d5/roster)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"sequence\":1,\"name\":\"Lørdag\",\"format\":\"singles\",\"course_id\":\"dddd4444-4444-4444-4444-444444444444\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/sessions)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/sessions)",
"Bash(curl -s http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/sessions/d6886130-a425-4a8f-ad5c-9ce09dd42ff3/matches)",
"Bash(curl -s -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/sessions/d6886130-a425-4a8f-ad5c-9ce09dd42ff3/matches)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"team_id\":\"0e300504-185e-452f-9828-0804d0961d37\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/sessions/d6886130-a425-4a8f-ad5c-9ce09dd42ff3/lock)",
"Bash(curl -s -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/sessions)",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(len\\(d[0]['participants']\\), 'deltaker\\(e\\) synlig'\\)\")",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 33333333-3333-3333-3333-333333333333' -H 'Content-Type: application/json' -d '{\"team_id\":\"3d8986d7-4467-426d-91c5-081974b320d5\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/sessions/d6886130-a425-4a8f-ad5c-9ce09dd42ff3/lock)",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('revealed:', d[0]['revealed']\\)\")",
"Bash(curl -s -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/teams)",
"Bash(curl -s -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' http://127.0.0.1:8099/orgs/44444444-4444-4444-4444-444444444444/tournaments/55555555-5555-5555-5555-555555555555/teams)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"name\":\"Nabolag\"}' http://127.0.0.1:8099/orgs/44444444-4444-4444-4444-444444444444/tournaments/55555555-5555-5555-5555-555555555555/teams)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"display_name\":\"Spiller C\",\"handicap_index\":15.0,\"gender\":\"f\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/players)",
"Bash(curl -s http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/players -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222')",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(len\\(d\\), 'spillere:', [p['display_name'] for p in d]\\)\")",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"sequence\":2,\"format\":\"singles\",\"course_id\":\"00000000-0000-0000-0000-000000000000\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/sessions)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'X-Debug-User-Id: 22222222-2222-2222-2222-222222222222' -H 'Content-Type: application/json' -d '{\"sequence\":2,\"format\":\"tullball\",\"course_id\":\"__TRACKED_VAR__\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments/cccc3333-3333-3333-3333-333333333333/sessions)",
"Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/match_id.env)"
] ]
} }
} }

View file

@ -19,9 +19,16 @@
-- --- Rolle (cluster-nivå: opprett bare hvis den ikke finnes) ---------- -- --- Rolle (cluster-nivå: opprett bare hvis den ikke finnes) ----------
-- Eksplisitt NOSUPERUSER / NOBYPASSRLS — dette er hele poenget. -- Eksplisitt NOSUPERUSER / NOBYPASSRLS — dette er hele poenget.
DO $$ -- MERK: dette var opprinnelig en DO $$ ... $$-blokk med
BEGIN -- "PASSWORD :'teecup_app_password'" inni. psql interpolerer ikke :'var'
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'teecup_app') THEN -- inne i dollar-quoted strenger, så det feilet alltid med
-- "syntax error at or near ':'". Erstattet med \if/\else slik at
-- PASSWORD-linjen står som vanlig SQL utenfor $$ $$ (verifisert mot
-- scratch-database, se test_isolation.sql-kjøringen).
SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'teecup_app') AS teecup_app_exists \gset
\if :teecup_app_exists
\echo 'teecup_app finnes allerede, hopper over CREATE ROLE'
\else
CREATE ROLE teecup_app CREATE ROLE teecup_app
LOGIN LOGIN
PASSWORD :'teecup_app_password' PASSWORD :'teecup_app_password'
@ -30,8 +37,7 @@ BEGIN
NOCREATEROLE NOCREATEROLE
NOBYPASSRLS NOBYPASSRLS
INHERIT; INHERIT;
END IF; \endif
END $$;
-- --- Grants (kjøres mens du er koblet til teecup_db) ------------------ -- --- Grants (kjøres mens du er koblet til teecup_db) ------------------
GRANT CONNECT ON DATABASE teecup_db TO teecup_app; GRANT CONNECT ON DATABASE teecup_db TO teecup_app;

View file

@ -46,13 +46,30 @@ Ferdig og verifisert:
- Handicap-motor + tester (24/24, R&A-verifisert). - Handicap-motor + tester (24/24, R&A-verifisert).
- Skjema `001` + roller `002` + scoring/blind draw `003`. Isolasjon bevist med - Skjema `001` + roller `002` + scoring/blind draw `003`. Isolasjon bevist med
`test_isolation.sql` (RLS-oppførsel, ikke bare at skjemaet kjører). `test_isolation.sql` (RLS-oppførsel, ikke bare at skjemaet kjører).
- API-skjelett i `app/` (org-kontekst via SET LOCAL, medlemskapssjekk). Kun - 002 hadde en reell bug (psql interpolerer ikke `:'var'` inne i `DO $$...$$`)
syntaks-verifisert — ikke kjørt ennå (mangler container med avhengigheter). — permanent fikset, verifisert mot scratch to ganger.
- API-et kjørt for ekte (ikke bare syntaks-sjekket) i en engangs Docker-
container mot en scratch-database, RLS bevist gjennom hele
asyncpg-pool-stacken (ikke bare i rå SQL).
- Oppsett-endepunktene er bygget og verifisert: `app/routers/players.py`
(spillerpool), `tournaments.py` (turnering/lag/roster/økter, ADR-011
to-lags-grense håndhevet med `FOR UPDATE`-lås), `matches.py` (matcher/
deltakere/blind draw-lås, ADR-013-synlighet push-down i SQL). Delt
feiloversettelse i `app/errors.py`, delte synlighetsspørringer i
`app/blind_draw.py`. `main.py` er nå bare app-factory + `include_router`.
**Bevisst utelatt:** `course_handicap`/`playing_handicap` på
`match_participant` beregnes IKKE ennå (venter på motor-integrasjon i
scoring-runden — flerspillerformater trenger hele sidens spillere samtidig).
**Bevisst minimal autorisasjon:** kapteins-only er ikke bygget (se
FEATURE_BACKLOG ❓); bar er i dag org-medlemskap for oppsett, og
"rostret på laget" for deltaker-/lås-handlinger.
Neste steg: Neste steg:
1. Containerisere TeeCup-API-et (Dockerfile + compose-tjeneste), koble mot 1. Scoring-endepunkter: `hole_score`/`match_hole_result`-skriving,
matchstatus fra `handicap_engine.compute_match_state`, og
handicap-beregning (`course_handicap`/`playing_handicap`) for
`match_participant` via motoren.
2. Containerisere TeeCup-API-et (Dockerfile + compose-tjeneste), koble mot
`teecup_db` med `teecup_app`, rute via eksisterende Caddy til `teecup_db` med `teecup_app`, rute via eksisterende Caddy til
`teecup.teeoff.no`. `teecup.teeoff.no`.
2. API-endepunkter: oppsett (turnering/lag/økter/roster/blind draw) og scoring
(slag eller hull-resultat → matchstatus fra motoren).
3. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 004). 3. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 004).

41
app/blind_draw.py Normal file
View file

@ -0,0 +1,41 @@
"""
Delte spørringer for blind draw-synlighet (ADR-013).
Finkornet synlighet håndheves i APP-laget, ikke RLS begge lag i en turnering
ligger i samme organisasjon, RLS (som filtrerer organization_id) kan ikke
skille dem fra hverandre. Disse to hjelperne brukes av både
routers/tournaments.py (økt-listing: vise lås-status) og routers/matches.py
(match-listing: filtrere deltakere).
"""
from asyncpg import Connection
async def locked_team_ids(conn: Connection, session_id: str) -> set[str]:
"""Hvilke lag som har låst oppstillingen sin for denne økten."""
rows = await conn.fetch(
"SELECT team_id::text AS team_id FROM lineup_lock WHERE session_id = $1",
session_id,
)
return {r["team_id"] for r in rows}
async def own_team_ids(conn: Connection, tournament_id: str, user_id: str) -> set[str]:
"""Hvilke(t) lag brukeren selv er rostret på i denne turneringen.
Går via player.user_id (nullable) -> team_roster -> team. En org-admin uten
egen player-rad har ingen "eget lag" og får dermed tomt sett her bevisst,
ikke en feil (se matches.py).
"""
rows = await conn.fetch(
"""
SELECT DISTINCT tr.team_id::text AS team_id
FROM team_roster tr
JOIN team t ON t.id = tr.team_id
JOIN player p ON p.id = tr.player_id
WHERE t.tournament_id = $1 AND p.user_id = $2
""",
tournament_id,
user_id,
)
return {r["team_id"] for r in rows}

38
app/errors.py Normal file
View file

@ -0,0 +1,38 @@
"""
Felles feiloversettelse: Postgres-constraint-brudd -> rene HTTP-feil.
Mønsteret (unik nøkkel, fremmednøkkel, CHECK) gjentar seg tvers av routerne
(lag-navn, roster, sesjons-rekkefølge, lås). Meldingene er generiske og lekker
aldri Postgres-tekst (constraint-/tabellnavn) til klienten.
"""
from contextlib import asynccontextmanager
import asyncpg
from fastapi import HTTPException, status
# async (ikke bare @contextmanager) slik at den kan stå i samme
# "async with org_connection(...) as conn, translate_db_errors():"-setning som
# org_connection — Python krever at ALLE ledd i én async with er async
# context managers. Kroppen selv gjør ingenting async, den bare venter på at
# koden inni with-blokken (som awaiter) kaster.
@asynccontextmanager
async def translate_db_errors():
try:
yield
except asyncpg.UniqueViolationError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Raden finnes allerede (dupliserer noe som skal være unikt).",
) from e
except asyncpg.ForeignKeyViolationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Refererer til noe som ikke finnes.",
) from e
except asyncpg.CheckViolationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Verdien bryter en regel for feltet.",
) from e

View file

@ -1,12 +1,10 @@
""" """
TeeCup API skjelett. TeeCup API.
Demonstrerer det org-scopede mønsteret: hver rute under /orgs/{organization_id} App-factory + livssyklus. All forretningslogikk ligger i app/routers/ hver
verifiserer medlemskap (get_authorized_org) og kjører deretter mot en tilkobling rute der følger samme mønster: Depends(get_authorized_org) verifiserer
med org-konteksten satt lokalt (org_connection). RLS gjør resten. medlemskap, org_connection(organization_id) setter RLS-konteksten lokalt for
transaksjonen. Se ARCHITECTURE_DECISIONS.md (ADR-001013) for reglene bak.
Dette er et FUNDAMENT, ikke full CRUD. To ruter er nok til å vise mønsteret;
resten (økter, matcher, score, leaderboard) bygges samme lest.
Kjøres i app-containeren (FastAPI + asyncpg + uvicorn): Kjøres i app-containeren (FastAPI + asyncpg + uvicorn):
uvicorn app.main:app --host 0.0.0.0 --port 8000 uvicorn app.main:app --host 0.0.0.0 --port 8000
@ -14,11 +12,10 @@ Kjøres i app-containeren (FastAPI + asyncpg + uvicorn):
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI from fastapi import FastAPI
from pydantic import BaseModel, Field
from .auth import get_authorized_org from .db import init_pool, close_pool
from .db import init_pool, close_pool, org_connection from .routers import matches, players, tournaments
@asynccontextmanager @asynccontextmanager
@ -32,49 +29,11 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="TeeCup API", lifespan=lifespan) app = FastAPI(title="TeeCup API", lifespan=lifespan)
app.include_router(players.router)
class TournamentCreate(BaseModel): app.include_router(tournaments.router)
name: str = Field(min_length=1, max_length=200) app.include_router(matches.router)
class Tournament(BaseModel):
id: str
name: str
status: str
@app.get("/health") @app.get("/health")
async def health() -> dict[str, str]: async def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@app.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
async def list_tournaments(
organization_id: str = Depends(get_authorized_org),
) -> list[Tournament]:
# Ingen manuell 'WHERE organization_id = ...' — RLS filtrerer automatisk.
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
"SELECT id::text, name, status::text FROM tournament ORDER BY created_at DESC"
)
return [Tournament(id=r["id"], name=r["name"], status=r["status"]) for r in rows]
@app.post("/orgs/{organization_id}/tournaments", response_model=Tournament, status_code=201)
async def create_tournament(
body: TournamentCreate,
organization_id: str = Depends(get_authorized_org),
) -> Tournament:
# organization_id settes eksplisitt; WITH CHECK i RLS avviser uansett en verdi
# som ikke matcher konteksten — dobbel sikring.
async with org_connection(organization_id) as conn:
row = await conn.fetchrow(
"""
INSERT INTO tournament (organization_id, name)
VALUES ($1, $2)
RETURNING id::text, name, status::text
""",
organization_id,
body.name,
)
return Tournament(id=row["id"], name=row["name"], status=row["status"])

0
app/routers/__init__.py Normal file
View file

333
app/routers/matches.py Normal file
View file

@ -0,0 +1,333 @@
"""
Matcher, deltakere og blind draw-lås (ADR-013).
Autorisasjonsgrense (se plan): å opprette en match er organisator-arbeid (kun
org-medlemskap, som tournaments.py). Å legge til en DELTAKER eller LÅSE et lags
oppstilling krever i tillegg at brukeren har en team_roster-rad DET laget
her er ikke sirkularitet et problem, siden roster-en da allerede finnes.
Bevisst ikke kapteins-only ennå (FEATURE_BACKLOG: åpent spørsmål).
Synlighet (ADR-013): motstanderens deltakere er skjult i app-laget (ikke RLS
begge lag er i samme organisasjon) til BEGGE lag har låst for økten. Filteret
er lagt i SQL (WHERE ... AND ($revealed OR team_id = ANY($egne_lag))), ikke et
etterfølgende Python-filter, slik at lekkasje er strukturelt umulig.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..blind_draw import locked_team_ids, own_team_ids
from ..db import org_connection
from ..errors import translate_db_errors
router = APIRouter()
class MatchParticipantOut(BaseModel):
id: str
team_side: str
team_roster_id: str
player_name: str
tee_id: str
tee_name: str
class MatchCreate(BaseModel):
sequence: int
team_a_id: str
team_b_id: str
class MatchOut(BaseModel):
id: str
sequence: int
team_a_id: str
team_b_id: str
status_text: str | None
points_side_a: float | None
points_side_b: float | None
participants: list[MatchParticipantOut]
async def _user_may_act_for_team(conn, team_id: str, user_id: str) -> bool:
"""Brukeren har en team_roster-rad på laget (se autorisasjonsgrense i toppen)."""
return await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM team_roster tr
JOIN player p ON p.id = tr.player_id
WHERE tr.team_id = $1 AND p.user_id = $2
)
""",
team_id,
user_id,
)
@router.post(
"/orgs/{organization_id}/sessions/{session_id}/matches",
response_model=MatchOut,
status_code=201,
)
async def create_match(
session_id: str,
body: MatchCreate,
organization_id: str = Depends(get_authorized_org),
) -> MatchOut:
async with org_connection(organization_id) as conn, translate_db_errors():
session = await conn.fetchrow(
"SELECT tournament_id::text AS tournament_id FROM session WHERE id = $1", session_id
)
if session is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Økten finnes ikke.")
# Matchens FK garanterer bare at lagene er gyldige lag i ORG-en, ikke i
# DENNE turneringen — sjekk det eksplisitt her. Stoler på DB CHECK
# (team_a_id <> team_b_id) for distinkthet, dupliserer den ikke.
team_count = await conn.fetchval(
"SELECT count(*) FROM team WHERE tournament_id = $1 AND id = ANY($2::uuid[])",
session["tournament_id"],
[body.team_a_id, body.team_b_id],
)
if team_count != 2:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Begge lagene må tilhøre øktens turnering.",
)
row = await conn.fetchrow(
"""
INSERT INTO match (organization_id, session_id, sequence, team_a_id, team_b_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING id::text, sequence, team_a_id::text, team_b_id::text,
status_text, points_side_a::float AS points_side_a,
points_side_b::float AS points_side_b
""",
organization_id,
session_id,
body.sequence,
body.team_a_id,
body.team_b_id,
)
return MatchOut(**dict(row), participants=[])
@router.get(
"/orgs/{organization_id}/sessions/{session_id}/matches",
response_model=list[MatchOut],
)
async def list_matches(
session_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> list[MatchOut]:
async with org_connection(organization_id) as conn:
session = await conn.fetchrow(
"SELECT tournament_id::text AS tournament_id FROM session WHERE id = $1", session_id
)
if session is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Økten finnes ikke.")
matches = await conn.fetch(
"""
SELECT id::text, sequence, team_a_id::text, team_b_id::text,
status_text, points_side_a::float AS points_side_a,
points_side_b::float AS points_side_b
FROM match
WHERE session_id = $1
ORDER BY sequence
""",
session_id,
)
match_ids = [m["id"] for m in matches]
locked = await locked_team_ids(conn, session_id)
revealed = len(locked) >= 2
# Tomt sett hvis revealed (ubrukt da $2=true gjør ANY-leddet irrelevant).
own = set() if revealed else await own_team_ids(conn, session["tournament_id"], user.user_id)
participant_rows = await conn.fetch(
"""
SELECT mp.match_id::text AS match_id, mp.id::text, mp.team_side::text,
mp.team_roster_id::text, p.display_name AS player_name,
mp.tee_id::text, tee.name AS tee_name
FROM match_participant mp
JOIN team_roster tr ON tr.id = mp.team_roster_id
JOIN player p ON p.id = tr.player_id
JOIN tee ON tee.id = mp.tee_id
WHERE mp.match_id = ANY($1::uuid[])
AND ($2 OR tr.team_id::text = ANY($3::text[]))
""",
match_ids,
revealed,
list(own),
)
by_match: dict[str, list[MatchParticipantOut]] = {mid: [] for mid in match_ids}
for r in participant_rows:
by_match[r["match_id"]].append(
MatchParticipantOut(
id=r["id"],
team_side=r["team_side"],
team_roster_id=r["team_roster_id"],
player_name=r["player_name"],
tee_id=r["tee_id"],
tee_name=r["tee_name"],
)
)
return [
MatchOut(
id=m["id"],
sequence=m["sequence"],
team_a_id=m["team_a_id"],
team_b_id=m["team_b_id"],
status_text=m["status_text"],
points_side_a=m["points_side_a"],
points_side_b=m["points_side_b"],
participants=by_match[m["id"]],
)
for m in matches
]
class ParticipantCreate(BaseModel):
team_side: str = Field(pattern="^[ab]$")
team_roster_id: str
tee_id: str
@router.post(
"/orgs/{organization_id}/matches/{match_id}/participants",
response_model=MatchParticipantOut,
status_code=201,
)
async def add_participant(
match_id: str,
body: ParticipantCreate,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> MatchParticipantOut:
async with org_connection(organization_id) as conn, translate_db_errors():
match = await conn.fetchrow(
"""
SELECT session_id::text AS session_id, team_a_id::text AS team_a_id,
team_b_id::text AS team_b_id
FROM match WHERE id = $1
""",
match_id,
)
if match is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Matchen finnes ikke.")
expected_team_id = match["team_a_id"] if body.team_side == "a" else match["team_b_id"]
roster_team_id = await conn.fetchval(
"SELECT team_id::text FROM team_roster WHERE id = $1", body.team_roster_id
)
if roster_team_id is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Roster-oppføringen finnes ikke.")
if roster_team_id != expected_team_id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="team_roster_id tilhører ikke laget på angitt side i denne matchen.",
)
session = await conn.fetchrow(
"SELECT course_id::text AS course_id FROM session WHERE id = $1", match["session_id"]
)
tee_course_id = await conn.fetchval(
"SELECT course_id::text FROM tee WHERE id = $1", body.tee_id
)
if tee_course_id is None or tee_course_id != session["course_id"]:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail="tee_id tilhører ikke øktens bane."
)
if not await _user_may_act_for_team(conn, expected_team_id, user.user_id):
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget."
)
locked = await locked_team_ids(conn, match["session_id"])
if expected_team_id in locked:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="Laget har allerede låst oppstillingen for denne økten.",
)
# course_handicap/playing_handicap settes bevisst IKKE her — de krever
# hele sidens spillere samtidig for foursome/greensome/scramble
# (handicap_engine sine WeightedLowHigh/RankedSplit-strategier). Hører
# til motor-integrasjonen i scoring-runden, ikke ren strukturell
# oppsett (kolonnene er nullable).
row = await conn.fetchrow(
"""
WITH inserted AS (
INSERT INTO match_participant
(organization_id, match_id, team_side, team_roster_id, tee_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, team_side, team_roster_id, tee_id
)
SELECT inserted.id::text, inserted.team_side::text,
inserted.team_roster_id::text, p.display_name AS player_name,
inserted.tee_id::text, tee.name AS tee_name
FROM inserted
JOIN team_roster tr ON tr.id = inserted.team_roster_id
JOIN player p ON p.id = tr.player_id
JOIN tee ON tee.id = inserted.tee_id
""",
organization_id,
match_id,
body.team_side,
body.team_roster_id,
body.tee_id,
)
return MatchParticipantOut(**dict(row))
class LockCreate(BaseModel):
team_id: str
class LockOut(BaseModel):
team_id: str
locked_at: str
@router.post(
"/orgs/{organization_id}/sessions/{session_id}/lock",
response_model=LockOut,
status_code=201,
)
async def lock_lineup(
session_id: str,
body: LockCreate,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> LockOut:
async with org_connection(organization_id) as conn, translate_db_errors():
session = await conn.fetchrow("SELECT id FROM session WHERE id = $1", session_id)
if session is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Økten finnes ikke.")
if not await _user_may_act_for_team(conn, body.team_id, user.user_id):
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget."
)
# UNIQUE(session_id, team_id) gir 409 via translate_db_errors ved
# dobbel-lås — ingen manuell sjekk nødvendig.
row = await conn.fetchrow(
"""
INSERT INTO lineup_lock (organization_id, session_id, team_id, locked_by)
VALUES ($1, $2, $3, $4)
RETURNING team_id::text, locked_at::text
""",
organization_id,
session_id,
body.team_id,
user.user_id,
)
return LockOut(**dict(row))

63
app/routers/players.py Normal file
View file

@ -0,0 +1,63 @@
"""
Spillerpool organisasjonsnivå (ADR-001: gjenbrukbar tvers av turneringer).
Kun org-medlemskap kreves (get_authorized_org) å opprette en spiller i poolen
er organisator-arbeid, ikke noe som forutsetter rostring et lag.
"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from ..auth import get_authorized_org
from ..db import org_connection
from ..errors import translate_db_errors
router = APIRouter()
class PlayerCreate(BaseModel):
display_name: str = Field(min_length=1, max_length=200)
handicap_index: float | None = None
gender: str | None = Field(default=None, pattern="^[mfx]$")
class Player(BaseModel):
id: str
display_name: str
handicap_index: float | None
gender: str | None
@router.get("/orgs/{organization_id}/players", response_model=list[Player])
async def list_players(
organization_id: str = Depends(get_authorized_org),
) -> list[Player]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
"""
SELECT id::text, display_name, handicap_index::float AS handicap_index, gender
FROM player
ORDER BY display_name
"""
)
return [Player(**dict(r)) for r in rows]
@router.post("/orgs/{organization_id}/players", response_model=Player, status_code=201)
async def create_player(
body: PlayerCreate,
organization_id: str = Depends(get_authorized_org),
) -> Player:
async with org_connection(organization_id) as conn, translate_db_errors():
row = await conn.fetchrow(
"""
INSERT INTO player (organization_id, display_name, handicap_index, gender)
VALUES ($1, $2, $3, $4)
RETURNING id::text, display_name, handicap_index::float AS handicap_index, gender
""",
organization_id,
body.display_name,
body.handicap_index,
body.gender,
)
return Player(**dict(row))

346
app/routers/tournaments.py Normal file
View file

@ -0,0 +1,346 @@
"""
Turneringsoppsett: turneringer, lag, roster, økter.
Autorisasjonsgrense (se plan): kun org-medlemskap (get_authorized_org) kreves
her dette er organisator-arbeid. Å kreve at brukeren allerede er rostret
et lag ville vært sirkulært for lag-/roster-opprettelse (ingen kan være
rostret et lag som ikke finnes ennå).
"""
import json
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..auth import get_authorized_org
from ..blind_draw import locked_team_ids
from ..db import org_connection
from ..errors import translate_db_errors
router = APIRouter()
# ---------------------------------------------------------------------------
# Turneringer (flyttet uendret fra tidligere main.py)
# ---------------------------------------------------------------------------
class TournamentCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
class Tournament(BaseModel):
id: str
name: str
status: str
@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
async def list_tournaments(
organization_id: str = Depends(get_authorized_org),
) -> list[Tournament]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
"SELECT id::text, name, status::text FROM tournament ORDER BY created_at DESC"
)
return [Tournament(id=r["id"], name=r["name"], status=r["status"]) for r in rows]
@router.post("/orgs/{organization_id}/tournaments", response_model=Tournament, status_code=201)
async def create_tournament(
body: TournamentCreate,
organization_id: str = Depends(get_authorized_org),
) -> Tournament:
async with org_connection(organization_id) as conn:
row = await conn.fetchrow(
"""
INSERT INTO tournament (organization_id, name)
VALUES ($1, $2)
RETURNING id::text, name, status::text
""",
organization_id,
body.name,
)
return Tournament(id=row["id"], name=row["name"], status=row["status"])
# ---------------------------------------------------------------------------
# Lag (ADR-011: maks to lag per turnering, håndhevet her — ikke i skjemaet —
# slik at match-modellen kan holdes generell for fremtidige turneringstyper)
# ---------------------------------------------------------------------------
class TeamCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
color: str | None = None
class Team(BaseModel):
id: str
name: str
color: str | None
@router.get("/orgs/{organization_id}/tournaments/{tournament_id}/teams", response_model=list[Team])
async def list_teams(
tournament_id: str,
organization_id: str = Depends(get_authorized_org),
) -> list[Team]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
"SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at",
tournament_id,
)
return [Team(**dict(r)) for r in rows]
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/teams",
response_model=Team,
status_code=201,
)
async def create_team(
tournament_id: str,
body: TeamCreate,
organization_id: str = Depends(get_authorized_org),
) -> Team:
async with org_connection(organization_id) as conn, translate_db_errors():
# Lås turneringsraden slik at to samtidige forespørsler ikke begge kan
# lese "1 lag" og begge sette inn et andre/tredje lag (TOCTOU).
locked = await conn.fetchrow(
"SELECT id FROM tournament WHERE id = $1 AND organization_id = $2 FOR UPDATE",
tournament_id,
organization_id,
)
if locked is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Turneringen finnes ikke.")
existing = await conn.fetchval(
"SELECT count(*) FROM team WHERE tournament_id = $1", tournament_id
)
if existing >= 2:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="Turneringen har allerede to lag (ADR-011: nøyaktig to lag).",
)
row = await conn.fetchrow(
"""
INSERT INTO team (organization_id, tournament_id, name, color)
VALUES ($1, $2, $3, $4)
RETURNING id::text, name, color
""",
organization_id,
tournament_id,
body.name,
body.color,
)
return Team(**dict(row))
# ---------------------------------------------------------------------------
# Roster (spiller-pool per lag, ADR-007)
# ---------------------------------------------------------------------------
class RosterEntryCreate(BaseModel):
player_id: str
is_captain: bool = False
# None => fryses fra player.handicap_index ved innsetting (ADR-007).
handicap_index_snapshot: float | None = None
class RosterEntry(BaseModel):
id: str
player_id: str
display_name: str
handicap_index_snapshot: float | None
is_captain: bool
@router.get("/orgs/{organization_id}/teams/{team_id}/roster", response_model=list[RosterEntry])
async def list_roster(
team_id: str,
organization_id: str = Depends(get_authorized_org),
) -> list[RosterEntry]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
"""
SELECT tr.id::text, tr.player_id::text,
tr.handicap_index_snapshot::float AS handicap_index_snapshot,
tr.is_captain, p.display_name
FROM team_roster tr
JOIN player p ON p.id = tr.player_id
WHERE tr.team_id = $1
ORDER BY p.display_name
""",
team_id,
)
return [RosterEntry(**dict(r)) for r in rows]
@router.post(
"/orgs/{organization_id}/teams/{team_id}/roster",
response_model=RosterEntry,
status_code=201,
)
async def add_roster_entry(
team_id: str,
body: RosterEntryCreate,
organization_id: str = Depends(get_authorized_org),
) -> RosterEntry:
async with org_connection(organization_id) as conn, translate_db_errors():
team = await conn.fetchrow("SELECT tournament_id FROM team WHERE id = $1", team_id)
if team is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Laget finnes ikke.")
# Skjemaet håndhever kun unikhet PER lag (UNIQUE(team_id, player_id)).
# Uten denne sjekken kunne en spiller rostres på BEGGE lag i samme
# turnering, og own_team_ids() (blind draw-filteret i matches.py) ville
# da regnet hen som "eget lag" på begge sider — hen ville sett
# motstanderens skjulte oppstilling før lås (ADR-013).
already_elsewhere = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM team_roster tr
JOIN team t ON t.id = tr.team_id
WHERE t.tournament_id = $1 AND tr.player_id = $2 AND tr.team_id <> $3
)
""",
team["tournament_id"],
body.player_id,
team_id,
)
if already_elsewhere:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="Spilleren er allerede rostret på det andre laget i denne turneringen.",
)
snapshot = body.handicap_index_snapshot
if snapshot is None:
snapshot = await conn.fetchval(
"SELECT handicap_index FROM player WHERE id = $1", body.player_id
)
row = await conn.fetchrow(
"""
WITH inserted AS (
INSERT INTO team_roster
(organization_id, team_id, player_id, handicap_index_snapshot, is_captain)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, player_id, handicap_index_snapshot, is_captain
)
SELECT inserted.id::text, inserted.player_id::text,
inserted.handicap_index_snapshot::float AS handicap_index_snapshot,
inserted.is_captain, p.display_name
FROM inserted JOIN player p ON p.id = inserted.player_id
""",
organization_id,
team_id,
body.player_id,
snapshot,
body.is_captain,
)
return RosterEntry(**dict(row))
# ---------------------------------------------------------------------------
# Økter (ADR-007: turnering = ordnet sekvens av økter)
# ---------------------------------------------------------------------------
class SessionCreate(BaseModel):
sequence: int
name: str | None = None
format: str
hole_config: str = "full_18"
course_id: str
points_per_match: float = 1.0
allowance_override: dict | None = None
class SessionOut(BaseModel):
id: str
sequence: int
name: str | None
format: str
hole_config: str
course_id: str
points_per_match: float
allowance_override: dict | None
locked_team_ids: list[str]
revealed: bool
def _session_out(row, locked: set[str]) -> SessionOut:
return SessionOut(
id=row["id"],
sequence=row["sequence"],
name=row["name"],
format=row["format"],
hole_config=row["hole_config"],
course_id=row["course_id"],
points_per_match=row["points_per_match"],
allowance_override=json.loads(row["allowance_override"]) if row["allowance_override"] else None,
locked_team_ids=sorted(locked),
revealed=len(locked) >= 2,
)
@router.get(
"/orgs/{organization_id}/tournaments/{tournament_id}/sessions",
response_model=list[SessionOut],
)
async def list_sessions(
tournament_id: str,
organization_id: str = Depends(get_authorized_org),
) -> list[SessionOut]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
"""
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
course_id::text, points_per_match::float AS points_per_match,
allowance_override::text AS allowance_override
FROM session
WHERE tournament_id = $1
ORDER BY sequence
""",
tournament_id,
)
result = []
for r in rows:
locked = await locked_team_ids(conn, r["id"])
result.append(_session_out(r, locked))
return result
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/sessions",
response_model=SessionOut,
status_code=201,
)
async def create_session(
tournament_id: str,
body: SessionCreate,
organization_id: str = Depends(get_authorized_org),
) -> SessionOut:
allowance_json = json.dumps(body.allowance_override) if body.allowance_override is not None else None
async with org_connection(organization_id) as conn, translate_db_errors():
row = await conn.fetchrow(
"""
INSERT INTO session
(organization_id, tournament_id, sequence, name, format,
hole_config, course_id, points_per_match, allowance_override)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
course_id::text, points_per_match::float AS points_per_match,
allowance_override::text AS allowance_override
""",
organization_id,
tournament_id,
body.sequence,
body.name,
body.format,
body.hole_config,
body.course_id,
body.points_per_match,
allowance_json,
)
return _session_out(row, set())