From c71479351bccfa410f7d87303a63f64db4d5b97b Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Thu, 16 Jul 2026 09:16:22 +0200 Subject: [PATCH] =?UTF-8?q?Alle=20"oppsett"-endepunktene=20fra=20planen=20?= =?UTF-8?q?er=20bygget=20og=20verifisert=20for=20ekte=20mot=20scratch-data?= =?UTF-8?q?basen=20(fersk=20teecup=5Fscratch,=20001=E2=86=92002=E2=86=9200?= =?UTF-8?q?3,=20container=20med=20pip=20install=20+=20uvicorn,=20curl=20gj?= =?UTF-8?q?ennom=20hele=20flyten,=20deretter=20fullstendig=20opprydding).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 45 ++++- CLAUDE.md | 27 ++- app/blind_draw.py | 41 +++++ app/errors.py | 38 ++++ app/main.py | 63 ++----- app/routers/__init__.py | 0 app/routers/matches.py | 333 ++++++++++++++++++++++++++++++++++ app/routers/players.py | 63 +++++++ app/routers/tournaments.py | 346 ++++++++++++++++++++++++++++++++++++ 9 files changed, 898 insertions(+), 58 deletions(-) create mode 100644 app/blind_draw.py create mode 100644 app/errors.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/matches.py create mode 100644 app/routers/players.py create mode 100644 app/routers/tournaments.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 86dd4c1..f68bf0b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,50 @@ "Bash(echo \"EXIT:$?\")", "Read(//tmp/**)", "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)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 414f966..3dd833b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,13 +46,30 @@ Ferdig og verifisert: - Handicap-motor + tester (24/24, R&A-verifisert). - Skjema `001` + roller `002` + scoring/blind draw `003`. Isolasjon bevist med `test_isolation.sql` (RLS-oppførsel, ikke bare at skjemaet kjører). -- API-skjelett i `app/` (org-kontekst via SET LOCAL, medlemskapssjekk). Kun - syntaks-verifisert — ikke kjørt ennå (mangler container med avhengigheter). +- 002 hadde en reell bug (psql interpolerer ikke `:'var'` inne i `DO $$...$$`) + — 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: -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.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). diff --git a/app/blind_draw.py b/app/blind_draw.py new file mode 100644 index 0000000..8f4db06 --- /dev/null +++ b/app/blind_draw.py @@ -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, så RLS (som filtrerer på 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} diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 0000000..688ceb6 --- /dev/null +++ b/app/errors.py @@ -0,0 +1,38 @@ +""" +Felles feiloversettelse: Postgres-constraint-brudd -> rene HTTP-feil. + +Mønsteret (unik nøkkel, fremmednøkkel, CHECK) gjentar seg på tvers av routerne +(lag-navn, roster, sesjons-rekkefølge, lås). Meldingene er generiske og lekker +aldri rå 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 diff --git a/app/main.py b/app/main.py index 19af714..5ce6f13 100644 --- a/app/main.py +++ b/app/main.py @@ -1,12 +1,10 @@ """ -TeeCup API — skjelett. +TeeCup API. -Demonstrerer det org-scopede mønsteret: hver rute under /orgs/{organization_id} -verifiserer medlemskap (get_authorized_org) og kjører deretter mot en tilkobling -med org-konteksten satt lokalt (org_connection). RLS gjør resten. - -Dette er et FUNDAMENT, ikke full CRUD. To ruter er nok til å vise mønsteret; -resten (økter, matcher, score, leaderboard) bygges på samme lest. +App-factory + livssyklus. All forretningslogikk ligger i app/routers/ — hver +rute der følger samme mønster: Depends(get_authorized_org) verifiserer +medlemskap, org_connection(organization_id) setter RLS-konteksten lokalt for +transaksjonen. Se ARCHITECTURE_DECISIONS.md (ADR-001…013) for reglene bak. Kjøres i app-containeren (FastAPI + asyncpg + uvicorn): 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 fastapi import Depends, FastAPI -from pydantic import BaseModel, Field +from fastapi import FastAPI -from .auth import get_authorized_org -from .db import init_pool, close_pool, org_connection +from .db import init_pool, close_pool +from .routers import matches, players, tournaments @asynccontextmanager @@ -32,49 +29,11 @@ async def lifespan(app: FastAPI): app = FastAPI(title="TeeCup API", lifespan=lifespan) - -class TournamentCreate(BaseModel): - name: str = Field(min_length=1, max_length=200) - - -class Tournament(BaseModel): - id: str - name: str - status: str +app.include_router(players.router) +app.include_router(tournaments.router) +app.include_router(matches.router) @app.get("/health") async def health() -> dict[str, str]: 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"]) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/matches.py b/app/routers/matches.py new file mode 100644 index 0000000..d6ddb04 --- /dev/null +++ b/app/routers/matches.py @@ -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 på 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)) diff --git a/app/routers/players.py b/app/routers/players.py new file mode 100644 index 0000000..e2b3196 --- /dev/null +++ b/app/routers/players.py @@ -0,0 +1,63 @@ +""" +Spillerpool på organisasjonsnivå (ADR-001: gjenbrukbar på tvers av turneringer). + +Kun org-medlemskap kreves (get_authorized_org) — å opprette en spiller i poolen +er organisator-arbeid, ikke noe som forutsetter rostring på 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)) diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py new file mode 100644 index 0000000..f00dd9c --- /dev/null +++ b/app/routers/tournaments.py @@ -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 på +et lag ville vært sirkulært for lag-/roster-opprettelse (ingen kan være +rostret på 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())