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>
1908 lines
78 KiB
Python
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
|
|
],
|
|
)
|