2026-07-16 09:16:22 +02:00
|
|
|
"""
|
|
|
|
|
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.
|
2026-07-17 21:40:42 +02:00
|
|
|
|
|
|
|
|
Klokkeslett (se plan): match.tee_time er UTLEDET, ikke lagret -- session sin
|
|
|
|
|
scheduled_at + (sequence-1) * tee_interval_minutes, med en valgfri
|
|
|
|
|
tee_time_override som vinner hvis satt. Beregnes i Python i både create_match
|
|
|
|
|
og list_matches, som allerede henter økten for andre formål.
|
2026-07-16 09:16:22 +02:00
|
|
|
"""
|
|
|
|
|
|
2026-07-16 14:38:42 +02:00
|
|
|
import json
|
2026-07-17 21:40:42 +02:00
|
|
|
from datetime import datetime, timedelta
|
2026-07-16 14:38:42 +02:00
|
|
|
|
2026-07-17 21:40:42 +02:00
|
|
|
from fastapi import APIRouter, Depends
|
2026-07-16 09:16:22 +02:00
|
|
|
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
|
2026-07-17 21:40:42 +02:00
|
|
|
from ..errors import app_error, translate_db_errors
|
2026-07-16 14:38:42 +02:00
|
|
|
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
|
|
|
|
|
from ..team_authz import user_may_act_for_team
|
2026-07-16 09:16:22 +02:00
|
|
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 21:40:42 +02:00
|
|
|
def _compute_tee_time(
|
|
|
|
|
scheduled_at: datetime | None,
|
|
|
|
|
tee_interval_minutes: int | None,
|
|
|
|
|
sequence: int,
|
|
|
|
|
override: datetime | None,
|
|
|
|
|
) -> datetime | None:
|
|
|
|
|
if override is not None:
|
|
|
|
|
return override
|
|
|
|
|
if scheduled_at is None or tee_interval_minutes is None:
|
|
|
|
|
return None
|
|
|
|
|
return scheduled_at + timedelta(minutes=(sequence - 1) * tee_interval_minutes)
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 09:16:22 +02:00
|
|
|
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
|
2026-07-17 21:40:42 +02:00
|
|
|
tee_time: datetime | None
|
2026-07-16 09:16:22 +02:00
|
|
|
participants: list[MatchParticipantOut]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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(
|
2026-07-17 21:40:42 +02:00
|
|
|
"""
|
|
|
|
|
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes
|
|
|
|
|
FROM session WHERE id = $1
|
|
|
|
|
""",
|
|
|
|
|
session_id,
|
2026-07-16 09:16:22 +02:00
|
|
|
)
|
|
|
|
|
if session is None:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
|
2026-07-16 09:16:22 +02:00
|
|
|
|
|
|
|
|
# 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:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(400, "VALIDATION_FAILED", "Begge lagene må tilhøre øktens turnering.")
|
2026-07-16 09:16:22 +02:00
|
|
|
|
|
|
|
|
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,
|
2026-07-17 21:40:42 +02:00
|
|
|
points_side_b::float AS points_side_b, tee_time_override
|
2026-07-16 09:16:22 +02:00
|
|
|
""",
|
|
|
|
|
organization_id,
|
|
|
|
|
session_id,
|
|
|
|
|
body.sequence,
|
|
|
|
|
body.team_a_id,
|
|
|
|
|
body.team_b_id,
|
|
|
|
|
)
|
2026-07-17 21:40:42 +02:00
|
|
|
tee_time = _compute_tee_time(
|
|
|
|
|
session["scheduled_at"], session["tee_interval_minutes"], row["sequence"], row["tee_time_override"]
|
|
|
|
|
)
|
|
|
|
|
return MatchOut(**dict(row), tee_time=tee_time, participants=[])
|
2026-07-16 09:16:22 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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(
|
2026-07-17 21:40:42 +02:00
|
|
|
"""
|
|
|
|
|
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes
|
|
|
|
|
FROM session WHERE id = $1
|
|
|
|
|
""",
|
|
|
|
|
session_id,
|
2026-07-16 09:16:22 +02:00
|
|
|
)
|
|
|
|
|
if session is None:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
|
2026-07-16 09:16:22 +02:00
|
|
|
|
|
|
|
|
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,
|
2026-07-17 21:40:42 +02:00
|
|
|
points_side_b::float AS points_side_b, tee_time_override
|
2026-07-16 09:16:22 +02:00
|
|
|
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"],
|
2026-07-17 21:40:42 +02:00
|
|
|
tee_time=_compute_tee_time(
|
|
|
|
|
session["scheduled_at"], session["tee_interval_minutes"], m["sequence"], m["tee_time_override"]
|
|
|
|
|
),
|
2026-07-16 09:16:22 +02:00
|
|
|
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:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
|
2026-07-16 09:16:22 +02:00
|
|
|
|
|
|
|
|
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:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.")
|
2026-07-16 09:16:22 +02:00
|
|
|
if roster_team_id != expected_team_id:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(
|
|
|
|
|
400, "MISMATCHED_SIDE", "team_roster_id tilhører ikke laget på angitt side i denne matchen."
|
2026-07-16 09:16:22 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
session = await conn.fetchrow(
|
2026-07-16 14:38:42 +02:00
|
|
|
"""
|
|
|
|
|
SELECT course_id::text AS course_id, format, hole_config::text AS hole_config,
|
|
|
|
|
allowance_override::text AS allowance_override
|
|
|
|
|
FROM session WHERE id = $1
|
|
|
|
|
""",
|
|
|
|
|
match["session_id"],
|
2026-07-16 09:16:22 +02:00
|
|
|
)
|
|
|
|
|
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"]:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke øktens bane.")
|
2026-07-16 09:16:22 +02:00
|
|
|
|
2026-07-16 14:38:42 +02:00
|
|
|
if not await user_may_act_for_team(conn, expected_team_id, user.user_id):
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
2026-07-16 09:16:22 +02:00
|
|
|
|
|
|
|
|
locked = await locked_team_ids(conn, match["session_id"])
|
|
|
|
|
if expected_team_id in locked:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(
|
|
|
|
|
409, "ALREADY_LOCKED", "Laget har allerede låst oppstillingen for denne økten."
|
2026-07-16 09:16:22 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|
2026-07-16 14:38:42 +02:00
|
|
|
|
|
|
|
|
# Beregn/lagre course_handicap/playing_handicap nå som deltakeren er
|
|
|
|
|
# satt inn. Funksjonen avgjør selv om siden er "klar" (singles/
|
|
|
|
|
# fourball: alltid; foursome/greensome/scramble: kun når siden er
|
|
|
|
|
# komplett) -- se app/handicap.py.
|
|
|
|
|
allowance_override = (
|
|
|
|
|
json.loads(session["allowance_override"]) if session["allowance_override"] else None
|
|
|
|
|
)
|
|
|
|
|
config = parse_allowance_config(session["format"], allowance_override)
|
|
|
|
|
await compute_and_store_side_handicaps(
|
|
|
|
|
conn, match_id, body.team_side, session["format"], session["hole_config"], config
|
|
|
|
|
)
|
2026-07-16 09:16:22 +02:00
|
|
|
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:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
|
2026-07-16 09:16:22 +02:00
|
|
|
|
2026-07-16 14:38:42 +02:00
|
|
|
if not await user_may_act_for_team(conn, body.team_id, user.user_id):
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
2026-07-16 09:16:22 +02:00
|
|
|
|
|
|
|
|
# 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))
|