teecup/app/routers/matches.py

333 lines
12 KiB
Python

"""
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))