Compare commits
2 commits
4f90fb05dc
...
c71479351b
| Author | SHA1 | Date | |
|---|---|---|---|
| c71479351b | |||
| 576ae64fa3 |
10 changed files with 917 additions and 71 deletions
|
|
@ -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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,19 +19,25 @@
|
||||||
|
|
||||||
-- --- 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
|
||||||
CREATE ROLE teecup_app
|
-- "syntax error at or near ':'". Erstattet med \if/\else slik at
|
||||||
LOGIN
|
-- PASSWORD-linjen står som vanlig SQL utenfor $$ $$ (verifisert mot
|
||||||
PASSWORD :'teecup_app_password'
|
-- scratch-database, se test_isolation.sql-kjøringen).
|
||||||
NOSUPERUSER
|
SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'teecup_app') AS teecup_app_exists \gset
|
||||||
NOCREATEDB
|
\if :teecup_app_exists
|
||||||
NOCREATEROLE
|
\echo 'teecup_app finnes allerede, hopper over CREATE ROLE'
|
||||||
NOBYPASSRLS
|
\else
|
||||||
INHERIT;
|
CREATE ROLE teecup_app
|
||||||
END IF;
|
LOGIN
|
||||||
END $$;
|
PASSWORD :'teecup_app_password'
|
||||||
|
NOSUPERUSER
|
||||||
|
NOCREATEDB
|
||||||
|
NOCREATEROLE
|
||||||
|
NOBYPASSRLS
|
||||||
|
INHERIT;
|
||||||
|
\endif
|
||||||
|
|
||||||
-- --- 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;
|
||||||
|
|
|
||||||
27
CLAUDE.md
27
CLAUDE.md
|
|
@ -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
41
app/blind_draw.py
Normal 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, 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}
|
||||||
38
app/errors.py
Normal file
38
app/errors.py
Normal file
|
|
@ -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
|
||||||
63
app/main.py
63
app/main.py
|
|
@ -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-001…013) for reglene bak.
|
||||||
Dette er et FUNDAMENT, ikke full CRUD. To ruter er nok til å vise mønsteret;
|
|
||||||
resten (økter, matcher, score, leaderboard) bygges på 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
0
app/routers/__init__.py
Normal file
333
app/routers/matches.py
Normal file
333
app/routers/matches.py
Normal 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 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))
|
||||||
63
app/routers/players.py
Normal file
63
app/routers/players.py
Normal file
|
|
@ -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))
|
||||||
346
app/routers/tournaments.py
Normal file
346
app/routers/tournaments.py
Normal 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 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())
|
||||||
Loading…
Reference in a new issue