Tradisjonelt utslag (samme starthull, staggerte klokkeslett med fast intervall), manuell gruppesammensetning med et forslag å justere, fast gruppestørrelse. Fire ekte bugs funnet og fikset før utrulling via egen scratch-verifisering (FK-korrupsjon, str/datetime-mismatch, tidssone, React state). Migrasjon 084 lagt til, men IKKE anvendt mot ekte teecup_db ennå -- venter på bekreftelse.
2527 lines
105 KiB
Python
2527 lines
105 KiB
Python
"""
|
|
Individuelle/flerrunde-turneringer (ADR-037): grunnstruktur oppå
|
|
`040_individual_tournaments.sql`. Kun for `tournament.format_type =
|
|
'individual'` -- lagturneringer (`format_type = 'team'`, standard) er
|
|
uendret, bygget/vedlikeholdt i tournaments.py/matches.py/scoring.py.
|
|
|
|
Ett flatt felt av spillere (`tournament_participant`), over én eller flere
|
|
runder (`tournament_round`, samme rolle som `session` for lagturneringer).
|
|
Rå bruttoslag lagres per hull (`tournament_round_hole`, kilde-sannhet); et
|
|
ferdig utregnet resultat caches per deltaker per runde
|
|
(`tournament_round_score`) -- samme "regn på nytt ved hver innsending,
|
|
SQL summerer bare det cachede ved lesing"-mønster som `match.status_text`/
|
|
`points_side_a/b`. Sammenlagt over flere runder summeres VED LESING i
|
|
leaderboardet, ikke i en egen tredje cache-tabell (ADR-037 Beslutning C).
|
|
|
|
Autorisasjon (strammet inn 2026-07-30, se `user_is_own_tournament_
|
|
participant` i `app/team_authz.py`): selve SCORE-REGISTRERINGEN
|
|
(`update_hole`) krever nå at brukeren ER deltakeren (via `player.user_id`)
|
|
ELLER org-eier/admin -- samme mønster som `user_is_match_participant`
|
|
strammet inn lagturneringer i ADR-023. Runde-/deltaker-OPPSETT (opprett/
|
|
slett runde, legg til/fjern turnering-/rundedeltaker) forblir bevisst på
|
|
vanlig org-medlemsnivå (`get_authorized_org`) -- samme presedens som
|
|
`session`-opprettelse og `team_roster`-tilføyelse i tournaments.py, som
|
|
heller aldri har vært captain-/admin-gatet. Kun VALGET AV HVEM SOM SPILLER
|
|
og selve SCORINGEN er gatet strengere noe sted i appen, aldri det generelle
|
|
oppsettet.
|
|
|
|
Københavner (2026-07-30): scoring_method='copenhagen' -- flatt felt av
|
|
NØYAKTIG 3 spillere, 6 poeng delt per hull etter relativ NETTO-rangering
|
|
(handicap_engine.py sin compute_copenhagen_detail). Eneste scoring_method
|
|
her som krever paring PÅ TVERS av spillere per hull -- _recompute_
|
|
copenhagen_points regner derfor om for ALLE tre samtidig (kalt fra
|
|
update_hole), ikke ett-om-gangen slik gross/net/stableford ellers gjøres.
|
|
|
|
Bevisst UTENFOR omfang (se ADR-037): High-low-high/Robbins/Try all/
|
|
Flaggturnering (High-low-high/Robbins/Try all krever to SIDER, ikke et
|
|
flatt felt -- hører hjemme i org-lagturneringer i stedet, se matches.py/
|
|
scoring.py) og Order of Merit (sesong-sammenlagt på tvers av FLERE
|
|
turneringer).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import Literal
|
|
|
|
import asyncpg
|
|
from fastapi import APIRouter, Depends
|
|
from pydantic import BaseModel, Field
|
|
|
|
from handicap_engine import (
|
|
allocate_over_played_holes,
|
|
allocate_strokes_by_index,
|
|
bbb_points_for_hole,
|
|
compute_bbb,
|
|
compute_copenhagen_detail,
|
|
course_handicap_raw,
|
|
EclecticHoleValue,
|
|
eclectic_best_per_hole,
|
|
flag_result,
|
|
round_half_up,
|
|
stableford_points_for_hole,
|
|
stableford_total,
|
|
stroke_play_gross_total,
|
|
stroke_play_net_total,
|
|
)
|
|
|
|
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
|
from ..db import org_connection
|
|
from ..errors import app_error, translate_db_errors
|
|
from ..hole_history import (
|
|
HoleHistoryOut,
|
|
hole_history_for_user,
|
|
resolve_tournament_round_course_key,
|
|
)
|
|
from ..target_points import TargetPoint, get_target_points
|
|
from ..team_authz import user_is_own_tournament_participant
|
|
from .scoring import played_hole_numbers
|
|
|
|
router = APIRouter()
|
|
|
|
_SCORING_METHODS_REQUIRING_HANDICAP = {"stroke_net", "stableford", "copenhagen", "flag"}
|
|
|
|
|
|
# =====================================================================
|
|
# Runder (tournament_round)
|
|
# =====================================================================
|
|
|
|
class TournamentRoundCreate(BaseModel):
|
|
sequence: int = Field(ge=1)
|
|
name: str | None = None
|
|
hole_config: str = Field(default="full_18", pattern="^(full_18|front_9|back_9)$")
|
|
course_id: str
|
|
# `datetime`, IKKE `str` -- Pydantic parser ISO 8601-tekst til en ekte
|
|
# datetime selv, som asyncpg krever for en timestamptz-kolonne (en rå
|
|
# streng feiler med TypeError ved skriving -- funnet 2026-08-18 da
|
|
# update_round under fikk akkurat samme felt og ble faktisk testet med
|
|
# en verdi satt, noe create_round aldri hadde blitt før siden
|
|
# frontend-skjemaet ikke satte dette feltet ved opprettelse).
|
|
scheduled_at: datetime | None = None
|
|
tee_interval_minutes: int | None = Field(default=None, gt=0)
|
|
start_hole: int = Field(default=1, ge=1, le=18)
|
|
|
|
|
|
class TournamentRound(BaseModel):
|
|
id: str
|
|
tournament_id: str
|
|
sequence: int
|
|
name: str | None
|
|
hole_config: str
|
|
course_id: str
|
|
course_name: str
|
|
scheduled_at: str | None
|
|
tee_interval_minutes: int | None
|
|
start_hole: int
|
|
|
|
|
|
_ROUND_COLUMNS = """
|
|
tr.id::text, tr.tournament_id::text, tr.sequence, tr.name,
|
|
tr.hole_config::text, tr.course_id::text, c.name AS course_name,
|
|
tr.scheduled_at::text, tr.tee_interval_minutes, tr.start_hole
|
|
"""
|
|
|
|
|
|
@router.post(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds",
|
|
response_model=TournamentRound,
|
|
status_code=201,
|
|
)
|
|
async def create_round(
|
|
tournament_id: str,
|
|
body: TournamentRoundCreate,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> TournamentRound:
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
tournament = await conn.fetchrow(
|
|
"SELECT format_type, scoring_method FROM tournament WHERE id = $1", tournament_id
|
|
)
|
|
if tournament is None:
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
if tournament["format_type"] != "individual":
|
|
raise app_error(
|
|
400, "VALIDATION_FAILED", "Runder kan kun opprettes for individuelle turneringer."
|
|
)
|
|
# Eclectic (ADR-067-tillegget "Del C"): "beste resultat per hull på
|
|
# tvers av rundene" krever at hullnumrene faktisk betyr det samme
|
|
# (samme par/stroke index/fysiske hull) i alle runder -- fail
|
|
# loudly fremfor å stille sammenligne epler og pærer, samme
|
|
# ADR-019-filosofi som resten av appen.
|
|
if tournament["scoring_method"] is not None and tournament["scoring_method"].startswith("eclectic_"):
|
|
existing_course_id = await conn.fetchval(
|
|
"SELECT course_id::text FROM tournament_round WHERE tournament_id = $1 LIMIT 1",
|
|
tournament_id,
|
|
)
|
|
if existing_course_id is not None and existing_course_id != body.course_id:
|
|
raise app_error(
|
|
400, "VALIDATION_FAILED",
|
|
"Eclectic krever samme bane for alle turneringens runder.",
|
|
)
|
|
row = await conn.fetchrow(
|
|
f"""
|
|
WITH inserted AS (
|
|
INSERT INTO tournament_round
|
|
(organization_id, tournament_id, sequence, name, hole_config,
|
|
course_id, scheduled_at, tee_interval_minutes, start_hole)
|
|
VALUES ($1, $2, $3, $4, $5::hole_scope, $6, $7, $8, $9)
|
|
RETURNING *
|
|
)
|
|
SELECT {_ROUND_COLUMNS} FROM inserted tr JOIN course c ON c.id = tr.course_id
|
|
""",
|
|
organization_id,
|
|
tournament_id,
|
|
body.sequence,
|
|
body.name,
|
|
body.hole_config,
|
|
body.course_id,
|
|
body.scheduled_at,
|
|
body.tee_interval_minutes,
|
|
body.start_hole,
|
|
)
|
|
return TournamentRound(**dict(row))
|
|
|
|
|
|
class TournamentRoundUpdate(BaseModel):
|
|
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
|
|
(PATCH-semantikk via Pydantic sin exclude_unset). Lagt til 2026-08-18
|
|
(utslagsgrupper, migrasjon 084) -- runden hadde INGEN vei til å endre
|
|
scheduled_at/tee_interval_minutes/start_hole etter opprettelse
|
|
tidligere, kun ved selve INSERT-en (og `create_round`-skjemaet i
|
|
frontend satte dem aldri i utgangspunktet). Trengs for at organisator
|
|
faktisk skal kunne sette et første utslagstidspunkt + intervall FØR
|
|
utslagsgrupper gir noen mening (gruppenes klokkeslett er UTLEDET
|
|
herfra, se `_compute_group_tee_time`). course_id/hole_config er
|
|
BEVISST IKKE med -- mer strukturelle endringer enn dette skjemaet er
|
|
ment for, og ville krevd samme eclectic-samme-bane-sjekk som
|
|
create_round."""
|
|
|
|
name: str | None = None
|
|
scheduled_at: datetime | None = None # se TournamentRoundCreate for hvorfor datetime, ikke str
|
|
tee_interval_minutes: int | None = Field(default=None, gt=0)
|
|
start_hole: int | None = Field(default=None, ge=1, le=18)
|
|
|
|
|
|
@router.patch(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}",
|
|
response_model=TournamentRound,
|
|
)
|
|
async def update_round(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
body: TournamentRoundUpdate,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> TournamentRound:
|
|
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(round_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_round SET {', '.join(set_clauses)}
|
|
WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)}
|
|
RETURNING *
|
|
)
|
|
SELECT {_ROUND_COLUMNS} FROM updated tr JOIN course c ON c.id = tr.course_id
|
|
""",
|
|
*values,
|
|
)
|
|
if row is None:
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
return TournamentRound(**dict(row))
|
|
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds",
|
|
response_model=list[TournamentRound],
|
|
)
|
|
async def list_rounds(
|
|
tournament_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> list[TournamentRound]:
|
|
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.")
|
|
rows = await conn.fetch(
|
|
f"""
|
|
SELECT {_ROUND_COLUMNS} FROM tournament_round tr
|
|
JOIN course c ON c.id = tr.course_id
|
|
WHERE tr.tournament_id = $1
|
|
ORDER BY tr.sequence
|
|
""",
|
|
tournament_id,
|
|
)
|
|
return [TournamentRound(**dict(r)) for r in rows]
|
|
|
|
|
|
@router.delete("/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}", status_code=204)
|
|
async def delete_round(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> None:
|
|
async with org_connection(organization_id) as conn:
|
|
has_participants = await conn.fetchval(
|
|
"SELECT EXISTS(SELECT 1 FROM tournament_round_participant WHERE tournament_round_id = $1)",
|
|
round_id,
|
|
)
|
|
if has_participants:
|
|
raise app_error(
|
|
409,
|
|
"NOT_EMPTY",
|
|
"Runden har allerede deltakere -- fjern dem først, eller la runden stå.",
|
|
)
|
|
deleted = await conn.fetchval(
|
|
"DELETE FROM tournament_round WHERE id = $1 AND tournament_id = $2 RETURNING id",
|
|
round_id,
|
|
tournament_id,
|
|
)
|
|
if deleted is None:
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
|
|
|
|
# =====================================================================
|
|
# Turnering-deltakere (tournament_participant -- flatt felt, intet lag)
|
|
# =====================================================================
|
|
|
|
# Statistikknivå (migrasjon 075, ADR-071) -- samme tre verdier og samme
|
|
# semantikk som round_participant.stat_level (rounds.py sin StatLevel):
|
|
# hvor mye detalj UI-et ber om utover selve bruttoslaget. Tournament-bredt
|
|
# (ikke per runde) -- en deltaker spiller normalt flere runder i samme
|
|
# turnering og skal ikke måtte velge på nytt hver gang.
|
|
StatLevel = Literal["strokes_only", "strokes_and_putts", "full"]
|
|
|
|
|
|
class TournamentParticipantCreate(BaseModel):
|
|
player_id: str
|
|
# Konkurranseklasse (2026-08-03) -- valgfri, se 053_tournament_classes.sql.
|
|
class_id: str | None = None
|
|
stat_level: StatLevel = "strokes_only"
|
|
|
|
|
|
class TournamentParticipantUpdate(BaseModel):
|
|
"""exclude_unset, samme PATCH-semantikk som resten av appen."""
|
|
|
|
class_id: str | None = None
|
|
stat_level: StatLevel | None = None
|
|
|
|
|
|
class TournamentParticipantOut(BaseModel):
|
|
id: str
|
|
player_id: str
|
|
player_name: str
|
|
handicap_index_snapshot: float | None
|
|
class_id: str | None
|
|
class_name: str | None
|
|
stat_level: str
|
|
|
|
|
|
_TOURNAMENT_PARTICIPANT_COLUMNS = """
|
|
tp.id::text AS id, tp.player_id::text AS player_id,
|
|
p.display_name AS player_name,
|
|
tp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
|
tp.class_id::text AS class_id, tc.name AS class_name, tp.stat_level
|
|
"""
|
|
|
|
|
|
@router.post(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/participants",
|
|
response_model=TournamentParticipantOut,
|
|
status_code=201,
|
|
)
|
|
async def add_tournament_participant(
|
|
tournament_id: str,
|
|
body: TournamentParticipantCreate,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> TournamentParticipantOut:
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
player = await conn.fetchrow(
|
|
"SELECT display_name, handicap_index FROM player WHERE id = $1", body.player_id
|
|
)
|
|
if player is None:
|
|
raise app_error(404, "NOT_FOUND", "Spilleren finnes ikke.")
|
|
row = await conn.fetchrow(
|
|
f"""
|
|
WITH inserted AS (
|
|
INSERT INTO tournament_participant
|
|
(organization_id, tournament_id, player_id, handicap_index_snapshot, class_id, stat_level)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id, player_id, handicap_index_snapshot, class_id, stat_level
|
|
)
|
|
SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS}
|
|
FROM inserted tp
|
|
JOIN player p ON p.id = tp.player_id
|
|
LEFT JOIN tournament_class tc ON tc.id = tp.class_id
|
|
""",
|
|
organization_id,
|
|
tournament_id,
|
|
body.player_id,
|
|
player["handicap_index"],
|
|
body.class_id,
|
|
body.stat_level,
|
|
)
|
|
return TournamentParticipantOut(**dict(row))
|
|
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/participants",
|
|
response_model=list[TournamentParticipantOut],
|
|
)
|
|
async def list_tournament_participants(
|
|
tournament_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> list[TournamentParticipantOut]:
|
|
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.")
|
|
rows = await conn.fetch(
|
|
f"""
|
|
SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS}
|
|
FROM tournament_participant tp
|
|
JOIN player p ON p.id = tp.player_id
|
|
LEFT JOIN tournament_class tc ON tc.id = tp.class_id
|
|
WHERE tp.tournament_id = $1
|
|
ORDER BY p.display_name
|
|
""",
|
|
tournament_id,
|
|
)
|
|
return [TournamentParticipantOut(**dict(r)) for r in rows]
|
|
|
|
|
|
@router.patch(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/participants/{participant_id}",
|
|
response_model=TournamentParticipantOut,
|
|
)
|
|
async def update_tournament_participant(
|
|
tournament_id: str,
|
|
participant_id: str,
|
|
body: TournamentParticipantUpdate,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> TournamentParticipantOut:
|
|
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(participant_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_participant SET {', '.join(set_clauses)}
|
|
WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)}
|
|
RETURNING id, player_id, handicap_index_snapshot, class_id, stat_level
|
|
)
|
|
SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS}
|
|
FROM updated tp
|
|
JOIN player p ON p.id = tp.player_id
|
|
LEFT JOIN tournament_class tc ON tc.id = tp.class_id
|
|
""",
|
|
*values,
|
|
)
|
|
if row is None:
|
|
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.")
|
|
return TournamentParticipantOut(**dict(row))
|
|
|
|
|
|
@router.delete(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/participants/{participant_id}",
|
|
status_code=204,
|
|
)
|
|
async def remove_tournament_participant(
|
|
tournament_id: str,
|
|
participant_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> None:
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
deleted = await conn.fetchval(
|
|
"DELETE FROM tournament_participant WHERE id = $1 AND tournament_id = $2 RETURNING id",
|
|
participant_id,
|
|
tournament_id,
|
|
)
|
|
if deleted is None:
|
|
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.")
|
|
|
|
|
|
# =====================================================================
|
|
# Deltaker PÅ ÉN RUNDE (tee + cachet handicap for akkurat den runden)
|
|
# =====================================================================
|
|
|
|
class RoundParticipantCreate(BaseModel):
|
|
tournament_participant_id: str
|
|
tee_id: str
|
|
|
|
|
|
class RoundParticipantOut(BaseModel):
|
|
id: str
|
|
tournament_participant_id: str
|
|
player_name: str
|
|
tee_id: str
|
|
tee_name: str
|
|
course_handicap: int | None
|
|
playing_handicap: int | None
|
|
# Statistikknivå (migrasjon 075, ADR-071) -- tournament-bredt (tp.stat_
|
|
# level), speilet ut her slik at frontend vet om HoleGrid-cellen skal
|
|
# åpne det utvidede HoleStatsSheet-et for denne deltakeren.
|
|
stat_level: str
|
|
|
|
|
|
async def _compute_round_participant_handicap(conn, round_participant_id: str) -> None:
|
|
"""Regner course_handicap/playing_handicap for ÉN rundedeltaker.
|
|
|
|
v1 har ingen allowance-prosent for individuelle turneringer (ulikt
|
|
lagturneringenes AllowanceStrategy-familie, som er bygget for et
|
|
relativt to-siders oppgjør -- ikke relevant her) -- playing_handicap er
|
|
derfor alltid identisk med (avrundet) course_handicap. Setter INGENTING
|
|
hvis spilleren mangler handicap_index_snapshot eller en matchende
|
|
tee_rating -- kalleren har allerede validert dette FØR innsetting når
|
|
scoring_method krever det (se add_round_participant).
|
|
"""
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT tp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
|
rating.course_rating::float AS course_rating,
|
|
rating.slope_rating::float AS slope_rating,
|
|
rating.par AS par
|
|
FROM tournament_round_participant trp
|
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
|
JOIN player p ON p.id = tp.player_id
|
|
JOIN tee_rating rating ON rating.tee_id = trp.tee_id
|
|
AND rating.scope = 'full_18'
|
|
AND rating.gender = p.gender
|
|
WHERE trp.id = $1
|
|
""",
|
|
round_participant_id,
|
|
)
|
|
if row is None or row["handicap_index_snapshot"] is None:
|
|
return
|
|
ch = round_half_up(
|
|
course_handicap_raw(
|
|
row["handicap_index_snapshot"], row["slope_rating"], row["course_rating"], row["par"]
|
|
)
|
|
)
|
|
await conn.execute(
|
|
"UPDATE tournament_round_participant SET course_handicap = $1, playing_handicap = $1 WHERE id = $2",
|
|
ch,
|
|
round_participant_id,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants",
|
|
response_model=RoundParticipantOut,
|
|
status_code=201,
|
|
)
|
|
async def add_round_participant(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
body: RoundParticipantCreate,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> RoundParticipantOut:
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
round_row = await conn.fetchrow(
|
|
"""
|
|
SELECT tr.course_id::text AS course_id, t.scoring_method
|
|
FROM tournament_round tr
|
|
JOIN tournament t ON t.id = tr.tournament_id
|
|
WHERE tr.id = $1 AND tr.tournament_id = $2
|
|
""",
|
|
round_id,
|
|
tournament_id,
|
|
)
|
|
if round_row is None:
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
|
|
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 != round_row["course_id"]:
|
|
raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke rundens bane.")
|
|
|
|
requires_handicap = round_row["scoring_method"] in _SCORING_METHODS_REQUIRING_HANDICAP
|
|
if requires_handicap:
|
|
player_row = await conn.fetchrow(
|
|
"""
|
|
SELECT tp.handicap_index_snapshot, p.gender
|
|
FROM tournament_participant tp
|
|
JOIN player p ON p.id = tp.player_id
|
|
WHERE tp.id = $1
|
|
""",
|
|
body.tournament_participant_id,
|
|
)
|
|
if player_row is None:
|
|
raise app_error(404, "NOT_FOUND", "Turnering-deltakeren finnes ikke.")
|
|
if player_row["handicap_index_snapshot"] is None:
|
|
raise app_error(
|
|
400,
|
|
"VALIDATION_FAILED",
|
|
"Denne spilleren mangler handicap-indeks -- påkrevd for "
|
|
"nettoslagspill/Stableford.",
|
|
)
|
|
if player_row["gender"] is None:
|
|
raise app_error(
|
|
400,
|
|
"VALIDATION_FAILED",
|
|
"Denne spilleren mangler registrert kjønn -- trengs for å finne riktig tee-rating.",
|
|
)
|
|
has_rating = await conn.fetchval(
|
|
"SELECT EXISTS(SELECT 1 FROM tee_rating WHERE tee_id = $1 AND scope = 'full_18' AND gender = $2)",
|
|
body.tee_id,
|
|
player_row["gender"],
|
|
)
|
|
if not has_rating:
|
|
kjonn_tekst = "dame" if player_row["gender"] == "f" else "herre"
|
|
raise app_error(
|
|
400, "VALIDATION_FAILED", f"Dette utslaget har ingen {kjonn_tekst}-rating -- velg et annet."
|
|
)
|
|
|
|
row = await conn.fetchrow(
|
|
"""
|
|
WITH inserted AS (
|
|
INSERT INTO tournament_round_participant
|
|
(organization_id, tournament_round_id, tournament_participant_id, tee_id)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id
|
|
)
|
|
SELECT inserted.id::text AS id
|
|
FROM inserted
|
|
""",
|
|
organization_id,
|
|
round_id,
|
|
body.tournament_participant_id,
|
|
body.tee_id,
|
|
)
|
|
round_participant_id = row["id"]
|
|
await _compute_round_participant_handicap(conn, round_participant_id)
|
|
|
|
out = await conn.fetchrow(
|
|
"""
|
|
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
|
|
p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
|
|
trp.course_handicap, trp.playing_handicap, tp.stat_level
|
|
FROM tournament_round_participant trp
|
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
|
JOIN player p ON p.id = tp.player_id
|
|
JOIN tee ON tee.id = trp.tee_id
|
|
WHERE trp.id = $1
|
|
""",
|
|
round_participant_id,
|
|
)
|
|
return RoundParticipantOut(**dict(out))
|
|
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants",
|
|
response_model=list[RoundParticipantOut],
|
|
)
|
|
async def list_round_participants(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> list[RoundParticipantOut]:
|
|
async with org_connection(organization_id) as conn:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
|
|
p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
|
|
trp.course_handicap, trp.playing_handicap, tp.stat_level
|
|
FROM tournament_round_participant trp
|
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
|
JOIN player p ON p.id = tp.player_id
|
|
JOIN tee ON tee.id = trp.tee_id
|
|
WHERE trp.tournament_round_id = $1
|
|
ORDER BY p.display_name
|
|
""",
|
|
round_id,
|
|
)
|
|
return [RoundParticipantOut(**dict(r)) for r in rows]
|
|
|
|
|
|
@router.delete(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants/{round_participant_id}",
|
|
status_code=204,
|
|
)
|
|
async def remove_round_participant(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
round_participant_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_round_participant WHERE id = $1 AND tournament_round_id = $2 RETURNING id",
|
|
round_participant_id,
|
|
round_id,
|
|
)
|
|
if deleted is None:
|
|
raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
|
|
|
|
|
|
# =====================================================================
|
|
# Utslagsgrupper (migrasjon 084, 2026-08-18) -- hvem spiller med hvem,
|
|
# klokka når. Bruker: "Jeg må jo kunne bulksjekke hvem som skal spille
|
|
# fra hvor, med hvem, klokka når?" Design bekreftet: TRADISJONELT utslag
|
|
# (samme starthull for alle, allerede rundens eget start_hole -- ingen
|
|
# endring der), staggerte klokkeslett per gruppe med FAST intervall
|
|
# (tournament_round.tee_interval_minutes), gruppesammensetning MANUELL
|
|
# MED et forslag å justere, FAST gruppestørrelse (organisator velger
|
|
# tallet). Se ADR-088.
|
|
# =====================================================================
|
|
|
|
def _compute_group_tee_time(
|
|
scheduled_at: datetime | None,
|
|
tee_interval_minutes: int | None,
|
|
sequence: int,
|
|
override: datetime | None,
|
|
) -> datetime | None:
|
|
"""Samme formel/filosofi som matches.py sin `_compute_tee_time` for
|
|
lagturneringers økter -- utledet, ikke lagret, med en valgfri
|
|
override som vinner (f.eks. én gruppe forsinket)."""
|
|
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)
|
|
|
|
|
|
class RoundGroupParticipant(BaseModel):
|
|
round_participant_id: str
|
|
player_name: str
|
|
|
|
|
|
class RoundGroupOut(BaseModel):
|
|
# `id=None` for en FORESLÅTT (ikke lagret) gruppe -- se suggest_round_groups.
|
|
id: str | None
|
|
sequence: int
|
|
tee_time: str | None # utledet (eller override), ISO-tekst
|
|
tee_time_override: str | None
|
|
participants: list[RoundGroupParticipant]
|
|
|
|
|
|
class RoundGroupsOut(BaseModel):
|
|
groups: list[RoundGroupOut]
|
|
# Rundedeltakere som IKKE er i noen gruppe ennå (nylig lagt til runden,
|
|
# eller aldri gruppert) -- alltid vist separat, aldri stille utelatt.
|
|
ungrouped: list[RoundGroupParticipant]
|
|
|
|
|
|
async def _round_scheduling(conn, tournament_id: str, round_id: str) -> asyncpg.Record:
|
|
round_row = await conn.fetchrow(
|
|
"SELECT scheduled_at, tee_interval_minutes FROM tournament_round WHERE id = $1 AND tournament_id = $2",
|
|
round_id,
|
|
tournament_id,
|
|
)
|
|
if round_row is None:
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
return round_row
|
|
|
|
|
|
async def _fetch_round_groups(conn, round_id: str, round_row) -> RoundGroupsOut:
|
|
group_rows = await conn.fetch(
|
|
"SELECT id::text, sequence, tee_time_override "
|
|
"FROM tournament_round_group WHERE tournament_round_id = $1 ORDER BY sequence",
|
|
round_id,
|
|
)
|
|
participant_rows = await conn.fetch(
|
|
"""
|
|
SELECT trp.id::text AS round_participant_id, p.display_name AS player_name,
|
|
trp.tournament_round_group_id::text AS group_id
|
|
FROM tournament_round_participant trp
|
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
|
JOIN player p ON p.id = tp.player_id
|
|
WHERE trp.tournament_round_id = $1
|
|
ORDER BY p.display_name
|
|
""",
|
|
round_id,
|
|
)
|
|
by_group: dict[str, list[RoundGroupParticipant]] = {}
|
|
ungrouped: list[RoundGroupParticipant] = []
|
|
for r in participant_rows:
|
|
entry = RoundGroupParticipant(round_participant_id=r["round_participant_id"], player_name=r["player_name"])
|
|
if r["group_id"] is None:
|
|
ungrouped.append(entry)
|
|
else:
|
|
by_group.setdefault(r["group_id"], []).append(entry)
|
|
|
|
groups = []
|
|
for g in group_rows:
|
|
tee_time = _compute_group_tee_time(
|
|
round_row["scheduled_at"], round_row["tee_interval_minutes"], g["sequence"], g["tee_time_override"]
|
|
)
|
|
groups.append(
|
|
RoundGroupOut(
|
|
id=g["id"],
|
|
sequence=g["sequence"],
|
|
tee_time=tee_time.isoformat() if tee_time is not None else None,
|
|
tee_time_override=g["tee_time_override"].isoformat() if g["tee_time_override"] else None,
|
|
participants=by_group.get(g["id"], []),
|
|
)
|
|
)
|
|
return RoundGroupsOut(groups=groups, ungrouped=ungrouped)
|
|
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/groups",
|
|
response_model=RoundGroupsOut,
|
|
)
|
|
async def list_round_groups(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> RoundGroupsOut:
|
|
async with org_connection(organization_id) as conn:
|
|
round_row = await _round_scheduling(conn, tournament_id, round_id)
|
|
return await _fetch_round_groups(conn, round_id, round_row)
|
|
|
|
|
|
class SuggestGroupsIn(BaseModel):
|
|
group_size: int = Field(ge=1, le=12)
|
|
|
|
|
|
@router.post(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/groups/suggest",
|
|
response_model=RoundGroupsOut,
|
|
)
|
|
async def suggest_round_groups(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
body: SuggestGroupsIn,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> RoundGroupsOut:
|
|
"""Regner ut et FORSLAG (skriver INGENTING) -- organisator justerer i
|
|
frontend og lagrer med save_round_groups når de er fornøyd. Fordeler
|
|
ALLE rundens deltakere (uansett eksisterende gruppe) i rekkefølge
|
|
etter spillernavn, i biter av `group_size` -- enkel og forutsigbar
|
|
startfordeling, ikke ment å være den endelige (manuell justering med
|
|
forslag å justere fra var det bekreftede designet, ikke helautomatisk)."""
|
|
async with org_connection(organization_id) as conn:
|
|
round_row = await _round_scheduling(conn, tournament_id, round_id)
|
|
participant_rows = await conn.fetch(
|
|
"""
|
|
SELECT trp.id::text AS round_participant_id, p.display_name AS player_name
|
|
FROM tournament_round_participant trp
|
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
|
JOIN player p ON p.id = tp.player_id
|
|
WHERE trp.tournament_round_id = $1
|
|
ORDER BY p.display_name
|
|
""",
|
|
round_id,
|
|
)
|
|
entries = [
|
|
RoundGroupParticipant(round_participant_id=r["round_participant_id"], player_name=r["player_name"])
|
|
for r in participant_rows
|
|
]
|
|
groups = []
|
|
for i in range(0, len(entries), body.group_size):
|
|
sequence = i // body.group_size + 1
|
|
chunk = entries[i : i + body.group_size]
|
|
tee_time = _compute_group_tee_time(
|
|
round_row["scheduled_at"], round_row["tee_interval_minutes"], sequence, None
|
|
)
|
|
groups.append(
|
|
RoundGroupOut(
|
|
id=None,
|
|
sequence=sequence,
|
|
tee_time=tee_time.isoformat() if tee_time is not None else None,
|
|
tee_time_override=None,
|
|
participants=chunk,
|
|
)
|
|
)
|
|
return RoundGroupsOut(groups=groups, ungrouped=[])
|
|
|
|
|
|
class RoundGroupIn(BaseModel):
|
|
sequence: int = Field(ge=1)
|
|
tee_time_override: datetime | None = None
|
|
round_participant_ids: list[str]
|
|
|
|
|
|
class SaveRoundGroupsIn(BaseModel):
|
|
groups: list[RoundGroupIn]
|
|
|
|
|
|
@router.put(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/groups",
|
|
response_model=RoundGroupsOut,
|
|
)
|
|
async def save_round_groups(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
body: SaveRoundGroupsIn,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> RoundGroupsOut:
|
|
"""Erstatter HELE rundens gruppeoppsett i én transaksjon -- samme
|
|
"lagre hele brettet på nytt"-mønster som spillerimport-tabellen
|
|
(enklere og mer forutsigbart enn å diffe mot forrige lagring).
|
|
Rundedeltakere som ikke er nevnt i noen gruppe blir/forblir
|
|
ugrupperte (ikke fjernet fra runden -- kun gruppetilhørigheten
|
|
endres)."""
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
round_row = await _round_scheduling(conn, tournament_id, round_id)
|
|
|
|
# Nullstiller koblingen EKSPLISITT før sletting -- se migrasjon 084
|
|
# sin kommentar for hvorfor FK-en bevisst IKKE har ON DELETE SET
|
|
# NULL (en sammensatt FK med SET NULL ville korrumpert
|
|
# organization_id, ikke bare selve gruppe-referansen).
|
|
await conn.execute(
|
|
"UPDATE tournament_round_participant SET tournament_round_group_id = NULL "
|
|
"WHERE tournament_round_id = $1",
|
|
round_id,
|
|
)
|
|
await conn.execute("DELETE FROM tournament_round_group WHERE tournament_round_id = $1", round_id)
|
|
|
|
for g in body.groups:
|
|
group_row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO tournament_round_group
|
|
(organization_id, tournament_round_id, sequence, tee_time_override)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id
|
|
""",
|
|
organization_id,
|
|
round_id,
|
|
g.sequence,
|
|
g.tee_time_override,
|
|
)
|
|
if g.round_participant_ids:
|
|
await conn.execute(
|
|
"""
|
|
UPDATE tournament_round_participant
|
|
SET tournament_round_group_id = $1
|
|
WHERE id = ANY($2::uuid[]) AND tournament_round_id = $3
|
|
""",
|
|
group_row["id"],
|
|
g.round_participant_ids,
|
|
round_id,
|
|
)
|
|
|
|
return await _fetch_round_groups(conn, round_id, round_row)
|
|
|
|
|
|
# =====================================================================
|
|
# Hull-for-hull-score (tournament_round_hole -- kilde-sannhet)
|
|
# =====================================================================
|
|
|
|
class HoleUpdate(BaseModel):
|
|
gross_strokes: int = Field(ge=1, le=20)
|
|
# Full statistikkdybde (migrasjon 075, ADR-071) -- speiler rounds.py sin
|
|
# HoleUpdate feltnavn-for-feltnavn (samme CHECK-betingelser i skjemaet).
|
|
putts: int | None = Field(default=None, ge=0, le=10)
|
|
club_off_tee: str | None = Field(default=None, max_length=50)
|
|
tee_shot_result: Literal["fairway", "left", "right"] | None = None
|
|
approach_result: Literal["hit", "long", "short", "left", "right"] | None = None
|
|
chip_count: int | None = Field(default=None, ge=0)
|
|
bunker_shot_count: int | None = Field(default=None, ge=0)
|
|
penalty_strokes: int | None = Field(default=None, ge=0)
|
|
first_putt_distance_bucket: Literal["<1m", "<2m", "<3m", "<5m", "<8m", "8m+"] | None = None
|
|
anyway_strokes: int | None = Field(default=None, ge=0)
|
|
# Optimistisk versjonssjekk (ADR-057-mønster, ny for denne tabellen).
|
|
# None = hopp over sjekken (samme bakoverkompatible mønster som rounds.py).
|
|
expected_version: int | None = None
|
|
|
|
|
|
class RoundHoleOut(BaseModel):
|
|
hole_number: int
|
|
par: int
|
|
stroke_index: int
|
|
gross_strokes: int | None
|
|
putts: int | None
|
|
club_off_tee: str | None
|
|
tee_shot_result: str | None
|
|
approach_result: str | None
|
|
chip_count: int | None
|
|
bunker_shot_count: int | None
|
|
penalty_strokes: int | None
|
|
first_putt_distance_bucket: str | None
|
|
anyway_strokes: int | None
|
|
version: int
|
|
strokes_received: int | None
|
|
|
|
|
|
async def _recompute_round_score(conn, round_id: str, round_participant_id: str, organization_id: str) -> None:
|
|
"""Regner ferdig totalsum (brutto/netto/Stableford) på nytt for ÉN
|
|
deltaker i ÉN runde, og cacher resultatet -- kalt etter HVER
|
|
hull-innsending (samme mønster som recompute_and_cache_match_state).
|
|
"""
|
|
ctx = await conn.fetchrow(
|
|
"""
|
|
SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config,
|
|
trp.tournament_participant_id::text AS tournament_participant_id,
|
|
trp.playing_handicap
|
|
FROM tournament_round_participant trp
|
|
JOIN tournament_round tr ON tr.id = trp.tournament_round_id
|
|
WHERE trp.id = $1
|
|
""",
|
|
round_participant_id,
|
|
)
|
|
holes = await conn.fetch(
|
|
"""
|
|
SELECT h.hole_number, h.par, h.stroke_index, trh.gross_strokes
|
|
FROM hole h
|
|
LEFT JOIN tournament_round_hole trh
|
|
ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
|
|
WHERE h.course_id = $2
|
|
ORDER BY h.hole_number
|
|
""",
|
|
round_participant_id,
|
|
ctx["course_id"],
|
|
)
|
|
played = [h for h in holes if h["gross_strokes"] is not None]
|
|
holes_played = len(played)
|
|
gross_total = stroke_play_gross_total([h["gross_strokes"] for h in played]) if played else None
|
|
|
|
net_total = None
|
|
stableford_points = None
|
|
if played and ctx["playing_handicap"] is not None:
|
|
all_18_si = [h["stroke_index"] for h in holes]
|
|
played_numbers = [h["hole_number"] for h in played]
|
|
received = allocate_over_played_holes(ctx["playing_handicap"], all_18_si, played_numbers)
|
|
gross_list = [h["gross_strokes"] for h in played]
|
|
pars = [h["par"] for h in played]
|
|
net_total = stroke_play_net_total(gross_list, received)
|
|
stableford_points = stableford_total(pars, gross_list, received)
|
|
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO tournament_round_score
|
|
(organization_id, tournament_round_id, tournament_participant_id,
|
|
holes_played, gross_total, net_total, stableford_points, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
|
ON CONFLICT (tournament_round_id, tournament_participant_id) DO UPDATE SET
|
|
holes_played = $4, gross_total = $5, net_total = $6,
|
|
stableford_points = $7, updated_at = now()
|
|
""",
|
|
organization_id,
|
|
round_id,
|
|
ctx["tournament_participant_id"],
|
|
holes_played,
|
|
gross_total,
|
|
net_total,
|
|
stableford_points,
|
|
)
|
|
|
|
|
|
async def _recompute_copenhagen_points(conn, round_id: str, organization_id: str) -> None:
|
|
"""Københavner (2026-07-30): 6 poeng deles per hull mellom NØYAKTIG 3
|
|
deltakere basert på relativ NETTO-rangering -- kan derfor IKKE regnes
|
|
per deltaker isolert slik gross/net/stableford ellers gjøres (se
|
|
modulens docstring, "bevisst utenfor omfang" er nå innhentet for dette
|
|
ene formatet). Regner om for ALLE deltakerne i runden samtidig, kalt
|
|
etter HVER hull-innsending når scoring_method='copenhagen' -- ETTER at
|
|
_recompute_round_score allerede har kjørt for alle tre (sikrer at
|
|
tournament_round_score-raden finnes for alle, ikke bare den som nettopp
|
|
scoret)."""
|
|
participants = await conn.fetch(
|
|
"""
|
|
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
|
|
trp.playing_handicap
|
|
FROM tournament_round_participant trp
|
|
WHERE trp.tournament_round_id = $1
|
|
""",
|
|
round_id,
|
|
)
|
|
if len(participants) != 3:
|
|
return # Ikke komplett ennå -- copenhagen_points_for_hole krever nøyaktig 3
|
|
|
|
course_id = await conn.fetchval("SELECT course_id::text FROM tournament_round WHERE id = $1", round_id)
|
|
all_18 = await conn.fetch(
|
|
"SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", course_id
|
|
)
|
|
all_18_si = [h["stroke_index"] for h in all_18]
|
|
|
|
net_by_hole: dict[int, list[tuple[str, int]]] = {}
|
|
for p in participants:
|
|
if p["playing_handicap"] is None:
|
|
continue
|
|
holes = await conn.fetch(
|
|
"SELECT hole_number, gross_strokes FROM tournament_round_hole "
|
|
"WHERE tournament_round_participant_id = $1 AND gross_strokes IS NOT NULL "
|
|
"ORDER BY hole_number",
|
|
p["id"],
|
|
)
|
|
if not holes:
|
|
continue
|
|
played_numbers = [h["hole_number"] for h in holes]
|
|
allocation = allocate_over_played_holes(p["playing_handicap"], all_18_si, played_numbers)
|
|
for h, received in zip(holes, allocation):
|
|
net = h["gross_strokes"] - received
|
|
net_by_hole.setdefault(h["hole_number"], []).append((p["tournament_participant_id"], net))
|
|
|
|
scores_by_hole = [net_by_hole.get(h["hole_number"], []) for h in all_18]
|
|
totals, _log = compute_copenhagen_detail(scores_by_hole, higher_is_better=False)
|
|
|
|
for p in participants:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO tournament_round_score
|
|
(organization_id, tournament_round_id, tournament_participant_id, copenhagen_points, updated_at)
|
|
VALUES ($1, $2, $3, $4, now())
|
|
ON CONFLICT (tournament_round_id, tournament_participant_id) DO UPDATE SET
|
|
copenhagen_points = $4, updated_at = now()
|
|
""",
|
|
organization_id,
|
|
round_id,
|
|
p["tournament_participant_id"],
|
|
totals.get(p["tournament_participant_id"]),
|
|
)
|
|
|
|
|
|
async def _recompute_bbb_points(conn, round_id: str, organization_id: str) -> None:
|
|
"""Bingo Bango Bongo (2026-07-30): poeng er en per-HULL-fakta på tvers
|
|
av ALLE deltakerne i runden, ikke per deltaker isolert -- regner om for
|
|
alle samtidig, kalt etter HVER bbb-hull-registrering. Krever INGEN
|
|
handicap (ulikt Københavner) -- rene observasjons-poeng."""
|
|
participants = await conn.fetch(
|
|
"SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id "
|
|
"FROM tournament_round_participant trp WHERE trp.tournament_round_id = $1",
|
|
round_id,
|
|
)
|
|
if not participants:
|
|
return
|
|
tournament = await conn.fetchrow(
|
|
"SELECT t.bbb_sweep_bonus_enabled FROM tournament_round tr "
|
|
"JOIN tournament t ON t.id = tr.tournament_id WHERE tr.id = $1",
|
|
round_id,
|
|
)
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT hole_number, bingo_participant_id::text AS bingo_participant_id,
|
|
bango_participant_id::text AS bango_participant_id,
|
|
bongo_participant_id::text AS bongo_participant_id
|
|
FROM tournament_round_bbb_hole WHERE tournament_round_id = $1 ORDER BY hole_number
|
|
""",
|
|
round_id,
|
|
)
|
|
hole_log = [(r["bingo_participant_id"], r["bango_participant_id"], r["bongo_participant_id"]) for r in rows]
|
|
totals = compute_bbb(hole_log, tournament["bbb_sweep_bonus_enabled"])
|
|
|
|
for p in participants:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO tournament_round_score
|
|
(organization_id, tournament_round_id, tournament_participant_id, bbb_points, updated_at)
|
|
VALUES ($1, $2, $3, $4, now())
|
|
ON CONFLICT (tournament_round_id, tournament_participant_id) DO UPDATE SET
|
|
bbb_points = $4, updated_at = now()
|
|
""",
|
|
organization_id,
|
|
round_id,
|
|
p["tournament_participant_id"],
|
|
totals.get(p["tournament_participant_id"]),
|
|
)
|
|
|
|
|
|
class BBBHoleUpdate(BaseModel):
|
|
bingo_participant_id: str | None = None
|
|
bango_participant_id: str | None = None
|
|
bongo_participant_id: str | None = None
|
|
|
|
|
|
class BBBHoleOut(BaseModel):
|
|
hole_number: int
|
|
bingo_participant_id: str | None
|
|
bango_participant_id: str | None
|
|
bongo_participant_id: str | None
|
|
|
|
|
|
@router.patch(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/bbb/{hole_number}",
|
|
response_model=BBBHoleOut,
|
|
)
|
|
async def update_bbb_hole(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
hole_number: int,
|
|
body: BBBHoleUpdate,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> BBBHoleOut:
|
|
"""Runde-/deltaker-oppsett-nivå autorisasjon (get_authorized_org, org-
|
|
medlem), samme presedens som resten av dette modulens oppsett -- ikke
|
|
strammet inn til "kun deltakeren selv" slik update_hole er, siden
|
|
bbb-registrering er en OBSERVASJON om alle deltakerne (hvem som var
|
|
først på green osv.), ikke én persons egen score."""
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
round_row = await conn.fetchrow(
|
|
"""
|
|
SELECT tr.hole_config::text AS hole_config, t.scoring_method, t.id::text AS tournament_id
|
|
FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
|
|
WHERE tr.id = $1
|
|
""",
|
|
round_id,
|
|
)
|
|
if round_row is None or round_row["tournament_id"] != tournament_id:
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
if round_row["scoring_method"] != "bingo_bango_bongo":
|
|
raise app_error(400, "VALIDATION_FAILED", "Denne turneringen bruker ikke Bingo Bango Bongo.")
|
|
if hole_number not in played_hole_numbers(round_row["hole_config"]):
|
|
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor rundens hullomfang.")
|
|
|
|
for pid in (body.bingo_participant_id, body.bango_participant_id, body.bongo_participant_id):
|
|
if pid is None:
|
|
continue
|
|
belongs = await conn.fetchval(
|
|
"SELECT 1 FROM tournament_participant WHERE id = $1 AND tournament_id = $2",
|
|
pid,
|
|
tournament_id,
|
|
)
|
|
if not belongs:
|
|
raise app_error(400, "VALIDATION_FAILED", "Valgt spiller hører ikke til denne turneringen.")
|
|
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO tournament_round_bbb_hole
|
|
(organization_id, tournament_round_id, hole_number,
|
|
bingo_participant_id, bango_participant_id, bongo_participant_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
ON CONFLICT (tournament_round_id, hole_number) DO UPDATE SET
|
|
bingo_participant_id = $4, bango_participant_id = $5, bongo_participant_id = $6
|
|
RETURNING hole_number, bingo_participant_id::text AS bingo_participant_id,
|
|
bango_participant_id::text AS bango_participant_id,
|
|
bongo_participant_id::text AS bongo_participant_id
|
|
""",
|
|
organization_id,
|
|
round_id,
|
|
hole_number,
|
|
body.bingo_participant_id,
|
|
body.bango_participant_id,
|
|
body.bongo_participant_id,
|
|
)
|
|
await _recompute_bbb_points(conn, round_id, organization_id)
|
|
return BBBHoleOut(**dict(row))
|
|
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/bbb",
|
|
response_model=list[BBBHoleOut],
|
|
)
|
|
async def list_bbb_holes(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> list[BBBHoleOut]:
|
|
"""Rå bingo/bango/bongo-VALG per hull (ikke de utledede poengene) --
|
|
samme begrunnelse som round.py sin frittstående-motpart."""
|
|
async with org_connection(organization_id) as conn:
|
|
exists = await conn.fetchval(
|
|
"SELECT id FROM tournament_round WHERE id = $1 AND tournament_id = $2", round_id, tournament_id
|
|
)
|
|
if exists is None:
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT hole_number, bingo_participant_id::text AS bingo_participant_id,
|
|
bango_participant_id::text AS bango_participant_id,
|
|
bongo_participant_id::text AS bongo_participant_id
|
|
FROM tournament_round_bbb_hole WHERE tournament_round_id = $1
|
|
""",
|
|
round_id,
|
|
)
|
|
return [BBBHoleOut(**dict(r)) for r in rows]
|
|
|
|
|
|
@router.patch(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
|
"/participants/{round_participant_id}/holes/{hole_number}",
|
|
response_model=RoundHoleOut,
|
|
)
|
|
async def update_hole(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
round_participant_id: str,
|
|
hole_number: int,
|
|
body: HoleUpdate,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> RoundHoleOut:
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
round_row = await conn.fetchrow(
|
|
"""
|
|
SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config, tr.sequence,
|
|
t.scoring_method, t.cut_after_round
|
|
FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
|
|
WHERE tr.id = $1
|
|
""",
|
|
round_id,
|
|
)
|
|
if round_row is None:
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
if hole_number not in played_hole_numbers(round_row["hole_config"]):
|
|
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor rundens hullomfang.")
|
|
|
|
rp = await conn.fetchrow(
|
|
"""
|
|
SELECT trp.id, trp.tournament_participant_id::text AS tournament_participant_id, tp.cut
|
|
FROM tournament_round_participant trp
|
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
|
WHERE trp.id = $1 AND trp.tournament_round_id = $2
|
|
""",
|
|
round_participant_id,
|
|
round_id,
|
|
)
|
|
if rp is None:
|
|
raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
|
|
|
|
# Cut (migrasjon 083, 2026-08-18): en kuttet deltaker kan fortsatt
|
|
# se/rette score i runder T.O.M. cut_after_round (historikken deres
|
|
# skal ikke låses), men er blokkert fra å registrere score i runder
|
|
# ETTER cut-punktet -- det er selve poenget med en cut.
|
|
if (
|
|
rp["cut"]
|
|
and round_row["cut_after_round"] is not None
|
|
and round_row["sequence"] > round_row["cut_after_round"]
|
|
):
|
|
raise app_error(
|
|
403, "CUT", "Denne spilleren er kuttet og kan ikke registrere score i denne runden."
|
|
)
|
|
|
|
if not await user_is_own_tournament_participant(
|
|
conn, organization_id, rp["tournament_participant_id"], user.user_id
|
|
):
|
|
raise app_error(
|
|
403,
|
|
"NOT_TOURNAMENT_PARTICIPANT",
|
|
"Du kan kun registrere score for deg selv (eller være organisasjonsadministrator).",
|
|
)
|
|
|
|
hole = await conn.fetchrow(
|
|
"SELECT par, stroke_index FROM hole WHERE course_id = $1 AND hole_number = $2",
|
|
round_row["course_id"],
|
|
hole_number,
|
|
)
|
|
if hole is None:
|
|
raise app_error(
|
|
400, "VALIDATION_FAILED", "Banen mangler hull-data for dette hullnummeret."
|
|
)
|
|
|
|
# Optimistisk versjonssjekk (ADR-057-mønster): WHERE-betingelsen på
|
|
# DO UPDATE-grenen gjelder KUN når raden allerede finnes (konflikt)
|
|
# -- første innsending av et hull treffer alltid INSERT-grenen og
|
|
# skrives uansett expected_version, samme som round_hole sitt
|
|
# migrasjon-062-mønster forutsetter (raden "finnes" fra scratch der
|
|
# fordi alle 18 opprettes ved rundestart -- her opprettes den først
|
|
# ved første score, så INSERT-grenen MÅ være ubetinget).
|
|
updated_row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO tournament_round_hole
|
|
(organization_id, tournament_round_participant_id, hole_number, gross_strokes,
|
|
putts, club_off_tee, tee_shot_result, approach_result, chip_count,
|
|
bunker_shot_count, penalty_strokes, first_putt_distance_bucket, anyway_strokes,
|
|
version, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 1, now())
|
|
ON CONFLICT (tournament_round_participant_id, hole_number) DO UPDATE SET
|
|
gross_strokes = $4, putts = $5, club_off_tee = $6, tee_shot_result = $7,
|
|
approach_result = $8, chip_count = $9, bunker_shot_count = $10,
|
|
penalty_strokes = $11, first_putt_distance_bucket = $12, anyway_strokes = $13,
|
|
version = tournament_round_hole.version + 1, updated_at = now()
|
|
WHERE ($14::int IS NULL OR tournament_round_hole.version = $14::int)
|
|
RETURNING gross_strokes, putts, club_off_tee, tee_shot_result, approach_result,
|
|
chip_count, bunker_shot_count, penalty_strokes, first_putt_distance_bucket,
|
|
anyway_strokes, version
|
|
""",
|
|
organization_id,
|
|
round_participant_id,
|
|
hole_number,
|
|
body.gross_strokes,
|
|
body.putts,
|
|
body.club_off_tee,
|
|
body.tee_shot_result,
|
|
body.approach_result,
|
|
body.chip_count,
|
|
body.bunker_shot_count,
|
|
body.penalty_strokes,
|
|
body.first_putt_distance_bucket,
|
|
body.anyway_strokes,
|
|
body.expected_version,
|
|
)
|
|
if updated_row is None:
|
|
raise app_error(
|
|
409, "STALE_VERSION",
|
|
"Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.",
|
|
)
|
|
await _recompute_round_score(conn, round_id, round_participant_id, organization_id)
|
|
if round_row["scoring_method"] == "copenhagen":
|
|
# Kobenhavner-poeng avhenger av ALLE tre deltakernes netto på
|
|
# hvert hull -- regn om for de to andre ogsa (sikrer at deres
|
|
# tournament_round_score-rad finnes) FOR selve poeng-fordelingen.
|
|
other_ids = await conn.fetch(
|
|
"SELECT id::text AS id FROM tournament_round_participant "
|
|
"WHERE tournament_round_id = $1 AND id != $2",
|
|
round_id,
|
|
round_participant_id,
|
|
)
|
|
for other in other_ids:
|
|
await _recompute_round_score(conn, round_id, other["id"], organization_id)
|
|
await _recompute_copenhagen_points(conn, round_id, organization_id)
|
|
|
|
strokes_received = None
|
|
playing_handicap = await conn.fetchval(
|
|
"SELECT playing_handicap FROM tournament_round_participant WHERE id = $1", round_participant_id
|
|
)
|
|
if playing_handicap is not None:
|
|
all_18 = await conn.fetch(
|
|
"SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
|
|
round_row["course_id"],
|
|
)
|
|
allocation = allocate_over_played_holes(
|
|
playing_handicap, [h["stroke_index"] for h in all_18], [hole_number]
|
|
)
|
|
strokes_received = allocation[0]
|
|
|
|
return RoundHoleOut(
|
|
hole_number=hole_number,
|
|
par=hole["par"],
|
|
stroke_index=hole["stroke_index"],
|
|
strokes_received=strokes_received,
|
|
**dict(updated_row),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Spillerens per-hull-historikk (ADR-072) -- se app/hole_history.py for hele
|
|
# bane-bro-utledningen mellom frittstående runder og org-turneringer. Samme
|
|
# lesetilgang som list_round_participant_holes under (ethvert org-medlem,
|
|
# ikke self-only slik selve SCORINGEN er) -- aggregerte historikktall
|
|
# (snitt/GIR%) er ikke mer sensitivt enn selve scorekortet.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
|
"/participants/{round_participant_id}/holes/{hole_number}/history",
|
|
response_model=HoleHistoryOut | None,
|
|
)
|
|
async def get_round_participant_hole_history(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
round_participant_id: str,
|
|
hole_number: int,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> HoleHistoryOut | None:
|
|
async with org_connection(organization_id) as conn:
|
|
exists = await conn.fetchval(
|
|
"SELECT id FROM tournament_round WHERE id = $1 AND tournament_id = $2", round_id, tournament_id
|
|
)
|
|
if exists is None:
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
target_user_id = await conn.fetchval(
|
|
"""
|
|
SELECT p.user_id::text
|
|
FROM tournament_round_participant trp
|
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
|
JOIN player p ON p.id = tp.player_id
|
|
WHERE trp.id = $1 AND trp.tournament_round_id = $2
|
|
""",
|
|
round_participant_id, round_id,
|
|
)
|
|
if target_user_id is None:
|
|
# Gjest (ingen app_user-kobling) -- ingen historikk å finne.
|
|
return None
|
|
key = await resolve_tournament_round_course_key(conn, round_id)
|
|
return await hole_history_for_user(target_user_id, key, hole_number)
|
|
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
|
"/holes/{hole_number}/target-points",
|
|
response_model=list[TargetPoint],
|
|
)
|
|
async def get_tournament_round_hole_target_points(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
hole_number: int,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> list[TargetPoint]:
|
|
"""Rangefinder-data for hullet (migrasjon 079) -- samme kilde/prinsipp
|
|
som frittstående runders `GET /rounds/{id}/holes/{n}/target-points`,
|
|
her for en org-turnering-runde (individuell slagspill). Tom liste for
|
|
håndlagde baner, rangefinder-UI-et skjules da bare."""
|
|
async with org_connection(organization_id) as conn:
|
|
exists = await conn.fetchval(
|
|
"SELECT id FROM tournament_round WHERE id = $1 AND tournament_id = $2", round_id, tournament_id
|
|
)
|
|
if exists is None:
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
course_key = await resolve_tournament_round_course_key(conn, round_id)
|
|
points = await get_target_points(conn, course_key, hole_number)
|
|
return points
|
|
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
|
"/participants/{round_participant_id}/holes",
|
|
response_model=list[RoundHoleOut],
|
|
)
|
|
async def list_round_participant_holes(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
round_participant_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> list[RoundHoleOut]:
|
|
async with org_connection(organization_id) as conn:
|
|
round_row = await conn.fetchrow(
|
|
"SELECT course_id::text AS course_id FROM tournament_round WHERE id = $1", round_id
|
|
)
|
|
if round_row is None:
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
playing_handicap = await conn.fetchval(
|
|
"SELECT playing_handicap FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2",
|
|
round_participant_id,
|
|
round_id,
|
|
)
|
|
holes = await conn.fetch(
|
|
"""
|
|
SELECT h.hole_number, h.par, h.stroke_index, trh.gross_strokes,
|
|
trh.putts, trh.club_off_tee, trh.tee_shot_result, trh.approach_result,
|
|
trh.chip_count, trh.bunker_shot_count, trh.penalty_strokes,
|
|
trh.first_putt_distance_bucket, trh.anyway_strokes,
|
|
COALESCE(trh.version, 1) AS version
|
|
FROM hole h
|
|
LEFT JOIN tournament_round_hole trh
|
|
ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
|
|
WHERE h.course_id = $2
|
|
ORDER BY h.hole_number
|
|
""",
|
|
round_participant_id,
|
|
round_row["course_id"],
|
|
)
|
|
allocation = (
|
|
allocate_over_played_holes(
|
|
playing_handicap, [h["stroke_index"] for h in holes], [h["hole_number"] for h in holes]
|
|
)
|
|
if playing_handicap is not None
|
|
else [None] * len(holes)
|
|
)
|
|
return [
|
|
RoundHoleOut(strokes_received=a, **dict(h))
|
|
for h, a in zip(holes, allocation)
|
|
]
|
|
|
|
|
|
# =====================================================================
|
|
# Leaderboard (summert VED LESING på tvers av alle runder, Beslutning C)
|
|
# =====================================================================
|
|
|
|
class RoundCellOut(BaseModel):
|
|
round_number: int
|
|
label: str
|
|
tone: str | None # "under" | "even" | "over" -- matches stroke-play-leaderboard.tsx sin RoundCell
|
|
|
|
|
|
class EclecticHoleCellOut(BaseModel):
|
|
hole_number: int
|
|
par: int
|
|
value: int # brutto/nettoslag eller Stableford-poeng, avhengig av scoring_method
|
|
round_number: int # HVILKEN runde (sequence) det beste resultatet kom fra ("beste-kilde")
|
|
|
|
|
|
class LeaderboardEntry(BaseModel):
|
|
tournament_participant_id: str
|
|
# Order of Merit (2026-08-04, order_of_merit.py) trenger den EKTE
|
|
# spilleren, ikke bare denne ENE turneringens deltaker-rad, for å
|
|
# summere resultater på tvers av flere lenkede turneringer.
|
|
player_id: str
|
|
player_name: str
|
|
rounds_played: int
|
|
gross_total: int | None
|
|
net_total: int | None
|
|
stableford_total: int | None
|
|
copenhagen_total: int | None
|
|
bbb_total: int | None
|
|
# Konkurranseklasse (2026-08-03) -- frontend grupperer den allerede
|
|
# sorterte listen under på class_id (stabil gruppering bevarer riktig
|
|
# rangering per klasse). Sorteringen under er UENDRET -- ren tillegg.
|
|
class_id: str | None
|
|
class_name: str | None
|
|
# Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx) --
|
|
# KUN populert for scoring_method i (stroke_gross, stroke_net, stableford).
|
|
# Alltid None/tom liste for copenhagen/bingo_bango_bongo/flag -- frontend
|
|
# bruker fortsatt den enkle listen for de formatene.
|
|
position: str | None = None
|
|
is_leader: bool = False
|
|
today_label: str | None = None
|
|
thru_label: str | None = None
|
|
total_label: str | None = None
|
|
rounds: list[RoundCellOut] = []
|
|
# Nøytral visning for spillere som ikke har startet gjeldende/neste runde
|
|
# ennå (2026-08-18). next_start_hole settes alltid når det finnes en
|
|
# referanse-runde (start_hole har alltid en verdi -- default 1).
|
|
# next_tee_time settes kun når runden faktisk har et oppgitt klokkeslett
|
|
# (scheduled_at er nullable -- ikke alle turneringer setter det).
|
|
next_tee_time: str | None = None
|
|
next_start_hole: int | None = None
|
|
# Cut (migrasjon 083, 2026-08-18) -- KUN populert (og kun meningsfullt)
|
|
# for scoring_method i (stroke_gross, stroke_net, stableford), samme
|
|
# begrensning som resten av Augusta-resultattavlen. `rank` er den RÅ
|
|
# konkurranse-rangeringen (uten "T"-prefiks/uavgjort-tekst -- se
|
|
# `position` for visningsklar streng) -- brukt internt av apply_cut for
|
|
# å finne cut-grensen numerisk, men trygt å eksponere. `cut=True` når
|
|
# organisator har trykket "Anvend cut" og denne deltakeren falt utenfor
|
|
# cut_size+uavgjort-grensen (eller ikke hadde spilt noe gjennom
|
|
# cut_after_round i det hele tatt).
|
|
rank: int | None = None
|
|
cut: bool = False
|
|
# Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- beste resultat
|
|
# per hull på tvers av ALLE turneringens runder (samme bane, håndhevet
|
|
# ved rundeopprettelse). Alltid None/tom liste for øvrige scoring_
|
|
# method-verdier.
|
|
eclectic_total: int | None = None
|
|
eclectic_holes: list[EclecticHoleCellOut] = []
|
|
|
|
|
|
async def _compute_individual_standings(
|
|
conn, tournament_id: str, max_sequence: int | None = None
|
|
) -> tuple[dict, list[LeaderboardEntry]]:
|
|
"""Delt mellom `individual_leaderboard` under (denne ENE turneringens
|
|
egen visning) og Order of Merit sitt leaderboard-endepunkt
|
|
(`order_of_merit.py`, som summerer resultater over FLERE lenkede
|
|
turneringer) -- faktorisert ut 2026-08-04 for å unngå å duplisere
|
|
posisjon-/til-par-beregningen (inkl. den til-par-normaliserte
|
|
rangeringsnøkkelen, se kommentar i `_attach_stroke_play_columns`).
|
|
|
|
`max_sequence` (2026-08-18, Cut): når satt, regnes stillingen KUN ut
|
|
fra runder t.o.m. den sekvensen -- brukt av `apply_cut` til å beregne
|
|
"stillingen som den var etter cut_after_round", uavhengig av hvor
|
|
langt turneringen faktisk har kommet i mellomtiden. `None` (standard)
|
|
= alle runder, uendret oppførsel for den vanlige leaderboard-visningen.
|
|
|
|
Returnerer `(tournament_row, entries)` -- kalleren i OOM-sammenheng
|
|
trenger `tournament_row["scoring_method"]` for å vite hvilket felt på
|
|
hver entry (`gross_total`/`net_total`/`stableford_total`/`position`)
|
|
som faktisk er meningsfullt å lese videre."""
|
|
tournament = await conn.fetchrow(
|
|
"SELECT format_type, scoring_method FROM tournament WHERE id = $1", tournament_id
|
|
)
|
|
if tournament is None:
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
if tournament["format_type"] != "individual":
|
|
raise app_error(400, "VALIDATION_FAILED", "Kun individuelle turneringer har dette leaderboardet.")
|
|
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT tp.id::text AS tournament_participant_id, tp.player_id::text AS player_id,
|
|
p.display_name AS player_name,
|
|
tp.cut,
|
|
count(trs.id) FILTER (WHERE trs.holes_played > 0) AS rounds_played,
|
|
SUM(trs.gross_total)::int AS gross_total,
|
|
SUM(trs.net_total)::int AS net_total,
|
|
SUM(trs.stableford_points)::int AS stableford_total,
|
|
SUM(trs.copenhagen_points)::int AS copenhagen_total,
|
|
SUM(trs.bbb_points)::int AS bbb_total,
|
|
tc.id::text AS class_id, tc.name AS class_name
|
|
FROM tournament_participant tp
|
|
JOIN player p ON p.id = tp.player_id
|
|
LEFT JOIN tournament_round_score trs
|
|
ON trs.tournament_participant_id = tp.id
|
|
AND ($2::smallint IS NULL OR trs.tournament_round_id IN (
|
|
SELECT id FROM tournament_round WHERE tournament_id = $1 AND sequence <= $2
|
|
))
|
|
LEFT JOIN tournament_class tc ON tc.id = tp.class_id
|
|
WHERE tp.tournament_id = $1
|
|
GROUP BY tp.id, p.display_name, tc.id, tc.name
|
|
""",
|
|
tournament_id,
|
|
max_sequence,
|
|
)
|
|
entries = [LeaderboardEntry(**dict(r)) for r in rows]
|
|
|
|
method = tournament["scoring_method"]
|
|
if method in ("stroke_gross", "stroke_net", "stableford"):
|
|
# VIKTIG: kan IKKE sorteres på rå gross_total/stableford_total her
|
|
# (slik de andre metodene gjør) -- i en flerrunde-turnering har
|
|
# deltakere spilt ULIKT antall hull til enhver tid (noen midt i
|
|
# runde 2, andre ikke startet ennå), så rå sum er ikke
|
|
# sammenlignbar. _attach_stroke_play_columns sorterer selv, på
|
|
# til-par (normalisert for antall hull spilt), FØR posisjon
|
|
# tildeles -- reell bug funnet under scratch-verifisering: en
|
|
# deltaker med KUN 18 hull spilt (lavere rå sum) rangerte foran
|
|
# deltakere med -5 til par over 27-36 hull, kun fordi rått
|
|
# slagtall er mindre jo færre hull man har spilt.
|
|
await _attach_stroke_play_columns(conn, tournament_id, method, entries, max_sequence=max_sequence)
|
|
# Cut (2026-08-18): kuttede spillere vises samlet BAKERST, som en
|
|
# egen seksjon -- ALDRI innimellom fortsatt-med-spillere basert på
|
|
# frossent til-par-tall alene (en kuttet spiller sin score endrer
|
|
# seg jo aldri mer, mens resten av feltet fortsetter å spille --
|
|
# uten dette ville en kuttet spiller med et godt frosset tall kunne
|
|
# ende opp foran en som fortsatt spiller og har hatt en dårlig
|
|
# runde). `total_label` overstyres til "CUT" -- gjenbruker
|
|
# STATUS-mekanismen som WD/DQ/DNS/NR allerede har i
|
|
# stroke-play-leaderboard.tsx (dempet rad, egen badge), pluss en
|
|
# visuell skillelinje i komponenten mellom siste ikke-kuttede og
|
|
# første kuttede rad. `_attach_stroke_play_columns` har ALLEREDE
|
|
# sortert `entries` riktig internt (beste til-par først) -- en
|
|
# STABIL sortering på kun `cut` her bevarer den rekkefølgen innad i
|
|
# begge gruppene, ingen ny rangeringsnøkkel trengs.
|
|
entries.sort(key=lambda e: e.cut)
|
|
for e in entries:
|
|
if e.cut:
|
|
e.total_label = "CUT"
|
|
e.is_leader = False
|
|
elif method == "copenhagen":
|
|
# Flest poeng totalt vinner (kilden, spilletyper-og-spilleformer-2023.pdf s.4).
|
|
entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0)))
|
|
elif method == "bingo_bango_bongo":
|
|
entries.sort(key=lambda e: (e.bbb_total is None, -(e.bbb_total or 0)))
|
|
elif method in ("eclectic_gross", "eclectic_net", "eclectic_stableford"):
|
|
await _attach_eclectic_totals(conn, tournament_id, method, entries)
|
|
if method == "eclectic_stableford":
|
|
entries.sort(key=lambda e: (e.eclectic_total is None, -(e.eclectic_total or 0)))
|
|
else:
|
|
entries.sort(key=lambda e: (e.eclectic_total is None, e.eclectic_total or 0))
|
|
else:
|
|
entries.sort(key=lambda e: (e.gross_total is None, e.gross_total or 0))
|
|
|
|
return dict(tournament), entries
|
|
|
|
|
|
@router.get(
|
|
# IKKE /leaderboard -- den stien er allerede tournaments.py sitt
|
|
# LAG-leaderboard (points_side_a/b, forutsetter nøyaktig to lag). Samme
|
|
# klasse feil som /rounds vs. /my-rounds tidligere i prosjektet -- unngått
|
|
# her ved å gi den et eget, ikke-overlappende navn fra start.
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/individual-leaderboard",
|
|
response_model=list[LeaderboardEntry],
|
|
)
|
|
async def individual_leaderboard(
|
|
tournament_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> list[LeaderboardEntry]:
|
|
async with org_connection(organization_id) as conn:
|
|
_, entries = await _compute_individual_standings(conn, tournament_id)
|
|
return entries
|
|
|
|
|
|
class CutResult(BaseModel):
|
|
cut_after_round: int
|
|
cut_size: int
|
|
survivors: int
|
|
cut_count: int
|
|
cut_participant_ids: list[str]
|
|
|
|
|
|
@router.post(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/apply-cut",
|
|
response_model=CutResult,
|
|
)
|
|
async def apply_cut(
|
|
tournament_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> CutResult:
|
|
"""Anvender cutten MANUELT (migrasjon 083, 2026-08-18) -- bruker
|
|
bekreftet eksplisitt: IKKE automatisk ved fullspilt runde, organisator
|
|
trykker selv en "Anvend cut"-knapp når de er klare (unngår at cutten
|
|
slår inn mens noen fortsatt er midt i runden). Topp `cut_size` OG
|
|
delt plass slipper gjennom -- alle med samme til-par-tall som
|
|
grenseplassen regnes som "innenfor". Deltakere som ikke har spilt ETT
|
|
eneste hull gjennom `cut_after_round` kuttes alltid, uansett `cut_size`
|
|
(ingen resultat å beskytte).
|
|
|
|
IDEMPOTENT -- regner cut-linjen på nytt fra bunnen av HELE feltet hver
|
|
gang (aldri inkrementelt), så et nytt trykk etter en score-rettelse i
|
|
en tidligere runde gir riktig oppdatert resultat, ikke et lag oppå
|
|
forrige kjøring. Kan derfor trykkes flere ganger."""
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
tournament = await conn.fetchrow(
|
|
"SELECT format_type, scoring_method, cut_after_round, cut_size FROM tournament WHERE id = $1",
|
|
tournament_id,
|
|
)
|
|
if tournament is None:
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
if tournament["format_type"] != "individual" or tournament["scoring_method"] not in (
|
|
"stroke_gross", "stroke_net", "stableford"
|
|
):
|
|
raise app_error(
|
|
400, "VALIDATION_FAILED", "Cut er kun støttet for brutto-, netto- eller stableford-turneringer."
|
|
)
|
|
cut_after_round = tournament["cut_after_round"]
|
|
cut_size = tournament["cut_size"]
|
|
if cut_after_round is None or cut_size is None:
|
|
raise app_error(
|
|
400, "VALIDATION_FAILED", "Sett rundenummer og antall som går videre til cut før du anvender den."
|
|
)
|
|
|
|
_, entries = await _compute_individual_standings(conn, tournament_id, max_sequence=cut_after_round)
|
|
ranked = [e for e in entries if e.rank is not None]
|
|
ranked.sort(key=lambda e: e.rank)
|
|
if ranked and cut_size < len(ranked):
|
|
cutoff_rank = ranked[cut_size - 1].rank
|
|
else:
|
|
cutoff_rank = ranked[-1].rank if ranked else 0
|
|
cut_ids = [e.tournament_participant_id for e in entries if e.rank is None or e.rank > cutoff_rank]
|
|
|
|
await conn.execute(
|
|
"UPDATE tournament_participant SET cut = (id = ANY($2::uuid[])) WHERE tournament_id = $1",
|
|
tournament_id,
|
|
cut_ids,
|
|
)
|
|
await conn.execute("UPDATE tournament SET cut_applied_at = now() WHERE id = $1", tournament_id)
|
|
|
|
return CutResult(
|
|
cut_after_round=cut_after_round,
|
|
cut_size=cut_size,
|
|
survivors=len(entries) - len(cut_ids),
|
|
cut_count=len(cut_ids),
|
|
cut_participant_ids=cut_ids,
|
|
)
|
|
|
|
|
|
_HOLE_COUNT_BY_CONFIG = {"full_18": 18, "front_9": 9, "back_9": 9}
|
|
|
|
|
|
def _to_par_label(v: int) -> str:
|
|
if v == 0:
|
|
return "E"
|
|
return f"+{v}" if v > 0 else str(v)
|
|
|
|
|
|
def _to_par_tone(v: int) -> str:
|
|
if v < 0:
|
|
return "under"
|
|
if v > 0:
|
|
return "over"
|
|
return "even"
|
|
|
|
|
|
async def _attach_stroke_play_columns(
|
|
conn, tournament_id: str, method: str, entries: list[LeaderboardEntry], max_sequence: int | None = None
|
|
) -> None:
|
|
"""Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx):
|
|
POS/TODAY/THRU/TOTAL/R1-Rn. Muterer `entries` (allerede riktig sortert av
|
|
kalleren) i stedet for å bygge en ny liste -- gjenbruker eksisterende
|
|
gross_total/net_total/stableford_total som ALLEREDE er summert riktig,
|
|
legger kun til det som mangler: par-for-spilte-hull (for til-par-tall),
|
|
per-runde nedbrytning, og posisjon-med-uavgjort-håndtering.
|
|
|
|
`max_sequence` (2026-08-18, Cut) -- se `_compute_individual_standings`.
|
|
"""
|
|
rounds_meta = await conn.fetch(
|
|
"""
|
|
SELECT sequence, hole_config::text AS hole_config, scheduled_at::text AS scheduled_at, start_hole
|
|
FROM tournament_round
|
|
WHERE tournament_id = $1
|
|
ORDER BY sequence
|
|
""",
|
|
tournament_id,
|
|
)
|
|
hole_count_by_seq = {
|
|
r["sequence"]: _HOLE_COUNT_BY_CONFIG[r["hole_config"]] for r in rounds_meta
|
|
}
|
|
round_meta_by_seq = {r["sequence"]: r for r in rounds_meta}
|
|
first_sequence = rounds_meta[0]["sequence"] if rounds_meta else None
|
|
|
|
# Én rad per (runde, deltaker) som faktisk er PÅBEGYNT (holes_played > 0).
|
|
# par_played = summen av par for KUN de hullene deltakeren faktisk har
|
|
# ført score på i den runden -- riktig grunnlag for et til-par-tall
|
|
# midt i en runde (ikke hele rundens par, som ville misvist før runden
|
|
# er ferdigspilt).
|
|
round_rows = await conn.fetch(
|
|
"""
|
|
SELECT tr.sequence,
|
|
trs.tournament_participant_id::text AS tournament_participant_id,
|
|
trs.holes_played, trs.gross_total, trs.net_total, trs.stableford_points,
|
|
COALESCE(pp.par_played, 0)::int AS par_played
|
|
FROM tournament_round tr
|
|
JOIN tournament_round_score trs ON trs.tournament_round_id = tr.id
|
|
LEFT JOIN LATERAL (
|
|
SELECT SUM(h.par) AS par_played
|
|
FROM tournament_round_participant trp
|
|
JOIN tournament_round_hole trh ON trh.tournament_round_participant_id = trp.id
|
|
JOIN hole h ON h.course_id = tr.course_id AND h.hole_number = trh.hole_number
|
|
WHERE trp.tournament_round_id = tr.id
|
|
AND trp.tournament_participant_id = trs.tournament_participant_id
|
|
AND trh.gross_strokes IS NOT NULL
|
|
) pp ON true
|
|
WHERE tr.tournament_id = $1 AND trs.holes_played > 0
|
|
AND ($2::smallint IS NULL OR tr.sequence <= $2)
|
|
ORDER BY tr.sequence
|
|
""",
|
|
tournament_id,
|
|
max_sequence,
|
|
)
|
|
|
|
by_participant: dict[str, dict[int, object]] = {}
|
|
started_sequences: set[int] = set()
|
|
for r in round_rows:
|
|
by_participant.setdefault(r["tournament_participant_id"], {})[r["sequence"]] = r
|
|
started_sequences.add(r["sequence"])
|
|
# "I dag" = runden med høyest sekvensnummer som NOEN har påbegynt -- ikke
|
|
# nødvendigvis fullført (bekreftet med bruker: "det er den sist
|
|
# påbegynte"). Ingen eksplisitt status-/aktiv-markering finnes på
|
|
# tournament_round i dag, så dette utledes.
|
|
today_sequence = max(started_sequences) if started_sequences else None
|
|
|
|
value_col = "stableford_points" if method == "stableford" else (
|
|
"net_total" if method == "stroke_net" else "gross_total"
|
|
)
|
|
|
|
# Rangeringsnøkkel PER DELTAKER, til-par-normalisert (lavere = bedre,
|
|
# uniformt for alle tre metodene -- stableford negeres siden flere poeng
|
|
# er bedre der). Kan IKKE bruke rå gross_total/stableford_total: i en
|
|
# flerrunde-turnering har deltakere spilt ulikt antall hull til enhver
|
|
# tid, så rå sum er ikke sammenlignbar (se kallstedets kommentar).
|
|
NO_SCORE = 1_000_000_000
|
|
rank_key_by_id: dict[str, int] = {}
|
|
|
|
for e in entries:
|
|
pmap = by_participant.get(e.tournament_participant_id, {})
|
|
|
|
rounds_out: list[RoundCellOut] = []
|
|
total_par_played = 0
|
|
total_holes_played = 0
|
|
for seq, row in sorted(pmap.items()):
|
|
if row["holes_played"] == 0:
|
|
continue
|
|
total_par_played += row["par_played"]
|
|
total_holes_played += row["holes_played"]
|
|
if method == "stableford":
|
|
points = row["stableford_points"] or 0
|
|
expected = 2 * row["holes_played"]
|
|
rounds_out.append(
|
|
RoundCellOut(round_number=seq, label=f"{points} p", tone=_to_par_tone(points - expected))
|
|
)
|
|
else:
|
|
val = row[value_col]
|
|
if val is None:
|
|
continue
|
|
rounds_out.append(
|
|
RoundCellOut(
|
|
round_number=seq,
|
|
label=str(val),
|
|
tone=_to_par_tone(val - row["par_played"]),
|
|
)
|
|
)
|
|
e.rounds = rounds_out
|
|
|
|
# TODAY / THRU
|
|
today_row = pmap.get(today_sequence) if today_sequence is not None else None
|
|
if today_row is not None and today_row["holes_played"] > 0:
|
|
hole_count = hole_count_by_seq.get(today_sequence, 18)
|
|
e.thru_label = "F" if today_row["holes_played"] >= hole_count else str(today_row["holes_played"])
|
|
if method == "stableford":
|
|
e.today_label = f"{today_row['stableford_points'] or 0} p"
|
|
else:
|
|
val = today_row[value_col]
|
|
e.today_label = _to_par_label(val - today_row["par_played"]) if val is not None else None
|
|
else:
|
|
e.thru_label = "-"
|
|
e.today_label = None
|
|
|
|
# Ikke-startet-info (2026-08-18): så lenge deltakeren ikke har
|
|
# startet den gjeldende/neste runden ("-" over), vis tee-tid/
|
|
# starthull for DEN runden -- referanserunden er selve gjeldende
|
|
# runden (today_sequence) hvis turneringen er i gang, ellers
|
|
# runde 1 (ingen har startet noe ennå).
|
|
if e.thru_label == "-":
|
|
reference_sequence = today_sequence if today_sequence is not None else first_sequence
|
|
meta = round_meta_by_seq.get(reference_sequence) if reference_sequence is not None else None
|
|
if meta is not None:
|
|
e.next_tee_time = meta["scheduled_at"]
|
|
e.next_start_hole = meta["start_hole"]
|
|
|
|
# TOTAL -- gjenbruker den allerede korrekt summerte gross_total/
|
|
# net_total/stableford_total (uendret av denne funksjonen), trekker
|
|
# kun fra summert par-for-spilte-hull for til-par-formatet. Ingen
|
|
# runde spilt ennå (total_holes_played == 0) -> total_label forblir
|
|
# None (nøytral -- IKKE "E"/"0 p", som ville sett ut som en ekte,
|
|
# jevn/poengløs score i stedet for "ikke startet").
|
|
started_any = total_holes_played > 0
|
|
if method == "stableford":
|
|
e.total_label = f"{e.stableford_total or 0} p" if started_any else None
|
|
rank_key_by_id[e.tournament_participant_id] = (
|
|
NO_SCORE if e.stableford_total is None or not started_any
|
|
else -(e.stableford_total - 2 * total_holes_played)
|
|
)
|
|
else:
|
|
total_val = e.net_total if method == "stroke_net" else e.gross_total
|
|
e.total_label = (
|
|
_to_par_label(total_val - total_par_played) if (total_val is not None and started_any) else None
|
|
)
|
|
rank_key_by_id[e.tournament_participant_id] = (
|
|
NO_SCORE if total_val is None or total_par_played == 0 else total_val - total_par_played
|
|
)
|
|
|
|
# Sorter på til-par-nøkkelen (lavere = bedre), IKKE på rå total -- se
|
|
# kallstedets kommentar for hvorfor rå total ikke er sammenlignbar på
|
|
# tvers av ulikt antall hull spilt.
|
|
entries.sort(key=lambda e: rank_key_by_id[e.tournament_participant_id])
|
|
|
|
# POS -- vanlig konkurranse-rangering (uavgjort deler plass, neste
|
|
# rangering hopper over like mange plasser som antall uavgjorte).
|
|
# Deltakere som ikke har spilt ETT eneste hull ennå (NO_SCORE) får INGEN
|
|
# posisjon -- de er ikke "sist rangert", de er ennå ikke en del av
|
|
# rangeringen (nøytral, se next_tee_time/next_start_hole over).
|
|
i, n = 0, len(entries)
|
|
while i < n:
|
|
j = i
|
|
key_i = rank_key_by_id[entries[i].tournament_participant_id]
|
|
while j < n and rank_key_by_id[entries[j].tournament_participant_id] == key_i:
|
|
j += 1
|
|
rank = i + 1
|
|
tied = (j - i) > 1
|
|
not_started_group = key_i == NO_SCORE
|
|
for k in range(i, j):
|
|
entries[k].position = None if not_started_group else (f"T{rank}" if tied else str(rank))
|
|
entries[k].is_leader = rank == 1 and not not_started_group
|
|
entries[k].rank = None if not_started_group else rank
|
|
i = j
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- "drømmerunde" satt
|
|
# sammen av BESTE resultat per hullnummer på tvers av ALLE turneringens
|
|
# runder. Krever samme bane for alle runder (håndhevet ved rundeopprettelse,
|
|
# se create_round over) -- hullnummer 5 i runde 1 og hullnummer 5 i runde 2
|
|
# er da GARANTERT samme fysiske hull/par/stroke index, så sammenligningen er
|
|
# meningsfull. Motoren (eclectic_best_per_hole) vet ingenting om db/HCP --
|
|
# denne funksjonen forbereder per-hull-per-runde-verdiene (brutto/netto/
|
|
# Stableford-poeng, avhengig av `method`) og mater dem inn.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def _attach_eclectic_totals(conn, tournament_id: str, method: str, entries: list[LeaderboardEntry]) -> None:
|
|
course_id = await conn.fetchval(
|
|
"SELECT course_id::text FROM tournament_round WHERE tournament_id = $1 LIMIT 1", tournament_id
|
|
)
|
|
if course_id is None:
|
|
return
|
|
|
|
hole_rows = await conn.fetch(
|
|
"SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
|
|
course_id,
|
|
)
|
|
par_by_hole = {r["hole_number"]: r["par"] for r in hole_rows}
|
|
si_by_hole = [r["stroke_index"] for r in hole_rows] # hull 1..18, i rekkefølge
|
|
|
|
round_rows = await conn.fetch(
|
|
"""
|
|
SELECT tr.sequence,
|
|
trp.tournament_participant_id::text AS tournament_participant_id,
|
|
trp.course_handicap, trh.hole_number, trh.gross_strokes
|
|
FROM tournament_round tr
|
|
JOIN tournament_round_participant trp ON trp.tournament_round_id = tr.id
|
|
JOIN tournament_round_hole trh ON trh.tournament_round_participant_id = trp.id
|
|
WHERE tr.tournament_id = $1 AND trh.gross_strokes IS NOT NULL
|
|
ORDER BY tr.sequence
|
|
""",
|
|
tournament_id,
|
|
)
|
|
|
|
# round_index i motorens forstand = rekkefølgen sequence-verdiene faktisk
|
|
# dukker opp i (stabil, sortert) -- brukt kun til å spore "beste-kilde"
|
|
# tilbake til et ekte rundenummer (sequence) for visning.
|
|
sequence_by_index: dict[int, int] = {}
|
|
for r in round_rows:
|
|
if r["sequence"] not in sequence_by_index.values():
|
|
sequence_by_index[len(sequence_by_index)] = r["sequence"]
|
|
index_by_sequence = {seq: i for i, seq in sequence_by_index.items()}
|
|
|
|
# Cache slagfordelingen per (course_handicap) -- samme fordeling for
|
|
# enhver rad med samme handicap, uansett hvilken runde/hull den gjelder.
|
|
allocation_cache: dict[int, list[int]] = {}
|
|
|
|
values_by_participant: dict[str, list[list[EclecticHoleValue]]] = {}
|
|
for r in round_rows:
|
|
if r["course_handicap"] is None:
|
|
continue
|
|
pid = r["tournament_participant_id"]
|
|
if pid not in values_by_participant:
|
|
values_by_participant[pid] = [[] for _ in range(18)]
|
|
|
|
strokes_received = 0
|
|
if method != "eclectic_gross":
|
|
hcp = r["course_handicap"]
|
|
if hcp not in allocation_cache:
|
|
allocation_cache[hcp] = allocate_strokes_by_index(hcp, si_by_hole)
|
|
strokes_received = allocation_cache[hcp][r["hole_number"] - 1]
|
|
|
|
if method == "eclectic_gross":
|
|
value = r["gross_strokes"]
|
|
elif method == "eclectic_net":
|
|
value = r["gross_strokes"] - strokes_received
|
|
else:
|
|
value = stableford_points_for_hole(par_by_hole[r["hole_number"]], r["gross_strokes"], strokes_received)
|
|
|
|
values_by_participant[pid][r["hole_number"] - 1].append(
|
|
EclecticHoleValue(round_index=index_by_sequence[r["sequence"]], value=value)
|
|
)
|
|
|
|
result_type = "gross" if method == "eclectic_gross" else "net" if method == "eclectic_net" else "stableford"
|
|
for e in entries:
|
|
values_by_hole = values_by_participant.get(e.tournament_participant_id)
|
|
if values_by_hole is None:
|
|
continue
|
|
result = eclectic_best_per_hole(values_by_hole, result_type)
|
|
if not result.holes:
|
|
continue
|
|
e.eclectic_total = result.total
|
|
e.eclectic_holes = [
|
|
EclecticHoleCellOut(
|
|
hole_number=h.hole_number,
|
|
par=par_by_hole[h.hole_number],
|
|
value=h.value,
|
|
round_number=sequence_by_index[h.round_index],
|
|
)
|
|
for h in result.holes
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Flaggturnering/Flag tournament (2026-07-30) -- egen, PER-RUNDE visning
|
|
# (ikke summert på tvers av turneringen slik individual_leaderboard gjør
|
|
# for de andre scoring_method-ene) -- Flag er strukturelt en
|
|
# enkelt-rundes-konkurranse ("hvem kommer lengst DENNE runden"), ikke et
|
|
# akkumulerbart poeng-/slagtall på tvers av flere runder.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class FlagParticipantResult(BaseModel):
|
|
tournament_participant_id: str
|
|
player_name: str
|
|
holes_completed: int
|
|
ran_out: bool
|
|
strokes_remaining: int
|
|
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/flag-result",
|
|
response_model=list[FlagParticipantResult],
|
|
)
|
|
async def flag_round_result(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> list[FlagParticipantResult]:
|
|
async with org_connection(organization_id) as conn:
|
|
round_row = await conn.fetchrow(
|
|
"""
|
|
SELECT tr.id::text AS id, tr.hole_config::text AS hole_config, t.scoring_method
|
|
FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
|
|
WHERE tr.id = $1 AND tr.tournament_id = $2
|
|
""",
|
|
round_id,
|
|
tournament_id,
|
|
)
|
|
if round_row is None:
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
if round_row["scoring_method"] != "flag":
|
|
raise app_error(400, "VALIDATION_FAILED", "Denne turneringen bruker ikke Flaggturnering.")
|
|
|
|
play_order = played_hole_numbers(round_row["hole_config"])
|
|
participants = await conn.fetch(
|
|
"""
|
|
SELECT trp.id::text AS id, trp.course_handicap, tp.id::text AS tournament_participant_id,
|
|
p.display_name AS player_name
|
|
FROM tournament_round_participant trp
|
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
|
JOIN player p ON p.id = tp.player_id
|
|
WHERE trp.tournament_round_id = $1
|
|
""",
|
|
round_id,
|
|
)
|
|
results: list[FlagParticipantResult] = []
|
|
for p in participants:
|
|
if p["course_handicap"] is None:
|
|
continue
|
|
hole_rows = await conn.fetch(
|
|
"""
|
|
SELECT h.hole_number, h.par, trh.gross_strokes
|
|
FROM hole h
|
|
LEFT JOIN tournament_round_hole trh
|
|
ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
|
|
WHERE h.course_id = (SELECT course_id FROM tournament_round WHERE id = $2)
|
|
""",
|
|
p["id"],
|
|
round_id,
|
|
)
|
|
by_hole = {r["hole_number"]: r for r in hole_rows}
|
|
total_par = sum(by_hole[h]["par"] for h in play_order if h in by_hole)
|
|
gross_prefix = []
|
|
for h in play_order:
|
|
row = by_hole.get(h)
|
|
if row is None or row["gross_strokes"] is None:
|
|
break
|
|
gross_prefix.append(row["gross_strokes"])
|
|
budget = total_par + p["course_handicap"]
|
|
fr = flag_result(gross_prefix, budget)
|
|
results.append(
|
|
FlagParticipantResult(
|
|
tournament_participant_id=p["tournament_participant_id"],
|
|
player_name=p["player_name"],
|
|
holes_completed=fr.holes_completed,
|
|
ran_out=fr.ran_out,
|
|
strokes_remaining=fr.strokes_remaining,
|
|
)
|
|
)
|
|
results.sort(key=lambda r: -r.holes_completed)
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Flaggturnering: GPS-flaggplanting + runde 2+ (migrasjon 070, 2026-08-14).
|
|
# Speiler rounds.py sin frittstående-variant presist -- se der for full
|
|
# begrunnelse (egen isolert overflow-tabell, server-side beregnet hull/lap,
|
|
# aldri stolt blindt på fra klienten). Ulikt frittstående runder er
|
|
# scoreregistrering her SELV-only (user_is_own_tournament_participant),
|
|
# samme presedens som update_hole over -- ikke flight-styrt.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def _flag_current_position_org(
|
|
conn, tournament_round_participant_id: str, hole_config: str
|
|
) -> tuple[int, int]:
|
|
"""Org-ekvivalent av rounds.py sin _flag_current_position -- samme
|
|
logikk, men leser tournament_round_hole/tournament_round_hole_flag_
|
|
overflow, og play_order kommer fra played_hole_numbers(hole_config)
|
|
(ingen start_hole-rotasjon på org-siden)."""
|
|
play_order = played_hole_numbers(hole_config)
|
|
lap1_rows = await conn.fetch(
|
|
"SELECT hole_number FROM tournament_round_hole "
|
|
"WHERE tournament_round_participant_id = $1 AND gross_strokes IS NOT NULL",
|
|
tournament_round_participant_id,
|
|
)
|
|
lap1_played = {r["hole_number"] for r in lap1_rows}
|
|
for h in play_order:
|
|
if h not in lap1_played:
|
|
return 1, h
|
|
lap = 2
|
|
while True:
|
|
overflow_rows = await conn.fetch(
|
|
"SELECT hole_number FROM tournament_round_hole_flag_overflow "
|
|
"WHERE tournament_round_participant_id = $1 AND lap = $2 AND played",
|
|
tournament_round_participant_id, lap,
|
|
)
|
|
overflow_played = {r["hole_number"] for r in overflow_rows}
|
|
for h in play_order:
|
|
if h not in overflow_played:
|
|
return lap, h
|
|
lap += 1
|
|
|
|
|
|
class FlagPlantIn(BaseModel):
|
|
lat: float = Field(ge=-90, le=90)
|
|
lng: float = Field(ge=-180, le=180)
|
|
hole_number: int = Field(ge=1, le=18)
|
|
lap: int = Field(ge=1)
|
|
on_green: bool = False
|
|
distance_to_pin_cm: int | None = Field(default=None, ge=0)
|
|
|
|
|
|
class FlagPlantOut(BaseModel):
|
|
id: str
|
|
lap: int
|
|
hole_number: int
|
|
lat: float
|
|
lng: float
|
|
on_green: bool
|
|
distance_to_pin_cm: int | None
|
|
planted_by_user_id: str
|
|
planted_at: str
|
|
|
|
|
|
def _flag_plant_out(row) -> FlagPlantOut:
|
|
return FlagPlantOut(
|
|
id=row["id"], lap=row["lap"], hole_number=row["hole_number"],
|
|
lat=row["lat"], lng=row["lng"], on_green=row["on_green"],
|
|
distance_to_pin_cm=row["distance_to_pin_cm"],
|
|
planted_by_user_id=row["planted_by_user_id"], planted_at=row["planted_at"].isoformat(),
|
|
)
|
|
|
|
|
|
async def _get_round_participant_or_404(conn, tournament_id: str, round_id: str, round_participant_id: str):
|
|
round_row = await conn.fetchrow(
|
|
"""
|
|
SELECT tr.hole_config::text AS hole_config, t.scoring_method
|
|
FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
|
|
WHERE tr.id = $1 AND tr.tournament_id = $2
|
|
""",
|
|
round_id, tournament_id,
|
|
)
|
|
if round_row is None:
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
if round_row["scoring_method"] != "flag":
|
|
raise app_error(400, "VALIDATION_FAILED", "Denne turneringen bruker ikke Flaggturnering.")
|
|
rp = await conn.fetchrow(
|
|
"SELECT id, tournament_participant_id::text AS tournament_participant_id "
|
|
"FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2",
|
|
round_participant_id, round_id,
|
|
)
|
|
if rp is None:
|
|
raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
|
|
return round_row, rp
|
|
|
|
|
|
async def _require_own_tournament_participant(conn, organization_id: str, tournament_participant_id: str, user_id: str) -> None:
|
|
if not await user_is_own_tournament_participant(conn, organization_id, tournament_participant_id, user_id):
|
|
raise app_error(
|
|
403, "NOT_TOURNAMENT_PARTICIPANT",
|
|
"Du kan kun plante/registrere flagg for deg selv (eller være organisasjonsadministrator).",
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
|
"/participants/{round_participant_id}/flag-plant",
|
|
response_model=FlagPlantOut,
|
|
)
|
|
async def plant_flag(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
round_participant_id: str,
|
|
body: FlagPlantIn,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> FlagPlantOut:
|
|
if body.on_green and body.distance_to_pin_cm is None:
|
|
raise app_error(400, "VALIDATION_FAILED", "Oppgi avstand til hullet når flagget plantes på green.")
|
|
if not body.on_green and body.distance_to_pin_cm is not None:
|
|
raise app_error(
|
|
400, "VALIDATION_FAILED", "Avstand til hullet gir kun mening når flagget er plantet på green."
|
|
)
|
|
async with org_connection(organization_id) as conn:
|
|
round_row, rp = await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id)
|
|
await _require_own_tournament_participant(conn, organization_id, rp["tournament_participant_id"], user.user_id)
|
|
|
|
actual_lap, actual_hole = await _flag_current_position_org(
|
|
conn, round_participant_id, round_row["hole_config"]
|
|
)
|
|
if (body.lap, body.hole_number) != (actual_lap, actual_hole):
|
|
raise app_error(
|
|
400, "VALIDATION_FAILED",
|
|
f"Registrert scoredata sier spilleren er på hull {actual_hole} (lap {actual_lap}), "
|
|
f"ikke hull {body.hole_number} (lap {body.lap}). Før inn score for gjenstående hull først.",
|
|
)
|
|
|
|
async with conn.transaction():
|
|
await conn.execute(
|
|
"DELETE FROM tournament_round_participant_flag_plant WHERE tournament_round_participant_id = $1",
|
|
round_participant_id,
|
|
)
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO tournament_round_participant_flag_plant
|
|
(organization_id, tournament_round_participant_id, lap, hole_number, lat, lng,
|
|
on_green, distance_to_pin_cm, planted_by_user_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING id::text AS id, lap, hole_number, lat, lng, on_green, distance_to_pin_cm,
|
|
planted_by_user_id::text AS planted_by_user_id, planted_at
|
|
""",
|
|
organization_id, round_participant_id, body.lap, body.hole_number, body.lat, body.lng,
|
|
body.on_green, body.distance_to_pin_cm, user.user_id,
|
|
)
|
|
return _flag_plant_out(row)
|
|
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
|
"/participants/{round_participant_id}/flag-plant",
|
|
response_model=FlagPlantOut | None,
|
|
)
|
|
async def get_flag_plant(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
round_participant_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> FlagPlantOut | None:
|
|
async with org_connection(organization_id) as conn:
|
|
await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id)
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT id::text AS id, lap, hole_number, lat, lng, on_green, distance_to_pin_cm,
|
|
planted_by_user_id::text AS planted_by_user_id, planted_at
|
|
FROM tournament_round_participant_flag_plant
|
|
WHERE tournament_round_participant_id = $1
|
|
""",
|
|
round_participant_id,
|
|
)
|
|
return _flag_plant_out(row) if row is not None else None
|
|
|
|
|
|
@router.delete(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
|
"/participants/{round_participant_id}/flag-plant",
|
|
status_code=204,
|
|
)
|
|
async def unplant_flag(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
round_participant_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> None:
|
|
async with org_connection(organization_id) as conn:
|
|
_, rp = await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id)
|
|
await _require_own_tournament_participant(conn, organization_id, rp["tournament_participant_id"], user.user_id)
|
|
await conn.execute(
|
|
"DELETE FROM tournament_round_participant_flag_plant WHERE tournament_round_participant_id = $1",
|
|
round_participant_id,
|
|
)
|
|
|
|
|
|
class FlagOverflowHoleIn(BaseModel):
|
|
gross_strokes: int = Field(gt=0, le=20)
|
|
|
|
|
|
class FlagOverflowHoleOut(BaseModel):
|
|
lap: int
|
|
hole_number: int
|
|
gross_strokes: int | None
|
|
played: bool
|
|
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
|
"/participants/{round_participant_id}/flag-overflow",
|
|
response_model=list[FlagOverflowHoleOut],
|
|
)
|
|
async def list_flag_overflow_holes(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
round_participant_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
) -> list[FlagOverflowHoleOut]:
|
|
async with org_connection(organization_id) as conn:
|
|
await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id)
|
|
rows = await conn.fetch(
|
|
"SELECT lap, hole_number, gross_strokes, played FROM tournament_round_hole_flag_overflow "
|
|
"WHERE tournament_round_participant_id = $1 ORDER BY lap, hole_number",
|
|
round_participant_id,
|
|
)
|
|
return [FlagOverflowHoleOut(**dict(r)) for r in rows]
|
|
|
|
|
|
@router.put(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
|
"/participants/{round_participant_id}/flag-overflow/{lap}/holes/{hole_number}",
|
|
response_model=FlagOverflowHoleOut,
|
|
)
|
|
async def update_flag_overflow_hole(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
round_participant_id: str,
|
|
lap: int,
|
|
hole_number: int,
|
|
body: FlagOverflowHoleIn,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> FlagOverflowHoleOut:
|
|
if lap < 2:
|
|
raise app_error(400, "VALIDATION_FAILED", "Lap må være 2 eller høyere -- lap 1 føres som vanlig scoreføring.")
|
|
if not (1 <= hole_number <= 18):
|
|
raise app_error(400, "VALIDATION_FAILED", "Ugyldig hullnummer.")
|
|
async with org_connection(organization_id) as conn:
|
|
round_row, rp = await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id)
|
|
await _require_own_tournament_participant(conn, organization_id, rp["tournament_participant_id"], user.user_id)
|
|
|
|
actual_lap, _ = await _flag_current_position_org(conn, round_participant_id, round_row["hole_config"])
|
|
if lap > actual_lap:
|
|
raise app_error(
|
|
400, "VALIDATION_FAILED",
|
|
f"Lap {lap} kan ikke føres ennå -- spilleren er fortsatt i lap {actual_lap}.",
|
|
)
|
|
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO tournament_round_hole_flag_overflow
|
|
(organization_id, tournament_round_participant_id, lap, hole_number, gross_strokes, played)
|
|
VALUES ($1, $2, $3, $4, $5, true)
|
|
ON CONFLICT (tournament_round_participant_id, lap, hole_number)
|
|
DO UPDATE SET gross_strokes = $5, played = true
|
|
RETURNING lap, hole_number, gross_strokes, played
|
|
""",
|
|
organization_id, round_participant_id, lap, hole_number, body.gross_strokes,
|
|
)
|
|
return FlagOverflowHoleOut(**dict(row))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) -- speiler
|
|
# rounds.py sin frittstående-variant presist. Styrt av tournament.flag_map_
|
|
# visible (ethvert org-medlem kan slå av/på via update_tournament,
|
|
# tournaments.py -- se TournamentUpdate.flag_map_visible). Bevisst KUN
|
|
# org-medlem-/deltaker-tilgang (samme presedens som flag_round_result over)
|
|
# -- ingen offentlig tilskuer-gren, individuelle turneringer har ingen
|
|
# offentlig spectator-side i det hele tatt ennå (ulikt frittstående
|
|
# rundevisning), se ADR-067 for avklaringen.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class FlagMapEntryOut(BaseModel):
|
|
participant_id: str
|
|
display_name: str
|
|
lap: int
|
|
hole_number: int
|
|
lat: float
|
|
lng: float
|
|
on_green: bool
|
|
distance_to_pin_cm: int | None
|
|
planted_at: str
|
|
|
|
|
|
class FlagMapOut(BaseModel):
|
|
visible_to_all: bool
|
|
flags: list[FlagMapEntryOut]
|
|
|
|
|
|
@router.get(
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/flag-map",
|
|
response_model=FlagMapOut,
|
|
)
|
|
async def get_flag_map(
|
|
tournament_id: str,
|
|
round_id: str,
|
|
organization_id: str = Depends(get_authorized_org),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> FlagMapOut:
|
|
async with org_connection(organization_id) as conn:
|
|
round_row = await conn.fetchrow(
|
|
"""
|
|
SELECT t.flag_map_visible
|
|
FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
|
|
WHERE tr.id = $1 AND tr.tournament_id = $2
|
|
""",
|
|
round_id,
|
|
tournament_id,
|
|
)
|
|
if round_row is None:
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
visible_to_all = round_row["flag_map_visible"]
|
|
|
|
base_query = """
|
|
SELECT trpfp.tournament_round_participant_id::text AS participant_id,
|
|
p.display_name AS display_name,
|
|
trpfp.lap, trpfp.hole_number, trpfp.lat, trpfp.lng,
|
|
trpfp.on_green, trpfp.distance_to_pin_cm, trpfp.planted_at
|
|
FROM tournament_round_participant_flag_plant trpfp
|
|
JOIN tournament_round_participant trp ON trp.id = trpfp.tournament_round_participant_id
|
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
|
JOIN player p ON p.id = tp.player_id
|
|
WHERE trp.tournament_round_id = $1
|
|
"""
|
|
if visible_to_all:
|
|
rows = await conn.fetch(base_query, round_id)
|
|
else:
|
|
rows = await conn.fetch(
|
|
base_query + " AND p.user_id = $2",
|
|
round_id,
|
|
user.user_id,
|
|
)
|
|
|
|
return FlagMapOut(
|
|
visible_to_all=visible_to_all,
|
|
flags=[
|
|
FlagMapEntryOut(
|
|
participant_id=r["participant_id"],
|
|
display_name=r["display_name"],
|
|
lap=r["lap"],
|
|
hole_number=r["hole_number"],
|
|
lat=r["lat"],
|
|
lng=r["lng"],
|
|
on_green=r["on_green"],
|
|
distance_to_pin_cm=r["distance_to_pin_cm"],
|
|
planted_at=r["planted_at"].isoformat(),
|
|
)
|
|
for r in rows
|
|
],
|
|
)
|