teecup/app/routers/individual_tournaments.py
Erol Haagenrud fea701091c Bygg Order of Merit: sesong-sammenlagt spillerrangering på tvers av turneringer
Ny funksjon (ADR-043, migrasjon 055): organisatorer kan opprette en
Order of Merit, lenke individuelle turneringer til den, og få en
sesong-sammenlagt rangering regnet ut ved lesing -- fem resultattyper
(poeng etter plassering, Stableford-, brutto- og nettoscore-sum,
pengeliste), sum/snitt-aggregering, med valgfri behold-N-beste,
minimum antall resultater og aldersgrense. Motor i handicap_engine.py
(9 nye enhetstester), ny app/routers/order_of_merit.py, og to nye
håndkodede frontend-sider under /organizations/[id]/order-of-merit.

Eclectic-aggregering og lag-OOM sin faktiske resultatberegning er
bevisst ikke bygget denne runden (skjema/CRUD for lag finnes, men
leaderboardet avviser kind='team' med en tydelig feilmelding).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:19:30 +02:00

1405 lines
56 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,
bbb_points_for_hole,
compute_bbb,
compute_copenhagen_detail,
course_handicap_raw,
flag_result,
round_half_up,
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 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."
)
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 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] = []
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)))
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
# ---------------------------------------------------------------------------
# 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