Del C (ADR-068, migrasjon 072, siste del av den tredelte utvidelsen som startet med Flaggturnering GPS/kart, se ADR-066/067): nytt format eclectic_gross/eclectic_net/eclectic_stableford -- beste resultat per hull på tvers av en turnerings egne runder, krever samme bane (avvist tydelig ved rundeopprettelse ellers). Regnes ut ved lesing, ingen nye tabeller. Bevisst avvik fra opprinnelig plan: integrert som en ny gren i eksisterende individual-leaderboard-endepunkt fremfor et nytt eget endepunkt -- se ADR-068 for begrunnelsen. Tre ikke-relaterte, brukerrapporterte UI-rettelser tatt med i samme runde: avstandsindikatoren brukte "grønn"/"Midt" i stedet for riktige golf-uttrykk "green"/"senter", og "Oppdateres live"-badgen fjernet. "Antall hull"-bryteren i Ny runde-veiviseren fikk samme grønne aksent-valgt-stil som resten av samme skjerm (delt Segmented-primitiv). Se ARCHITECTURE_DECISIONS.md (ADR-068) og CHANGELOG.md (punkt 84) for full begrunnelse og verifiseringslogg. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1855 lines
72 KiB
Python
1855 lines
72 KiB
Python
"""
|
|
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
|
|
import secrets
|
|
import traceback
|
|
from datetime import date, datetime, timedelta, timezone
|
|
|
|
import asyncpg
|
|
from fastapi import APIRouter, Depends, UploadFile
|
|
from pydantic import BaseModel, Field
|
|
|
|
from handicap_engine import allocate_over_played_holes
|
|
|
|
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
|
from ..blind_draw import locked_team_ids, own_team_ids
|
|
from ..config import settings
|
|
from ..db import org_connection
|
|
from ..email import send_session_result_email
|
|
from ..errors import app_error, translate_db_errors
|
|
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
|
|
from .auth import _hash_secret
|
|
from ..team_authz import is_org_member, user_is_team_captain, user_is_tournament_participant
|
|
from .scoring import apply_concession, played_hole_numbers, recompute_and_cache_match_state
|
|
from .. import storage
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Turneringer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TournamentCreate(BaseModel):
|
|
name: str = Field(min_length=1, max_length=200)
|
|
start_date: date | None = None
|
|
end_date: date | None = None
|
|
# ADR-037: 'team' (dagens Ryder Cup-modell, uendret default) eller
|
|
# 'individual' (flatt felt, egen tabellstruktur -- se
|
|
# 040_individual_tournaments.sql). scoring_method er kun meningsfullt
|
|
# for individual, håndhevet i app-laget når runder/score bygges på den.
|
|
format_type: str = Field(default="team", pattern="^(team|individual)$")
|
|
scoring_method: str | None = Field(
|
|
default=None,
|
|
pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag|"
|
|
"eclectic_gross|eclectic_net|eclectic_stableford)$",
|
|
)
|
|
# KUN meningsfullt når scoring_method='bingo_bango_bongo' -- valgfri
|
|
# organisator-innstilling (samme som frittstående runders round.
|
|
# bbb_sweep_bonus_enabled), ikke fast påslått.
|
|
bbb_sweep_bonus_enabled: bool = False
|
|
|
|
|
|
_TOURNAMENT_COLUMNS = """
|
|
id::text, name, status::text, start_date, end_date,
|
|
visibility, description, registration_deadline, registration_capacity,
|
|
registration_overflow_policy, registration_requires_approval, hero_image_key,
|
|
join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible
|
|
"""
|
|
|
|
# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A).
|
|
_JOIN_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
|
|
|
|
def _generate_join_code() -> str:
|
|
return "".join(secrets.choice(_JOIN_CODE_ALPHABET) for _ in range(6))
|
|
|
|
|
|
class Tournament(BaseModel):
|
|
id: str
|
|
name: str
|
|
status: str
|
|
start_date: date | None
|
|
end_date: date | None
|
|
visibility: str
|
|
description: str | None
|
|
registration_deadline: datetime | None
|
|
registration_capacity: int | None
|
|
registration_overflow_policy: str
|
|
registration_requires_approval: bool
|
|
hero_image_key: str | None
|
|
hero_image_url: str | None
|
|
join_code: str
|
|
format_type: str
|
|
scoring_method: str | None
|
|
bbb_sweep_bonus_enabled: bool
|
|
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
|
flag_map_visible: bool
|
|
|
|
|
|
def _tournament_from_row(row) -> Tournament:
|
|
"""Samme mønster som auth.py sin avatar_url: DB-kolonnen forblir
|
|
`hero_image_key` (den nøytrale, lagrings-uavhengige verdien), men
|
|
responsen får i tillegg en ferdig-konstruert URL -- organisator-
|
|
frontend skal aldri selv måtte kjenne til MinIO-bucket/base-URL."""
|
|
data = dict(row)
|
|
key = data["hero_image_key"]
|
|
return Tournament(**data, hero_image_url=storage.public_url(key) if key else None)
|
|
|
|
|
|
@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
|
|
async def list_tournaments(
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> list[Tournament]:
|
|
# ADR-030: dashbord-kortet skal vise turneringens FAKTISKE datospenn.
|
|
# `tournament.start_date`/`end_date` er egne, eksplisitte felt (ADR-015)
|
|
# som ingen UI noensinne har hatt en vei til å SETTE -- alltid NULL i
|
|
# praksis. Eksplisitt satt verdi vinner hvis den noensinne blir satt
|
|
# (fremtidig PATCH-vei), ellers utledes spennet fra øktenes
|
|
# `scheduled_at` -- informasjonen finnes jo allerede der.
|
|
async with org_connection(organization_id) as conn:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT t.id::text, t.name, t.status::text,
|
|
COALESCE(t.start_date, s.min_date) AS start_date,
|
|
COALESCE(t.end_date, s.max_date) AS end_date,
|
|
t.visibility, t.description, t.registration_deadline,
|
|
t.registration_capacity, t.registration_overflow_policy,
|
|
t.registration_requires_approval, t.hero_image_key,
|
|
t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled,
|
|
t.flag_map_visible
|
|
FROM tournament t
|
|
LEFT JOIN (
|
|
SELECT tournament_id,
|
|
min(scheduled_at)::date AS min_date,
|
|
max(scheduled_at)::date AS max_date
|
|
FROM session
|
|
WHERE scheduled_at IS NOT NULL
|
|
GROUP BY tournament_id
|
|
) s ON s.tournament_id = t.id
|
|
ORDER BY t.created_at DESC
|
|
"""
|
|
)
|
|
return [_tournament_from_row(r) 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, translate_db_errors():
|
|
# join_code er globalt unikt (migrasjon 011) -- kollisjon er
|
|
# astronomisk usannsynlig (33^6 ≈ 1,3 mrd. kombinasjoner mot et
|
|
# lite antall turneringer totalt), men en liten retry-løkke koster
|
|
# ingenting og gjør oppførselen presis i stedet for å anta.
|
|
row = None
|
|
for _ in range(5):
|
|
try:
|
|
row = await conn.fetchrow(
|
|
f"""
|
|
INSERT INTO tournament
|
|
(organization_id, name, start_date, end_date, join_code, format_type,
|
|
scoring_method, bbb_sweep_bonus_enabled)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING {_TOURNAMENT_COLUMNS}
|
|
""",
|
|
organization_id,
|
|
body.name,
|
|
body.start_date,
|
|
body.end_date,
|
|
_generate_join_code(),
|
|
body.format_type,
|
|
body.scoring_method,
|
|
body.bbb_sweep_bonus_enabled,
|
|
)
|
|
break
|
|
except asyncpg.UniqueViolationError:
|
|
continue
|
|
if row is None:
|
|
raise app_error(500, "VALIDATION_FAILED", "Klarte ikke å generere en unik invitasjonskode. Prøv igjen.")
|
|
return _tournament_from_row(row)
|
|
|
|
|
|
class TournamentUpdate(BaseModel):
|
|
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
|
|
(PATCH-semantikk via Pydantic sin exclude_unset, se update_tournament)."""
|
|
|
|
status: str | None = Field(default=None, pattern="^(draft|active|completed|archived)$")
|
|
visibility: str | None = Field(default=None, pattern="^(public|org|participants)$")
|
|
format_type: str | None = Field(default=None, pattern="^(team|individual)$")
|
|
scoring_method: str | None = Field(
|
|
default=None,
|
|
pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag|"
|
|
"eclectic_gross|eclectic_net|eclectic_stableford)$",
|
|
)
|
|
description: str | None = None
|
|
registration_deadline: datetime | None = None
|
|
registration_capacity: int | None = Field(default=None, gt=0)
|
|
registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$")
|
|
registration_requires_approval: bool | None = None
|
|
bbb_sweep_bonus_enabled: bool | None = None
|
|
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067)
|
|
# -- ethvert org-medlem (samme tilgang som resten av denne PATCH-en, se
|
|
# get_authorized_org under). Standard AV, samme "skjult til avslørt"-
|
|
# filosofi som visibility.
|
|
flag_map_visible: bool | None = None
|
|
# hero_image_key er BEVISST IKKE med her -- den settes kun via
|
|
# upload_hero_image under, aldri som en fritekst-streng klienten sender,
|
|
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
|
|
|
|
|
|
@router.patch("/orgs/{organization_id}/tournaments/{tournament_id}", response_model=Tournament)
|
|
async def update_tournament(
|
|
tournament_id: str,
|
|
body: TournamentUpdate,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> Tournament:
|
|
# exclude_unset: kun felt klienten FAKTISK sendte skal skrives -- et
|
|
# utelatt felt skal IKKE nullstilles (ekte PATCH-semantikk, ikke PUT).
|
|
updates = body.model_dump(exclude_unset=True)
|
|
if not updates:
|
|
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
|
|
|
# Trygt mot injeksjon: nøklene kommer KUN fra TournamentUpdate sine egne
|
|
# feltnavn (en fast, kjent hviteliste), aldri fra en rå streng brukeren
|
|
# sender inn.
|
|
# `status` er -- ulikt de andre feltene her -- en EKTE Postgres ENUM-type
|
|
# (tournament_status, migrasjon 001), ikke text+CHECK. asyncpg sender
|
|
# parameteren som ukjent/text; et eksplisitt cast trengs for at
|
|
# tildelingen skal løses riktig.
|
|
set_clauses = [
|
|
f"{key} = ${i}::tournament_status" if key == "status" else f"{key} = ${i}"
|
|
for i, key in enumerate(updates, start=1)
|
|
]
|
|
values = list(updates.values())
|
|
values.append(tournament_id)
|
|
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
row = await conn.fetchrow(
|
|
f"""
|
|
UPDATE tournament SET {', '.join(set_clauses)}
|
|
WHERE id = ${len(values)}
|
|
RETURNING {_TOURNAMENT_COLUMNS}
|
|
""",
|
|
*values,
|
|
)
|
|
if row is None:
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
return _tournament_from_row(row)
|
|
|
|
|
|
@router.post(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/hero-image",
|
|
response_model=Tournament,
|
|
)
|
|
async def upload_hero_image(
|
|
tournament_id: str,
|
|
file: UploadFile,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> Tournament:
|
|
"""Ekte multipart-opplasting (IKKE en presignert nettleser-URL): filen
|
|
går gjennom API-et, som konverterer den til AVIF før den lagres i MinIO
|
|
-- se app/storage.py sin moduldocstring for hvorfor."""
|
|
if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES:
|
|
raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")
|
|
|
|
raw = await file.read(storage.MAX_UPLOAD_BYTES + 1)
|
|
if len(raw) > storage.MAX_UPLOAD_BYTES:
|
|
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
|
|
if exists is None:
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
|
|
try:
|
|
key = await storage.upload_image("tournaments", tournament_id, raw)
|
|
except storage.InvalidImageError:
|
|
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
|
|
|
|
row = await conn.fetchrow(
|
|
f"UPDATE tournament SET hero_image_key = $1 WHERE id = $2 RETURNING {_TOURNAMENT_COLUMNS}",
|
|
key,
|
|
tournament_id,
|
|
)
|
|
return _tournament_from_row(row)
|
|
|
|
|
|
@router.delete(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/hero-image",
|
|
response_model=Tournament,
|
|
)
|
|
async def delete_hero_image(
|
|
tournament_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> Tournament:
|
|
"""Samme mønster som DELETE /auth/profile/avatar -- nullstiller kun
|
|
referansen, sletter ikke selve MinIO-objektet (samme aksepterte
|
|
forenkling som avatar-fjerning)."""
|
|
async with org_connection(organization_id) as conn:
|
|
row = await conn.fetchrow(
|
|
f"UPDATE tournament SET hero_image_key = NULL WHERE id = $1 RETURNING {_TOURNAMENT_COLUMNS}",
|
|
tournament_id,
|
|
)
|
|
if row is None:
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
return _tournament_from_row(row)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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,
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> list[Team]:
|
|
"""2026-07-21 (deltaker-tilgang-runden): org-medlem ELLER deltaker i
|
|
denne turneringen, samme mønster som scoring.py sin get_scorecard --
|
|
laste team-navn/-farge er en forutsetning for at scorekort-siden i
|
|
det hele tatt kan rendres for en deltaker uten org-medlemskap."""
|
|
async with org_connection(organization_id) as conn:
|
|
exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
|
|
if exists is None:
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
|
|
conn, organization_id, tournament_id, user.user_id
|
|
):
|
|
raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
|
|
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 app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
|
|
existing = await conn.fetchval(
|
|
"SELECT count(*) FROM team WHERE tournament_id = $1", tournament_id
|
|
)
|
|
if existing >= 2:
|
|
raise app_error(
|
|
409, "LIMIT_REACHED", "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))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Turnering-nivå walkover/konsesjon (ADR-024)
|
|
#
|
|
# v1 er låst til nøyaktig to lag (ADR-011), så det finnes bare ÉN motstander
|
|
# uansett hvor mange sesjoner/matcher turneringen har -- "gi opp turneringen"
|
|
# er derfor bare "gi opp hver ikke-avgjorte match laget har" i én operasjon,
|
|
# gjenbruker match-nivå-logikken (apply_concession, scoring.py) per match.
|
|
# Samme autorisasjon som enkelt-match-konsesjon: kaptein for laget som gir
|
|
# seg selv, eller org-eier/admin.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class ConcedeTournamentRequest(BaseModel):
|
|
conceding_team_id: str
|
|
|
|
|
|
class ConcedeTournamentOut(BaseModel):
|
|
matches_conceded: int
|
|
|
|
|
|
@router.post(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/concede",
|
|
response_model=ConcedeTournamentOut,
|
|
status_code=201,
|
|
)
|
|
async def concede_tournament(
|
|
tournament_id: str,
|
|
body: ConcedeTournamentRequest,
|
|
organization_id: str,
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> ConcedeTournamentOut:
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
team = await conn.fetchrow(
|
|
"SELECT tournament_id::text AS tournament_id FROM team WHERE id = $1",
|
|
body.conceding_team_id,
|
|
)
|
|
if team is None or team["tournament_id"] != tournament_id:
|
|
raise app_error(404, "NOT_FOUND", "Laget finnes ikke i denne turneringen.")
|
|
|
|
if not await user_is_team_captain(conn, organization_id, body.conceding_team_id, user.user_id):
|
|
raise app_error(
|
|
403,
|
|
"NOT_TEAM_CAPTAIN",
|
|
"Du må være kaptein for laget som gir seg (eller organisasjonsadministrator) for å gi opp turneringen.",
|
|
)
|
|
|
|
# FOR UPDATE OF m: låser alle berørte match-rader FØR de endres, samme
|
|
# race-vern som enkelt-match-konsesjon og recompute_and_cache_match_state.
|
|
matches = await conn.fetch(
|
|
"""
|
|
SELECT m.id::text AS id, m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
|
|
s.points_per_match::float AS points_per_match
|
|
FROM match m
|
|
JOIN session s ON s.id = m.session_id
|
|
WHERE s.tournament_id = $1
|
|
AND m.points_side_a IS NULL
|
|
AND (m.team_a_id = $2 OR m.team_b_id = $2)
|
|
FOR UPDATE OF m
|
|
""",
|
|
tournament_id,
|
|
body.conceding_team_id,
|
|
)
|
|
for m in matches:
|
|
side = "a" if m["team_a_id"] == body.conceding_team_id else "b"
|
|
await apply_concession(conn, m["id"], side, m["points_per_match"], tournament_id)
|
|
|
|
return ConcedeTournamentOut(matches_conceded=len(matches))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
# Konkurranseklasse (2026-08-03) -- valgfri, se 053_tournament_classes.sql.
|
|
class_id: str | None = None
|
|
|
|
|
|
class RosterEntry(BaseModel):
|
|
id: str
|
|
player_id: str
|
|
display_name: str
|
|
handicap_index_snapshot: float | None
|
|
is_captain: bool
|
|
class_id: str | None
|
|
class_name: str | None
|
|
|
|
|
|
_ROSTER_COLUMNS = """
|
|
tr.id::text, tr.player_id::text,
|
|
tr.handicap_index_snapshot::float AS handicap_index_snapshot,
|
|
tr.is_captain, p.display_name,
|
|
tr.class_id::text AS class_id, tc.name AS class_name
|
|
"""
|
|
|
|
|
|
@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(
|
|
f"""
|
|
SELECT {_ROSTER_COLUMNS}
|
|
FROM team_roster tr
|
|
JOIN player p ON p.id = tr.player_id
|
|
LEFT JOIN tournament_class tc ON tc.id = tr.class_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 app_error(404, "NOT_FOUND", "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 app_error(
|
|
409,
|
|
"DUPLICATE",
|
|
"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
|
|
)
|
|
|
|
# Samme "kun én kaptein per lag"-invariant som update_roster_entry
|
|
# (ADR-023) -- håndhevet her også, selv om dagens frontend aldri
|
|
# sender is_captain=true ved opprettelse, for at API-et er korrekt
|
|
# uavhengig av klient.
|
|
if body.is_captain:
|
|
await conn.execute(
|
|
"UPDATE team_roster SET is_captain = false WHERE team_id = $1", team_id
|
|
)
|
|
|
|
row = await conn.fetchrow(
|
|
"""
|
|
WITH inserted AS (
|
|
INSERT INTO team_roster
|
|
(organization_id, team_id, player_id, handicap_index_snapshot, is_captain, class_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id, player_id, handicap_index_snapshot, is_captain, class_id
|
|
)
|
|
SELECT inserted.id::text, inserted.player_id::text,
|
|
inserted.handicap_index_snapshot::float AS handicap_index_snapshot,
|
|
inserted.is_captain, p.display_name,
|
|
inserted.class_id::text AS class_id, tc.name AS class_name
|
|
FROM inserted
|
|
JOIN player p ON p.id = inserted.player_id
|
|
LEFT JOIN tournament_class tc ON tc.id = inserted.class_id
|
|
""",
|
|
organization_id,
|
|
team_id,
|
|
body.player_id,
|
|
snapshot,
|
|
body.is_captain,
|
|
body.class_id,
|
|
)
|
|
return RosterEntry(**dict(row))
|
|
|
|
|
|
class RosterEntryUpdate(BaseModel):
|
|
"""Alle felt valgfrie -- exclude_unset, samme PATCH-semantikk som
|
|
TournamentUpdate/SponsorUpdate. `class_id` lagt til 2026-08-03 (bredere
|
|
enn tidligere, da feltet KUN var is_captain: bool påkrevd) -- utvidet
|
|
slik at klasse kan settes/endres uten å tvinge et samtidig
|
|
kaptein-valg."""
|
|
|
|
is_captain: bool | None = None
|
|
class_id: str | None = None
|
|
|
|
|
|
@router.patch(
|
|
"/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}",
|
|
response_model=RosterEntry,
|
|
)
|
|
async def update_roster_entry(
|
|
team_id: str,
|
|
roster_id: str,
|
|
body: RosterEntryUpdate,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> RosterEntry:
|
|
updates = body.model_dump(exclude_unset=True)
|
|
if not updates:
|
|
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
|
|
|
# 2026-07-19 (Brukerroller-runden, ADR-023): kaptein er nå en reell
|
|
# autorisasjonsrolle (se app/team_authz.py sin user_is_team_captain), så
|
|
# "kun én kaptein per lag" håndheves eksplisitt her -- å sette en NY
|
|
# kaptein fjerner automatisk merket fra en ev. tidligere kaptein på
|
|
# SAMME lag, i samme transaksjon (org_connection åpner allerede én).
|
|
# Å fjerne kapteinmerket (is_captain=false) rører ingen andre rader.
|
|
async with org_connection(organization_id) as conn:
|
|
if updates.get("is_captain"):
|
|
await conn.execute(
|
|
"UPDATE team_roster SET is_captain = false WHERE team_id = $1 AND id <> $2",
|
|
team_id,
|
|
roster_id,
|
|
)
|
|
|
|
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
|
|
values = list(updates.values())
|
|
values.append(roster_id)
|
|
values.append(team_id)
|
|
|
|
row = await conn.fetchrow(
|
|
f"""
|
|
WITH updated AS (
|
|
UPDATE team_roster SET {', '.join(set_clauses)}
|
|
WHERE id = ${len(values) - 1} AND team_id = ${len(values)}
|
|
RETURNING id, player_id, handicap_index_snapshot, is_captain, class_id
|
|
)
|
|
SELECT updated.id::text, updated.player_id::text,
|
|
updated.handicap_index_snapshot::float AS handicap_index_snapshot,
|
|
updated.is_captain, p.display_name,
|
|
updated.class_id::text AS class_id, tc.name AS class_name
|
|
FROM updated
|
|
JOIN player p ON p.id = updated.player_id
|
|
LEFT JOIN tournament_class tc ON tc.id = updated.class_id
|
|
""",
|
|
*values,
|
|
)
|
|
if row is None:
|
|
raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.")
|
|
return RosterEntry(**dict(row))
|
|
|
|
|
|
class RosterMove(BaseModel):
|
|
target_team_id: str
|
|
|
|
|
|
@router.post(
|
|
"/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}/move",
|
|
response_model=RosterEntry,
|
|
)
|
|
async def move_roster_entry(
|
|
team_id: str,
|
|
roster_id: str,
|
|
body: RosterMove,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> RosterEntry:
|
|
"""FEATURE_BACKLOG.md "Turneringsoppsett: flytte/slette spillere mellom
|
|
lag" (notert 2026-07-25) -- atomisk flytting i én operasjon i stedet for
|
|
to separate DELETE+POST-kall (unngår en mellomtilstand der spilleren
|
|
midlertidig ikke er rostret noe sted)."""
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
current = await conn.fetchrow(
|
|
"SELECT tr.id, t.tournament_id::text AS tournament_id "
|
|
"FROM team_roster tr JOIN team t ON t.id = tr.team_id "
|
|
"WHERE tr.id = $1 AND tr.team_id = $2",
|
|
roster_id,
|
|
team_id,
|
|
)
|
|
if current is None:
|
|
raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.")
|
|
if body.target_team_id == team_id:
|
|
raise app_error(400, "VALIDATION_FAILED", "Spilleren er allerede på dette laget.")
|
|
target_team = await conn.fetchrow(
|
|
"SELECT id FROM team WHERE id = $1 AND tournament_id = $2",
|
|
body.target_team_id,
|
|
current["tournament_id"],
|
|
)
|
|
if target_team is None:
|
|
raise app_error(404, "NOT_FOUND", "Mållaget finnes ikke i denne turneringen.")
|
|
|
|
# Roster-raden er referert av match_participant.team_roster_id (ON
|
|
# DELETE RESTRICT) -- en spiller som allerede er satt opp i minst én
|
|
# match kan ikke flyttes trygt (matchens team_side ville da ikke
|
|
# lenger stemme med spillerens faktiske lag). Krev at spilleren
|
|
# fjernes fra matchen(e) FØRST, i stedet for å tillate en
|
|
# inkonsistent mellomtilstand.
|
|
already_in_match = await conn.fetchval(
|
|
"SELECT EXISTS(SELECT 1 FROM match_participant WHERE team_roster_id = $1)",
|
|
roster_id,
|
|
)
|
|
if already_in_match:
|
|
raise app_error(
|
|
409,
|
|
"ALREADY_IN_MATCH",
|
|
"Spilleren er allerede lagt til i en match og kan ikke flyttes -- "
|
|
"fjern spilleren fra matchen(e) først.",
|
|
)
|
|
|
|
# Kaptein-merket følger IKKE med til det nye laget -- "kun én
|
|
# kaptein per lag" (ADR-023) håndheves allerede ELLERS via
|
|
# update_roster_entry, men en flyttet spiller skal aldri bli
|
|
# kaptein på et nytt lag uten et eksplisitt valg der.
|
|
row = await conn.fetchrow(
|
|
"""
|
|
WITH moved AS (
|
|
UPDATE team_roster
|
|
SET team_id = $1, is_captain = false
|
|
WHERE id = $2
|
|
RETURNING id, player_id, handicap_index_snapshot, is_captain
|
|
)
|
|
SELECT moved.id::text, moved.player_id::text,
|
|
moved.handicap_index_snapshot::float AS handicap_index_snapshot,
|
|
moved.is_captain, p.display_name
|
|
FROM moved JOIN player p ON p.id = moved.player_id
|
|
""",
|
|
body.target_team_id,
|
|
roster_id,
|
|
)
|
|
return RosterEntry(**dict(row))
|
|
|
|
|
|
@router.delete(
|
|
"/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}",
|
|
status_code=204,
|
|
)
|
|
async def delete_roster_entry(
|
|
team_id: str,
|
|
roster_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> None:
|
|
async with org_connection(organization_id) as conn:
|
|
deleted = await conn.fetchval(
|
|
"DELETE FROM team_roster WHERE id = $1 AND team_id = $2 RETURNING id",
|
|
roster_id,
|
|
team_id,
|
|
)
|
|
if deleted is None:
|
|
raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ø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
|
|
# ADR-012: 'stroke' (hole_score, motoren utleder netto) eller 'hole_result'
|
|
# (match_hole_result, bare hvem som vant hullet). DB-default er 'stroke'.
|
|
scoring_mode: str = "stroke"
|
|
# Klokkeslett-modell (se plan): én starttid + ett intervall per økt.
|
|
# match.tee_time utledes av disse i matches.py, IKKE lagret per match.
|
|
scheduled_at: datetime | None = None
|
|
tee_interval_minutes: int | None = None
|
|
start_hole: int = 1
|
|
# KUN meningsfullt (og påkrevd) når format='shamble' -- antall av
|
|
# sidens individuelle resultater som teller per hull ("beste N av M").
|
|
shamble_best_n: int | None = Field(default=None, ge=1)
|
|
|
|
|
|
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
|
|
scoring_mode: str
|
|
scheduled_at: datetime | None
|
|
tee_interval_minutes: int | None
|
|
start_hole: int
|
|
shamble_best_n: int | 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,
|
|
scoring_mode=row["scoring_mode"],
|
|
scheduled_at=row["scheduled_at"],
|
|
tee_interval_minutes=row["tee_interval_minutes"],
|
|
start_hole=row["start_hole"],
|
|
shamble_best_n=row["shamble_best_n"],
|
|
locked_team_ids=sorted(locked),
|
|
revealed=len(locked) >= 2,
|
|
)
|
|
|
|
|
|
async def _fetch_sessions(conn, tournament_id: str) -> list[SessionOut]:
|
|
"""Delt mellom den innloggede org-ruten under og det offentlige
|
|
landingsside-endepunktet (app/routers/registration.py, ADR-018) -- samme
|
|
spørring, samme blind draw-låsing, ikke duplisert to steder."""
|
|
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, scoring_mode,
|
|
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
|
|
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.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/sessions",
|
|
response_model=list[SessionOut],
|
|
)
|
|
async def list_sessions(
|
|
tournament_id: str,
|
|
organization_id: str,
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> list[SessionOut]:
|
|
"""2026-07-21 (deltaker-tilgang-runden): org-medlem ELLER deltaker i
|
|
denne turneringen, samme mønster som list_teams over -- innholdet er
|
|
for øvrig identisk med det offentlige `/public/.../sessions`-
|
|
endepunktet (ADR-018) for enhver som allerede kan se turneringen, så
|
|
dette utvider ikke hva som er synlig, kun HVEM som kan nå det via
|
|
denne org-scopede stien."""
|
|
async with org_connection(organization_id) as conn:
|
|
exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
|
|
if exists is None:
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
|
|
conn, organization_id, tournament_id, user.user_id
|
|
):
|
|
raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
|
|
return await _fetch_sessions(conn, tournament_id)
|
|
|
|
|
|
@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, scoring_mode,
|
|
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13, $14)
|
|
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, scoring_mode,
|
|
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
|
|
""",
|
|
organization_id,
|
|
tournament_id,
|
|
body.sequence,
|
|
body.name,
|
|
body.format,
|
|
body.hole_config,
|
|
body.course_id,
|
|
body.points_per_match,
|
|
allowance_json,
|
|
body.scoring_mode,
|
|
body.scheduled_at,
|
|
body.tee_interval_minutes,
|
|
body.start_hole,
|
|
body.shamble_best_n,
|
|
)
|
|
return _session_out(row, set())
|
|
|
|
|
|
class SessionUpdate(BaseModel):
|
|
"""Bevisst IKKE format/scoring_mode/hole_config ennå -- disse har dypere
|
|
strukturelle konsekvenser for allerede registrerte scores (bytter man
|
|
scoring_mode havner gamle oppføringer i feil tabell; bytter man format
|
|
endres reglene for match_participant) som fortjener egen, senere design.
|
|
course_id er den ENESTE feltet som krever spesialhåndtering her (se
|
|
_remap_course) -- resten er en vanlig exclude_unset-oppdatering."""
|
|
|
|
name: str | None = None
|
|
scheduled_at: datetime | None = None
|
|
tee_interval_minutes: int | None = None
|
|
start_hole: int | None = Field(default=None, ge=1, le=18)
|
|
points_per_match: float | None = None
|
|
allowance_override: dict | None = None
|
|
course_id: str | None = None
|
|
|
|
|
|
async def _remap_course(conn, session_id: str, new_course_id: str) -> None:
|
|
"""Reist av brukeren (2026-07-18): en organisator oppdager MIDT I RUNDEN
|
|
at feil bane var satt opp -- de faktisk registrerte slagene er ekte og
|
|
skal IKKE røres, men `match_participant.tee_id` peker til en tee som
|
|
hører til DEN GAMLE banen, og må flyttes til en tilsvarende tee på den
|
|
nye for at handicap-beregningen skal bli riktig videre.
|
|
|
|
Matcher på tee-navn (case-insensitive) -- kjønn hører til RATINGEN, ikke
|
|
selve utslaget (ADR-029), så remap-en sjekker i tillegg at den nye
|
|
tee-en faktisk har en full_18-rating for hver berørte spillers kjønn.
|
|
Finnes ingen tilsvarende, gyldig tee på den nye banen for én eller
|
|
flere spillere, avvises HELE bane-byttet tydelig FØR noe skrives --
|
|
heller stoppe enn å gjette feil (samme "fail loudly" prinsipp som
|
|
ADR-019 sin importvalidering).
|
|
"""
|
|
course_exists = await conn.fetchval("SELECT id FROM course WHERE id = $1", new_course_id)
|
|
if course_exists is None:
|
|
raise app_error(404, "NOT_FOUND", "Banen finnes ikke.")
|
|
|
|
participants = await conn.fetch(
|
|
"""
|
|
SELECT mp.id::text AS id, t.name AS tee_name, p.gender AS player_gender
|
|
FROM match_participant mp
|
|
JOIN match m ON m.id = mp.match_id
|
|
JOIN tee t ON t.id = mp.tee_id
|
|
JOIN team_roster tr ON tr.id = mp.team_roster_id
|
|
JOIN player p ON p.id = tr.player_id
|
|
WHERE m.session_id = $1
|
|
""",
|
|
session_id,
|
|
)
|
|
|
|
remap: dict[str, str] = {}
|
|
missing: set[str] = set()
|
|
for p in participants:
|
|
new_tee_id = await conn.fetchval(
|
|
"""
|
|
SELECT t.id::text FROM tee t
|
|
JOIN tee_rating tr ON tr.tee_id = t.id AND tr.scope = 'full_18'
|
|
AND tr.gender IS NOT DISTINCT FROM $3
|
|
WHERE t.course_id = $1 AND lower(t.name) = lower($2)
|
|
""",
|
|
new_course_id,
|
|
p["tee_name"],
|
|
p["player_gender"],
|
|
)
|
|
if new_tee_id is None:
|
|
missing.add(p["tee_name"])
|
|
else:
|
|
remap[p["id"]] = new_tee_id
|
|
|
|
if missing:
|
|
raise app_error(
|
|
400,
|
|
"VALIDATION_FAILED",
|
|
"Den nye banen mangler en tee som matcher: "
|
|
+ ", ".join(sorted(missing))
|
|
+ ". Legg til tilsvarende tee på den nye banen før du bytter.",
|
|
)
|
|
|
|
for participant_id, new_tee_id in remap.items():
|
|
await conn.execute("UPDATE match_participant SET tee_id = $1 WHERE id = $2", new_tee_id, participant_id)
|
|
|
|
await conn.execute("UPDATE session SET course_id = $1 WHERE id = $2", new_course_id, session_id)
|
|
|
|
|
|
async def _recompute_session_matches(
|
|
conn, session_id: str, format_: str, allowance_override: dict | None
|
|
) -> None:
|
|
"""Regner om handicap + matchstatus for HVER match i økten -- brukt etter
|
|
et bane- eller allowance-bytte. Bevisst UAVHENGIG av om en match allerede
|
|
er avgjort (brukeren bekreftet eksplisitt: en bane-korrigering skal kunne
|
|
endre et allerede cachet resultat, ikke bare blokkeres av den vanlige
|
|
ALREADY_DECIDED-sperren som gjelder NYE slag-innsendinger)."""
|
|
config = parse_allowance_config(format_, allowance_override)
|
|
matches = await conn.fetch("SELECT id::text AS id FROM match WHERE session_id = $1", session_id)
|
|
for m in matches:
|
|
for side in ("a", "b"):
|
|
await compute_and_store_side_handicaps(conn, m["id"], side, format_, config)
|
|
await recompute_and_cache_match_state(conn, m["id"])
|
|
|
|
|
|
@router.patch("/orgs/{organization_id}/sessions/{session_id}", response_model=SessionOut)
|
|
async def update_session(
|
|
session_id: str,
|
|
body: SessionUpdate,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> SessionOut:
|
|
updates = body.model_dump(exclude_unset=True)
|
|
if not updates:
|
|
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
|
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
session = await conn.fetchrow(
|
|
"SELECT format, course_id::text AS course_id FROM session WHERE id = $1",
|
|
session_id,
|
|
)
|
|
if session is None:
|
|
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
|
|
|
|
new_course_id = updates.pop("course_id", None)
|
|
allowance_or_points_changed = "allowance_override" in updates or "points_per_match" in updates
|
|
|
|
if updates:
|
|
set_clauses = []
|
|
values: list = []
|
|
for i, (key, value) in enumerate(updates.items(), start=1):
|
|
if key == "allowance_override":
|
|
set_clauses.append(f"allowance_override = ${i}::jsonb")
|
|
values.append(json.dumps(value) if value is not None else None)
|
|
else:
|
|
set_clauses.append(f"{key} = ${i}")
|
|
values.append(value)
|
|
values.append(session_id)
|
|
await conn.execute(
|
|
f"UPDATE session SET {', '.join(set_clauses)} WHERE id = ${len(values)}",
|
|
*values,
|
|
)
|
|
|
|
if new_course_id is not None and new_course_id != session["course_id"]:
|
|
await _remap_course(conn, session_id, new_course_id)
|
|
allowance_or_points_changed = True # banebytte krever alltid omregning
|
|
|
|
if allowance_or_points_changed:
|
|
current = await conn.fetchrow(
|
|
"SELECT allowance_override::text AS allowance_override FROM session WHERE id = $1",
|
|
session_id,
|
|
)
|
|
current_override = json.loads(current["allowance_override"]) if current["allowance_override"] else None
|
|
await _recompute_session_matches(
|
|
conn, session_id, session["format"], current_override
|
|
)
|
|
|
|
row = await conn.fetchrow(
|
|
"""
|
|
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, scoring_mode,
|
|
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
|
|
FROM session WHERE id = $1
|
|
""",
|
|
session_id,
|
|
)
|
|
locked = await locked_team_ids(conn, session_id)
|
|
|
|
return _session_out(row, locked)
|
|
|
|
|
|
@router.delete("/orgs/{organization_id}/sessions/{session_id}", status_code=204)
|
|
async def delete_session(
|
|
session_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> None:
|
|
"""Kun tomme økter kan slettes (ingen matcher opprettet ennå) -- dekker
|
|
"jeg satte opp feil økt, fjern den" FØR den er tatt i bruk. En økt med
|
|
matcher (og dermed potensielt registrerte scores/poeng) må rettes med
|
|
PATCH (bane-/felt-korrigering) i stedet for å kunne slettes -- hindrer
|
|
at noen ved et uhell sletter ekte kamper/resultater."""
|
|
async with org_connection(organization_id) as conn:
|
|
session = await conn.fetchrow("SELECT id FROM session WHERE id = $1", session_id)
|
|
if session is None:
|
|
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
|
|
|
|
match_count = await conn.fetchval("SELECT count(*) FROM match WHERE session_id = $1", session_id)
|
|
if match_count > 0:
|
|
raise app_error(
|
|
409,
|
|
"VALIDATION_FAILED",
|
|
"Økten har allerede matcher og kan ikke slettes -- bruk PATCH for å korrigere den i stedet.",
|
|
)
|
|
|
|
await conn.execute("DELETE FROM session WHERE id = $1", session_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Midlertidige spillere: etter-runde scorekort + innloggingsinvitasjon,
|
|
# økt-nivå (FEATURE_BACKLOG.md, avklart 2026-07-28, migrasjon 034).
|
|
#
|
|
# Eksplisitt organisator-handling (ikke en automatisk bakgrunnsjobb, se
|
|
# begrunnelse i backloggen) -- sender KUN til spillere UTEN egen konto ennå
|
|
# (player.user_id IS NULL) og med en registrert e-post, og KUN én gang per
|
|
# (økt, deltaker) via match_participant.invitation_sent_at. Locale er
|
|
# bevisst alltid 'nb' -- en spiller uten konto har ingen lagret
|
|
# språkpreferanse å lese.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class SendInvitationsOut(BaseModel):
|
|
sent: int
|
|
skipped_has_account: int
|
|
skipped_no_email: int
|
|
skipped_already_sent: int
|
|
|
|
|
|
@router.post(
|
|
"/orgs/{organization_id}/sessions/{session_id}/send-scorecard-invitations",
|
|
response_model=SendInvitationsOut,
|
|
)
|
|
async def send_scorecard_invitations(
|
|
session_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> SendInvitationsOut:
|
|
async with org_connection(organization_id) as conn:
|
|
session = await conn.fetchrow(
|
|
"SELECT tournament_id::text AS tournament_id, name FROM session WHERE id = $1",
|
|
session_id,
|
|
)
|
|
if session is None:
|
|
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
|
|
tournament_name = await conn.fetchval(
|
|
"SELECT name FROM tournament WHERE id = $1", session["tournament_id"]
|
|
)
|
|
session_label = (session["name"] or "").strip() or "en økt"
|
|
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT mp.id::text AS participant_id, mp.invitation_sent_at,
|
|
p.display_name, p.email, p.user_id::text AS user_id,
|
|
m.status_text
|
|
FROM match_participant mp
|
|
JOIN match m ON m.id = mp.match_id
|
|
JOIN team_roster tr ON tr.id = mp.team_roster_id
|
|
JOIN player p ON p.id = tr.player_id
|
|
WHERE m.session_id = $1
|
|
""",
|
|
session_id,
|
|
)
|
|
|
|
sent = 0
|
|
skipped_has_account = 0
|
|
skipped_no_email = 0
|
|
skipped_already_sent = 0
|
|
|
|
for r in rows:
|
|
if r["user_id"] is not None:
|
|
skipped_has_account += 1
|
|
continue
|
|
if not r["email"]:
|
|
skipped_no_email += 1
|
|
continue
|
|
if r["invitation_sent_at"] is not None:
|
|
skipped_already_sent += 1
|
|
continue
|
|
|
|
# Kun meningsfull for individuell-ball-formater (singles/
|
|
# fourball) -- for delt-ball-formater er match_participant_id
|
|
# ALDRI satt i hole_score (ADR/scoring.py), så SUM blir naturlig
|
|
# NULL der uten noen egen format-sjekk her.
|
|
gross = await conn.fetchval(
|
|
"SELECT SUM(gross_strokes) FROM hole_score WHERE match_participant_id = $1",
|
|
r["participant_id"],
|
|
)
|
|
if r["status_text"]:
|
|
summary = f"Du spilte i {session_label} i {tournament_name} -- resultatet ble {r['status_text']}."
|
|
else:
|
|
summary = f"Du var satt opp for {session_label} i {tournament_name}."
|
|
if gross is not None:
|
|
summary += f" Du hadde {gross} slag totalt."
|
|
|
|
email = r["email"].strip().lower()
|
|
now = datetime.now(timezone.utc)
|
|
raw_token = secrets.token_urlsafe(32)
|
|
expires_at = now + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
|
|
# Samme "ugyldiggjør eldre uforbrukte lenker"-mønster som
|
|
# request_magic_link (routers/auth.py) -- begrenser vinduet en
|
|
# eldre, lekket lenke kan misbrukes i.
|
|
await conn.execute(
|
|
"UPDATE magic_link_token SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL",
|
|
email,
|
|
)
|
|
await conn.execute(
|
|
"INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, 'nb')",
|
|
email,
|
|
_hash_secret(raw_token),
|
|
expires_at,
|
|
)
|
|
if settings.SMTP_CONFIGURED:
|
|
try:
|
|
await send_session_result_email(
|
|
email, r["display_name"], tournament_name, summary, raw_token, "nb"
|
|
)
|
|
except Exception:
|
|
# Se app/email.py sitt mønster -- driftsfeil i
|
|
# utsendingen skal aldri hindre resten av utsendingen
|
|
# (token-raden består uansett, lenken virker fortsatt
|
|
# hvis spilleren ber om en ny normalt).
|
|
traceback.print_exc()
|
|
elif settings.DEV_LOG_MAGIC_LINKS:
|
|
# Samme "[DEV] Magic link for ..."-linjeformat som
|
|
# request_magic_link (routers/auth.py) FØRST -- slik at
|
|
# eksisterende dev-/test-verktøy som harvester token derfra
|
|
# fungerer uendret her også -- pluss en egen, lesbar
|
|
# oppsummeringslinje.
|
|
print(f"[DEV] Magic link for {email} (nb): {raw_token}", flush=True)
|
|
print(f"[DEV] Scorekort-invitasjon til {email}: {summary}", flush=True)
|
|
|
|
await conn.execute(
|
|
"UPDATE match_participant SET invitation_sent_at = now() WHERE id = $1",
|
|
r["participant_id"],
|
|
)
|
|
sent += 1
|
|
|
|
return SendInvitationsOut(
|
|
sent=sent,
|
|
skipped_has_account=skipped_has_account,
|
|
skipped_no_email=skipped_no_email,
|
|
skipped_already_sent=skipped_already_sent,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sponsorer (ADR-018) -- navn+lenke+logo, alle aktive fra MinIO-runden.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class SponsorCreate(BaseModel):
|
|
name: str = Field(min_length=1, max_length=200)
|
|
url: str | None = None
|
|
|
|
|
|
_SPONSOR_COLUMNS = "id::text, name, url, logo_key"
|
|
|
|
|
|
class Sponsor(BaseModel):
|
|
id: str
|
|
name: str
|
|
url: str | None
|
|
logo_key: str | None
|
|
logo_url: str | None
|
|
|
|
|
|
def _sponsor_from_row(row) -> Sponsor:
|
|
data = dict(row)
|
|
key = data["logo_key"]
|
|
return Sponsor(**data, logo_url=storage.public_url(key) if key else None)
|
|
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors",
|
|
response_model=list[Sponsor],
|
|
)
|
|
async def list_sponsors(
|
|
tournament_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> list[Sponsor]:
|
|
async with org_connection(organization_id) as conn:
|
|
rows = await conn.fetch(
|
|
f"SELECT {_SPONSOR_COLUMNS} FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at",
|
|
tournament_id,
|
|
)
|
|
return [_sponsor_from_row(r) for r in rows]
|
|
|
|
|
|
@router.post(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors",
|
|
response_model=Sponsor,
|
|
status_code=201,
|
|
)
|
|
async def add_sponsor(
|
|
tournament_id: str,
|
|
body: SponsorCreate,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> Sponsor:
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
tournament = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
|
|
if tournament is None:
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
row = await conn.fetchrow(
|
|
f"""
|
|
INSERT INTO tournament_sponsor (organization_id, tournament_id, name, url)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING {_SPONSOR_COLUMNS}
|
|
""",
|
|
organization_id,
|
|
tournament_id,
|
|
body.name,
|
|
body.url,
|
|
)
|
|
return _sponsor_from_row(row)
|
|
|
|
|
|
class SponsorUpdate(BaseModel):
|
|
name: str | None = Field(default=None, min_length=1, max_length=200)
|
|
url: str | None = None
|
|
# logo_key er BEVISST IKKE med -- settes kun via upload_sponsor_logo
|
|
# under, samme begrunnelse som tournament.hero_image_key over.
|
|
|
|
|
|
@router.patch(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}",
|
|
response_model=Sponsor,
|
|
)
|
|
async def update_sponsor(
|
|
tournament_id: str,
|
|
sponsor_id: str,
|
|
body: SponsorUpdate,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> Sponsor:
|
|
updates = body.model_dump(exclude_unset=True)
|
|
if not updates:
|
|
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
|
|
|
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
|
|
values = list(updates.values())
|
|
values.append(sponsor_id)
|
|
values.append(tournament_id)
|
|
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
row = await conn.fetchrow(
|
|
f"""
|
|
UPDATE tournament_sponsor SET {', '.join(set_clauses)}
|
|
WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)}
|
|
RETURNING {_SPONSOR_COLUMNS}
|
|
""",
|
|
*values,
|
|
)
|
|
if row is None:
|
|
raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.")
|
|
return _sponsor_from_row(row)
|
|
|
|
|
|
@router.post(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}/logo",
|
|
response_model=Sponsor,
|
|
)
|
|
async def upload_sponsor_logo(
|
|
tournament_id: str,
|
|
sponsor_id: str,
|
|
file: UploadFile,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> Sponsor:
|
|
if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES:
|
|
raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")
|
|
|
|
raw = await file.read(storage.MAX_UPLOAD_BYTES + 1)
|
|
if len(raw) > storage.MAX_UPLOAD_BYTES:
|
|
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
exists = await conn.fetchval(
|
|
"SELECT id FROM tournament_sponsor WHERE id = $1 AND tournament_id = $2",
|
|
sponsor_id,
|
|
tournament_id,
|
|
)
|
|
if exists is None:
|
|
raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.")
|
|
|
|
try:
|
|
key = await storage.upload_image("sponsors", sponsor_id, raw)
|
|
except storage.InvalidImageError:
|
|
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
|
|
|
|
row = await conn.fetchrow(
|
|
f"UPDATE tournament_sponsor SET logo_key = $1 WHERE id = $2 RETURNING {_SPONSOR_COLUMNS}",
|
|
key,
|
|
sponsor_id,
|
|
)
|
|
return _sponsor_from_row(row)
|
|
|
|
|
|
@router.delete(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}",
|
|
status_code=204,
|
|
)
|
|
async def delete_sponsor(
|
|
tournament_id: str,
|
|
sponsor_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> None:
|
|
async with org_connection(organization_id) as conn:
|
|
deleted = await conn.fetchval(
|
|
"DELETE FROM tournament_sponsor WHERE id = $1 AND tournament_id = $2 RETURNING id",
|
|
sponsor_id,
|
|
tournament_id,
|
|
)
|
|
if deleted is None:
|
|
raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Konkurranseklasser (2026-08-03) -- fritt navngitte, valgfritt standard-
|
|
# utslag. Delt mellom lag- og individuelle turneringer (samme `tournament`-
|
|
# tabell). Brukes til å forhåndsutfylle riktig utslag når en deltaker legges
|
|
# til en match (her)/runde (individual_tournaments.py), og -- KUN for
|
|
# individuelle turneringer -- til å dele opp leaderboardet. Se
|
|
# 053_tournament_classes.sql for full begrunnelse.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TournamentClassCreate(BaseModel):
|
|
name: str = Field(min_length=1, max_length=200)
|
|
default_tee_id: str | None = None
|
|
|
|
|
|
class TournamentClassUpdate(BaseModel):
|
|
"""Alle felt valgfrie -- exclude_unset, samme PATCH-semantikk som
|
|
TournamentUpdate/SponsorUpdate."""
|
|
|
|
name: str | None = Field(default=None, min_length=1, max_length=200)
|
|
default_tee_id: str | None = None
|
|
|
|
|
|
_CLASS_COLUMNS = """
|
|
tc.id::text, tc.name, tc.default_tee_id::text AS default_tee_id, tee.name AS default_tee_name
|
|
"""
|
|
|
|
|
|
class TournamentClass(BaseModel):
|
|
id: str
|
|
name: str
|
|
default_tee_id: str | None
|
|
default_tee_name: str | None
|
|
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/classes",
|
|
response_model=list[TournamentClass],
|
|
)
|
|
async def list_tournament_classes(
|
|
tournament_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> list[TournamentClass]:
|
|
async with org_connection(organization_id) as conn:
|
|
rows = await conn.fetch(
|
|
f"""
|
|
SELECT {_CLASS_COLUMNS}
|
|
FROM tournament_class tc
|
|
LEFT JOIN tee ON tee.id = tc.default_tee_id
|
|
WHERE tc.tournament_id = $1
|
|
ORDER BY tc.created_at
|
|
""",
|
|
tournament_id,
|
|
)
|
|
return [TournamentClass(**dict(r)) for r in rows]
|
|
|
|
|
|
@router.post(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/classes",
|
|
response_model=TournamentClass,
|
|
status_code=201,
|
|
)
|
|
async def create_tournament_class(
|
|
tournament_id: str,
|
|
body: TournamentClassCreate,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> TournamentClass:
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
tournament = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
|
|
if tournament is None:
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
row = await conn.fetchrow(
|
|
f"""
|
|
WITH inserted AS (
|
|
INSERT INTO tournament_class (organization_id, tournament_id, name, default_tee_id)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, name, default_tee_id
|
|
)
|
|
SELECT inserted.id::text, inserted.name,
|
|
inserted.default_tee_id::text AS default_tee_id, tee.name AS default_tee_name
|
|
FROM inserted LEFT JOIN tee ON tee.id = inserted.default_tee_id
|
|
""",
|
|
organization_id,
|
|
tournament_id,
|
|
body.name,
|
|
body.default_tee_id,
|
|
)
|
|
return TournamentClass(**dict(row))
|
|
|
|
|
|
@router.patch(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/classes/{class_id}",
|
|
response_model=TournamentClass,
|
|
)
|
|
async def update_tournament_class(
|
|
tournament_id: str,
|
|
class_id: str,
|
|
body: TournamentClassUpdate,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> TournamentClass:
|
|
updates = body.model_dump(exclude_unset=True)
|
|
if not updates:
|
|
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
|
|
|
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
|
|
values = list(updates.values())
|
|
values.append(class_id)
|
|
values.append(tournament_id)
|
|
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
row = await conn.fetchrow(
|
|
f"""
|
|
WITH updated AS (
|
|
UPDATE tournament_class SET {', '.join(set_clauses)}
|
|
WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)}
|
|
RETURNING id, name, default_tee_id
|
|
)
|
|
SELECT updated.id::text, updated.name,
|
|
updated.default_tee_id::text AS default_tee_id, tee.name AS default_tee_name
|
|
FROM updated LEFT JOIN tee ON tee.id = updated.default_tee_id
|
|
""",
|
|
*values,
|
|
)
|
|
if row is None:
|
|
raise app_error(404, "NOT_FOUND", "Klassen finnes ikke.")
|
|
return TournamentClass(**dict(row))
|
|
|
|
|
|
@router.delete(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/classes/{class_id}",
|
|
status_code=204,
|
|
)
|
|
async def delete_tournament_class(
|
|
tournament_id: str,
|
|
class_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> None:
|
|
async with org_connection(organization_id) as conn:
|
|
deleted = await conn.fetchval(
|
|
"DELETE FROM tournament_class WHERE id = $1 AND tournament_id = $2 RETURNING id",
|
|
class_id,
|
|
tournament_id,
|
|
)
|
|
if deleted is None:
|
|
raise app_error(404, "NOT_FOUND", "Klassen finnes ikke.")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Leaderboard (turnering-total, summert på tvers av alle økter/matcher)
|
|
#
|
|
# `match.team_a_id`/`team_b_id` settes PER MATCH ved opprettelse (matches.py),
|
|
# ikke garantert konsistent på tvers av matcher -- en organisator kunne i
|
|
# prinsippet opprettet match 1 med team_a=Rød og match 3 med team_a=Blå. Derfor
|
|
# summeres ALT (både total og per-økt) på det EKTE lag-id-et, aldri på
|
|
# "a"/"b"-labelen -- se points_by_team under.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TeamStanding(BaseModel):
|
|
team_id: str
|
|
team_name: str
|
|
color: str | None
|
|
points: float
|
|
# ADR-020 Beslutning B: hvis pågående matcher holder seg som de står nå --
|
|
# ikke-avgjorte matcher bidrar med full poengsum til leading_side (delt
|
|
# likt ved "AS"/ikke startet). Avgjorte matcher bidrar likt til begge.
|
|
projected_points: float
|
|
|
|
|
|
class SessionStanding(BaseModel):
|
|
session_id: str
|
|
sequence: int
|
|
name: str | None
|
|
points_by_team: dict[str, float]
|
|
projected_points_by_team: dict[str, float]
|
|
matches_total: int
|
|
matches_decided: int
|
|
|
|
|
|
class Leaderboard(BaseModel):
|
|
teams: list[TeamStanding]
|
|
matches_total: int
|
|
matches_decided: int
|
|
sessions: list[SessionStanding]
|
|
|
|
|
|
async def fetch_leaderboard(conn, tournament_id: str) -> Leaderboard:
|
|
"""Delt mellom den innloggede (get_leaderboard under) og den offentlige
|
|
/public/tournaments/{id}/leaderboard (registration.py, tilskuer-rolle) --
|
|
ingen spiller-nivå-data i responsmodellene (kun lag-id/-navn/-farge/
|
|
poeng), så ingen ekstra synlighetsfiltrering trengs utover den vanlige
|
|
tournament.visibility-sjekken den offentlige ruten selv gjør FØR denne
|
|
kalles."""
|
|
teams = await conn.fetch(
|
|
"SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at",
|
|
tournament_id,
|
|
)
|
|
if len(teams) != 2:
|
|
raise app_error(400, "VALIDATION_FAILED", "Turneringen har ikke nøyaktig to lag ennå.")
|
|
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT s.id::text AS session_id, s.sequence, s.name,
|
|
s.points_per_match::float AS points_per_match,
|
|
m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
|
|
m.points_side_a::float AS points_side_a, m.points_side_b::float AS points_side_b,
|
|
m.leading_side
|
|
FROM match m
|
|
JOIN session s ON s.id = m.session_id
|
|
WHERE s.tournament_id = $1
|
|
ORDER BY s.sequence, m.sequence
|
|
""",
|
|
tournament_id,
|
|
)
|
|
|
|
team_totals: dict[str, float] = {t["id"]: 0.0 for t in teams}
|
|
team_projected: dict[str, float] = {t["id"]: 0.0 for t in teams}
|
|
session_order: list[str] = []
|
|
sessions: dict[str, dict] = {}
|
|
matches_total = 0
|
|
matches_decided = 0
|
|
|
|
for r in rows:
|
|
matches_total += 1
|
|
sid = r["session_id"]
|
|
if sid not in sessions:
|
|
session_order.append(sid)
|
|
sessions[sid] = {
|
|
"session_id": sid,
|
|
"sequence": r["sequence"],
|
|
"name": r["name"],
|
|
"points_by_team": {t["id"]: 0.0 for t in teams},
|
|
"projected_points_by_team": {t["id"]: 0.0 for t in teams},
|
|
"matches_total": 0,
|
|
"matches_decided": 0,
|
|
}
|
|
sessions[sid]["matches_total"] += 1
|
|
|
|
# Projisert bidrag: avgjorte matcher gir samme tall som faktisk
|
|
# (de ER allerede det de blir). Ikke-avgjorte matcher gir hele
|
|
# øktens poengsum til den som leder NÅ, delt 0,5/0,5 ved "AS"
|
|
# eller en match som ennå ikke har noen registrerte hull.
|
|
if r["points_side_a"] is not None:
|
|
proj_a, proj_b = r["points_side_a"], r["points_side_b"]
|
|
else:
|
|
ppm = r["points_per_match"]
|
|
if r["leading_side"] == "a":
|
|
proj_a, proj_b = ppm, 0.0
|
|
elif r["leading_side"] == "b":
|
|
proj_a, proj_b = 0.0, ppm
|
|
else:
|
|
proj_a = proj_b = ppm / 2
|
|
team_projected[r["team_a_id"]] = team_projected.get(r["team_a_id"], 0.0) + proj_a
|
|
team_projected[r["team_b_id"]] = team_projected.get(r["team_b_id"], 0.0) + proj_b
|
|
sessions[sid]["projected_points_by_team"][r["team_a_id"]] = (
|
|
sessions[sid]["projected_points_by_team"].get(r["team_a_id"], 0.0) + proj_a
|
|
)
|
|
sessions[sid]["projected_points_by_team"][r["team_b_id"]] = (
|
|
sessions[sid]["projected_points_by_team"].get(r["team_b_id"], 0.0) + proj_b
|
|
)
|
|
|
|
if r["points_side_a"] is None:
|
|
continue
|
|
matches_decided += 1
|
|
sessions[sid]["matches_decided"] += 1
|
|
team_totals[r["team_a_id"]] = team_totals.get(r["team_a_id"], 0.0) + r["points_side_a"]
|
|
team_totals[r["team_b_id"]] = team_totals.get(r["team_b_id"], 0.0) + r["points_side_b"]
|
|
sessions[sid]["points_by_team"][r["team_a_id"]] = (
|
|
sessions[sid]["points_by_team"].get(r["team_a_id"], 0.0) + r["points_side_a"]
|
|
)
|
|
sessions[sid]["points_by_team"][r["team_b_id"]] = (
|
|
sessions[sid]["points_by_team"].get(r["team_b_id"], 0.0) + r["points_side_b"]
|
|
)
|
|
|
|
team_standings = [
|
|
TeamStanding(
|
|
team_id=t["id"],
|
|
team_name=t["name"],
|
|
color=t["color"],
|
|
points=team_totals[t["id"]],
|
|
projected_points=team_projected[t["id"]],
|
|
)
|
|
for t in teams
|
|
]
|
|
|
|
return Leaderboard(
|
|
teams=team_standings,
|
|
matches_total=matches_total,
|
|
matches_decided=matches_decided,
|
|
sessions=[SessionStanding(**sessions[sid]) for sid in sorted(session_order, key=lambda s: sessions[s]["sequence"])],
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/leaderboard",
|
|
response_model=Leaderboard,
|
|
)
|
|
async def get_leaderboard(
|
|
tournament_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> Leaderboard:
|
|
async with org_connection(organization_id) as conn:
|
|
return await fetch_leaderboard(conn, tournament_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Individuell rangering PER ØKT (FEATURE_BACKLOG.md "Leaderboard for runder
|
|
# og turneringer", 2026-07-25/28 oppfølging) -- ved siden av, ikke i stedet
|
|
# for, lag-poeng-leaderboardet over. Bevisst AVGRENSET til én økt (ikke
|
|
# summert på tvers av turneringen, se avklaring i CLAUDE.md) -- og KUN
|
|
# meningsfullt for scoring_mode='stroke'-økter i individuell-ball-format
|
|
# (singles/fourball, se scoring.py sin _INDIVIDUAL_FORMATS) siden delt-ball-
|
|
# formater og hole_result-modus ikke har noen individuell brutto-score.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class IndividualLeaderboardEntry(BaseModel):
|
|
participant_id: str
|
|
display_name: str
|
|
team_id: str
|
|
team_name: str
|
|
team_color: str | None
|
|
thru: int
|
|
gross_score: int | None
|
|
gross_to_par: int | None
|
|
net_score: int | None
|
|
net_to_par: int | None
|
|
total_points: int | None
|
|
|
|
|
|
class IndividualLeaderboard(BaseModel):
|
|
session_id: str
|
|
format: str
|
|
entries: list[IndividualLeaderboardEntry]
|
|
|
|
|
|
async def fetch_individual_leaderboard(
|
|
conn, organization_id: str, session_id: str, viewer_user_id: str | None
|
|
) -> IndividualLeaderboard:
|
|
session = await conn.fetchrow(
|
|
"""
|
|
SELECT tournament_id::text AS tournament_id, format, scoring_mode,
|
|
hole_config::text AS hole_config, course_id::text AS course_id
|
|
FROM session WHERE id = $1
|
|
""",
|
|
session_id,
|
|
)
|
|
if session is None:
|
|
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
|
|
if session["scoring_mode"] != "stroke" or session["format"] not in ("singles", "fourball"):
|
|
raise app_error(
|
|
400,
|
|
"VALIDATION_FAILED",
|
|
"Individuell rangering er kun tilgjengelig for slagspill-økter i format singles eller fourball.",
|
|
)
|
|
|
|
hole_rows = await conn.fetch(
|
|
"SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
|
|
session["course_id"],
|
|
)
|
|
if len(hole_rows) != 18:
|
|
raise app_error(400, "VALIDATION_FAILED", "Banen mangler registrerte hull.")
|
|
par_by_hole = {r["hole_number"]: r["par"] for r in hole_rows}
|
|
all_18_si = [r["stroke_index"] for r in hole_rows]
|
|
played = played_hole_numbers(session["hole_config"])
|
|
|
|
# Samme reveal-respekterende synlighet som fetch_matches (ADR-013/026):
|
|
# egen side FØR reveal, hele feltet ETTER.
|
|
locked = await locked_team_ids(conn, session_id)
|
|
revealed = len(locked) >= 2
|
|
own = (
|
|
set()
|
|
if revealed
|
|
else await own_team_ids(conn, organization_id, session["tournament_id"], viewer_user_id)
|
|
)
|
|
|
|
participant_rows = await conn.fetch(
|
|
"""
|
|
SELECT mp.id::text AS participant_id, mp.playing_handicap,
|
|
tr.team_id::text AS team_id, t.name AS team_name, t.color AS team_color,
|
|
p.display_name
|
|
FROM match_participant mp
|
|
JOIN match m ON m.id = mp.match_id
|
|
JOIN team_roster tr ON tr.id = mp.team_roster_id
|
|
JOIN team t ON t.id = tr.team_id
|
|
JOIN player p ON p.id = tr.player_id
|
|
WHERE m.session_id = $1 AND ($2 OR tr.team_id::text = ANY($3::text[]))
|
|
ORDER BY p.display_name
|
|
""",
|
|
session_id,
|
|
revealed,
|
|
list(own),
|
|
)
|
|
|
|
entries: list[IndividualLeaderboardEntry] = []
|
|
for r in participant_rows:
|
|
score_rows = await conn.fetch(
|
|
"SELECT hole_number, gross_strokes FROM hole_score "
|
|
"WHERE match_participant_id = $1 ORDER BY hole_number",
|
|
r["participant_id"],
|
|
)
|
|
gross_score = gross_to_par = net_score = net_to_par = total_points = None
|
|
if score_rows:
|
|
gross_score = sum(s["gross_strokes"] for s in score_rows)
|
|
par_total = sum(par_by_hole[s["hole_number"]] for s in score_rows)
|
|
gross_to_par = gross_score - par_total
|
|
|
|
if r["playing_handicap"] is not None:
|
|
strokes_list = allocate_over_played_holes(r["playing_handicap"], all_18_si, played)
|
|
strokes_by_hole = dict(zip(played, strokes_list))
|
|
strokes_received_total = sum(strokes_by_hole[s["hole_number"]] for s in score_rows)
|
|
net_score = gross_score - strokes_received_total
|
|
net_to_par = gross_to_par - strokes_received_total
|
|
# Samme stableford-formel som round-leaderboardets
|
|
# total_points (rounds.py) -- 2 poeng for netto par, +/-1
|
|
# per slag, gulvet på 0.
|
|
total_points = sum(
|
|
max(
|
|
0,
|
|
par_by_hole[s["hole_number"]]
|
|
- (s["gross_strokes"] - strokes_by_hole[s["hole_number"]])
|
|
+ 2,
|
|
)
|
|
for s in score_rows
|
|
)
|
|
|
|
entries.append(
|
|
IndividualLeaderboardEntry(
|
|
participant_id=r["participant_id"],
|
|
display_name=r["display_name"],
|
|
team_id=r["team_id"],
|
|
team_name=r["team_name"],
|
|
team_color=r["team_color"],
|
|
thru=len(score_rows),
|
|
gross_score=gross_score,
|
|
gross_to_par=gross_to_par,
|
|
net_score=net_score,
|
|
net_to_par=net_to_par,
|
|
total_points=total_points,
|
|
)
|
|
)
|
|
|
|
return IndividualLeaderboard(session_id=session_id, format=session["format"], entries=entries)
|
|
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/sessions/{session_id}/individual-leaderboard",
|
|
response_model=IndividualLeaderboard,
|
|
)
|
|
async def get_individual_leaderboard(
|
|
session_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> IndividualLeaderboard:
|
|
async with org_connection(organization_id) as conn:
|
|
return await fetch_individual_leaderboard(conn, organization_id, session_id, user.user_id)
|