teecup/app/routers/individual_tournaments.py
Erol Haagenrud 9b7ff8e2ef Eclectic-format for org-individuelle turneringer (Del C) + tre UI-rettelser
Del C (ADR-068, migrasjon 072, siste del av den tredelte utvidelsen som
startet med Flaggturnering GPS/kart, se ADR-066/067): nytt format
eclectic_gross/eclectic_net/eclectic_stableford -- beste resultat per
hull på tvers av en turnerings egne runder, krever samme bane (avvist
tydelig ved rundeopprettelse ellers). Regnes ut ved lesing, ingen nye
tabeller. Bevisst avvik fra opprinnelig plan: integrert som en ny gren
i eksisterende individual-leaderboard-endepunkt fremfor et nytt eget
endepunkt -- se ADR-068 for begrunnelsen.

Tre ikke-relaterte, brukerrapporterte UI-rettelser tatt med i samme
runde: avstandsindikatoren brukte "grønn"/"Midt" i stedet for riktige
golf-uttrykk "green"/"senter", og "Oppdateres live"-badgen fjernet.
"Antall hull"-bryteren i Ny runde-veiviseren fikk samme grønne
aksent-valgt-stil som resten av samme skjerm (delt Segmented-primitiv).

Se ARCHITECTURE_DECISIONS.md (ADR-068) og CHANGELOG.md (punkt 84) for
full begrunnelse og verifiseringslogg.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 11:12:25 +02:00

1908 lines
78 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 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 ..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
scheduled_at: str | None = None # ISO 8601, tolkes av asyncpg/Pydantic ved behov
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))
@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)
# =====================================================================
class TournamentParticipantCreate(BaseModel):
player_id: str
# Konkurranseklasse (2026-08-03) -- valgfri, se 053_tournament_classes.sql.
class_id: str | None = None
class TournamentParticipantUpdate(BaseModel):
"""Eneste redigerbare felt i dag er klassetilhørighet -- exclude_unset,
samme PATCH-semantikk som resten av appen."""
class_id: str | 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
_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
"""
@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)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, player_id, handicap_index_snapshot, class_id
)
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,
)
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
)
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
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
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
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.")
# =====================================================================
# Hull-for-hull-score (tournament_round_hole -- kilde-sannhet)
# =====================================================================
class HoleUpdate(BaseModel):
gross_strokes: int = Field(ge=1, le=20)
class RoundHoleOut(BaseModel):
hole_number: int
par: int
stroke_index: int
gross_strokes: int | None
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, t.scoring_method
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
FROM tournament_round_participant trp
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.")
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."
)
await conn.execute(
"""
INSERT INTO tournament_round_hole
(organization_id, tournament_round_participant_id, hole_number, gross_strokes, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (tournament_round_participant_id, hole_number) DO UPDATE SET
gross_strokes = $4, updated_at = now()
""",
organization_id,
round_participant_id,
hole_number,
body.gross_strokes,
)
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"],
gross_strokes=body.gross_strokes,
strokes_received=strokes_received,
)
@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
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(
hole_number=h["hole_number"],
par=h["par"],
stroke_index=h["stroke_index"],
gross_strokes=h["gross_strokes"],
strokes_received=a,
)
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] = []
# 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) -> 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`).
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,
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
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,
)
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)
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
_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]
) -> 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.
"""
rounds_meta = await conn.fetch(
"""
SELECT sequence, hole_config::text AS hole_config
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
}
# É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
ORDER BY tr.sequence
""",
tournament_id,
)
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
# 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.
if method == "stableford":
e.total_label = f"{e.stableford_total or 0} p"
rank_key_by_id[e.tournament_participant_id] = (
NO_SCORE if e.stableford_total is None or total_holes_played == 0
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 else "E"
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).
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
for k in range(i, j):
entries[k].position = f"T{rank}" if tied else str(rank)
entries[k].is_leader = rank == 1
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
],
)