teecup/app/routers/scoring.py
Erol Haagenrud 93af7c5bef Rangefinder-koordinater for offisielle (TeeOff-koblede) baner
Rangefinder-funksjonalitet fantes til nå kun for GolfAPI-importerte
personlige baner. Offisielle teeoff-koblede baner (org-turneringer OG
frittstående runder spilt på en ekte teeoff-bane) hadde aldri noen
koordinatkilde. Ny migrasjon 079: delte domener (course_poi_type/
location/side) for å unngå duplisert CHECK-vedlikehold på tvers av
tabeller, ny global tabell teeoff_course_coordinate.

Ny delt modul app/target_points.py + resolve_match_course_key() i
hole_history.py (tredje søster til de to eksisterende bane-bro-
funksjonene) -- alle tre kallesteder (frittstående runder, individuell
org-turnering, lag-matchplay) bruker nå samme CourseKey-abstraksjon.
rounds.py sitt eksisterende endepunkt refaktorert til samme mønster --
sideeffekt: frittstående runder på en ekte teeoff-bane får nå også
rangefinder (var alltid tom liste før).

Ny skrive-vei i courses.py (PUT/GET .../coordinates) -- generell,
gjenbrukbar mulighet for å legge inn koordinater på enhver offisiell
bane, ikke en engangsfiks. HoleTargetDistance generalisert (roundId ->
baseUrl) og wiret inn i begge org-turnering-scoringsflytene.

11 nye tester (106/106 backend totalt), full scratch-verifisert
ende-til-ende i nettleser i alle tre flyter inkl. lys/mørk og
selvskjuling, regresjonssjekk av eksisterende GolfAPI-rangefinder
bekreftet uendret oppførsel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 12:06:47 +02:00

1083 lines
45 KiB
Python

"""
Scoring (ADR-012): to moduser per økt, cachet matchstatus.
'stroke' -> hole_score (brutto per hull, motoren utleder netto/hull-resultat).
'hole_result' -> match_hole_result (bare hvem som vant hullet, ingen motor for
selve hull-resultatet -- handicapen er allerede bakt inn i
match_participant fra oppsettsrunden).
Autorisasjon: begrenset til den enkelte matchens FAKTISKE deltakere (en
match_participant-rad for brukeren i akkurat denne matchen), ikke bare
"noen på laget" og UAVHENGIG av kapteinmerket -- se app/team_authz.py sin
user_is_match_participant. Byttet fra "rostret på laget" 2026-07-19
(Brukerroller-runden, ADR-023).
Individuell-vs-delt-ball (FEATURE_BACKLOG sitt app-lags-punkt, lukkes her):
singles/fourball MÅ ha match_participant_id; foursome/greensome/scramble MÅ
IKKE ha det. Ingenting i skjemaet håndhever dette.
Upsert i stedet for avvisning ved dobbel-innsending -- bevisst valg for at
live-scoring skal tåle rettelser. Ingen audit-trail på korrigeringer ennå.
Matchstatus-recompute låser match-raden FØRST (FOR UPDATE) for å serialisere
samtidige scoreinnsendinger for SAMME match (typisk to fourball-partnere som
taster inn ulike hull samtidig) -- uten det kan to transaksjoner lese
hverandres data før commit og siste UPDATE kan overskrive en mer komplett
status med en mindre komplett.
"Hopp over uferdige hull": compute_match_state teller POSISJON i listen
(len(hole_results)), ikke hullnummer. Derfor tas kun den SAMMENHENGENDE
prefiksen fra hull 1 og ut til første hull som ikke kan avgjøres -- aldri hull
lenger ute i sekvensen, selv om de tilfeldigvis er komplette, siden det ville
forskjøvet holes_remaining/is_dormie/is_closed for resten av matchen.
Walkover/konsesjon (ADR-024): `apply_concession` er en egen, enklere vei til
akkurat de samme fire kolonnene (`status_text`/`points_side_a/b`/
`leading_side`) som `recompute_and_cache_match_state` skriver -- brukt når
matchen avgjøres ved at en side GIR SEG, ikke ved utledning fra faktiske
hull. Delt med tournaments.py sin turnering-nivå-konsesjon (gir opp ALLE
ikke-avgjorte matcher laget har, i én operasjon).
"""
import json
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from handicap_engine import (
HoleResult,
allocate_over_played_holes,
compute_match_state,
high_low_high_points_for_hole,
high_low_high_running_score,
money_ball_hole_score,
shamble_hole_score,
stableford_points_for_hole,
)
from ..auth import CurrentUser, get_current_user
from ..db import org_connection
from ..errors import app_error, translate_db_errors
from ..handicap import parse_allowance_config, relative_strokes_for_match
from ..realtime import broadcast_live_update
from ..hole_history import resolve_match_course_key
from ..target_points import TargetPoint, get_target_points
from ..team_authz import (
is_org_member,
user_is_match_participant,
user_is_team_captain,
user_is_tournament_participant,
)
router = APIRouter()
_INDIVIDUAL_FORMATS = {"singles", "fourball", "shamble", "money_ball", "high_low_high"}
def played_hole_numbers(hole_config: str) -> list[int]:
if hole_config == "front_9":
return list(range(1, 10))
if hole_config == "back_9":
return list(range(10, 19))
return list(range(1, 19))
def _side_net(rows: list, strokes_per_hole: dict, format_: str, hole_number: int) -> int | None:
"""Sidens NETTO for ett hull, eller None hvis ikke avgjørbart ennå."""
if format_ not in _INDIVIDUAL_FORMATS:
if len(rows) != 1:
return None
r = rows[0]
per_hole = strokes_per_hole.get(r["team_side"])
if per_hole is None or hole_number not in per_hole:
return None
return r["gross_strokes"] - per_hole[hole_number]
# singles: forventer nøyaktig 1 rad. fourball: forventer nøyaktig 2
# (begge partnere) og tar den BESTE (laveste) netto -- klassisk
# better-ball-regel. Ufullstendig antall rader = ikke avgjørbart ennå.
expected_rows = 1 if format_ == "singles" else 2
if len(rows) != expected_rows:
return None
nets = []
for r in rows:
per_hole = strokes_per_hole.get(r["match_participant_id"])
if per_hole is None or hole_number not in per_hole:
return None
nets.append(r["gross_strokes"] - per_hole[hole_number])
return min(nets)
async def _compute_shamble_hole_results(conn, match_id: str, match, played: list[int]) -> list[HoleResult]:
"""Shamble (2026-07-30): lagets hullscore = sum av de `session.
shamble_best_n` laveste INDIVIDUELLE brutto-scorene blant sidens
spillere -- rå brutto, INGEN handicap-justering (kilden nevner ingen
egen allowance for selve hullscoren, kun for hvem som spiller sammen).
Krever at BEGGE sider har akkurat like mange registrerte spillere som
faktisk står på laget (variabel 2-4, satt av organisator) -- egen,
selvstendig sti, IKKE _side_net (som forutsetter en FAST enhetsstørrelse
per format)."""
best_n = match["shamble_best_n"]
if best_n is None:
return []
score_rows = await conn.fetch(
"SELECT hole_number, team_side::text AS team_side, gross_strokes "
"FROM hole_score WHERE match_id = $1 AND match_participant_id IS NOT NULL",
match_id,
)
by_hole_side: dict[tuple[int, str], list[int]] = {}
for r in score_rows:
by_hole_side.setdefault((r["hole_number"], r["team_side"]), []).append(r["gross_strokes"])
side_size_rows = await conn.fetch(
"SELECT team_side::text AS team_side, count(*) AS n FROM match_participant "
"WHERE match_id = $1 GROUP BY team_side",
match_id,
)
expected_by_side = {r["team_side"]: r["n"] for r in side_size_rows}
results = []
for h in played:
scores_a = by_hole_side.get((h, "a"), [])
scores_b = by_hole_side.get((h, "b"), [])
if len(scores_a) != expected_by_side.get("a", -1) or len(scores_b) != expected_by_side.get("b", -1):
break
if not (1 <= best_n <= len(scores_a)) or not (1 <= best_n <= len(scores_b)):
break
score_a = shamble_hole_score(scores_a, best_n)
score_b = shamble_hole_score(scores_b, best_n)
if score_a < score_b:
results.append(HoleResult.SIDE_A)
elif score_b < score_a:
results.append(HoleResult.SIDE_B)
else:
results.append(HoleResult.HALVED)
return results
async def _compute_money_ball_hole_results(conn, match_id: str, played: list[int]) -> list[HoleResult]:
"""Money Ball/Lone Ranger (2026-07-30): lagets hullscore = money-ball-
spillerens brutto + laveste av de tre andre -- rå brutto, INGEN
handicap-justering (samme begrunnelse som Shamble). Rotasjonen er
basert på POSISJON i den spilte rekkefølgen (1. spilte hull =
rotasjonsindeks 0), ikke rått hullnummer."""
score_rows = await conn.fetch(
"SELECT hole_number, team_side::text AS team_side, match_participant_id::text AS match_participant_id, "
"gross_strokes FROM hole_score WHERE match_id = $1 AND match_participant_id IS NOT NULL",
match_id,
)
by_hole_side: dict[tuple[int, str], dict[str, int]] = {}
for r in score_rows:
by_hole_side.setdefault((r["hole_number"], r["team_side"]), {})[r["match_participant_id"]] = r["gross_strokes"]
lineup_rows = await conn.fetch(
"SELECT id::text AS id, team_side::text AS team_side, lineup_order "
"FROM match_participant WHERE match_id = $1",
match_id,
)
ordered_by_side: dict[str, list[str]] = {"a": [None] * 4, "b": [None] * 4}
for r in lineup_rows:
if r["team_side"] in ordered_by_side and r["lineup_order"] is not None:
ordered_by_side[r["team_side"]][r["lineup_order"]] = r["id"]
results = []
for position, h in enumerate(played, start=1):
side_scores: dict[str, int] = {}
ok = True
for side in ("a", "b"):
order = ordered_by_side[side]
if any(pid is None for pid in order):
ok = False
break
hole_scores = by_hole_side.get((h, side), {})
if len(hole_scores) != 4 or any(pid not in hole_scores for pid in order):
ok = False
break
scores_in_order = [hole_scores[pid] for pid in order]
side_scores[side] = money_ball_hole_score(scores_in_order, position)
if not ok:
break
if side_scores["a"] < side_scores["b"]:
results.append(HoleResult.SIDE_A)
elif side_scores["b"] < side_scores["a"]:
results.append(HoleResult.SIDE_B)
else:
results.append(HoleResult.HALVED)
return results
async def _compute_hole_results(conn, match_id: str, match) -> list[HoleResult]:
played = played_hole_numbers(match["hole_config"])
if match["scoring_mode"] == "hole_result":
rows = await conn.fetch(
"""
SELECT hole_number, winning_side::text AS winning_side
FROM match_hole_result WHERE match_id = $1
""",
match_id,
)
by_hole = {r["hole_number"]: r["winning_side"] for r in rows}
results = []
for h in played:
if h not in by_hole:
break
ws = by_hole[h]
results.append(
HoleResult.SIDE_A if ws == "a" else HoleResult.SIDE_B if ws == "b" else HoleResult.HALVED
)
return results
if match["format"] == "shamble":
return await _compute_shamble_hole_results(conn, match_id, match, played)
if match["format"] == "money_ball":
return await _compute_money_ball_hole_results(conn, match_id, played)
if match["format"] == "high_low_high":
# Passer IKKE inn i den ternære HoleResult-modellen under (som
# forutsetter ÉN sammenlignbar verdi per side per hull) -- High-
# low-high trenger BEGGE spilleres individuelle Stableford-poeng
# for selve høy/lav-rangeringen, og literal poengsum (0/1/2 per
# hull), ikke bare hvem som vant. Egen, dedikert GET-endepunkt
# (/high-low-high) dekker den faktiske stillingen -- match sin
# vanlige status_text/leading_side forblir bevisst null for dette
# formatet i v1 (samme "regn ut ved lesing i et eget endepunkt"-
# mønster som Nassau, ikke tvunget inn i denne cachen).
return []
# 'stroke'-modus
allowance_override = json.loads(match["allowance_override"]) if match["allowance_override"] else None
config = parse_allowance_config(match["format"], allowance_override)
relative = await relative_strokes_for_match(conn, match_id, match["format"], config)
if not relative:
return [] # handicap ikke klart for én eller flere enheter ennå
stroke_index_rows = await conn.fetch(
"SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
match["course_id"],
)
all_18_si = [r["stroke_index"] for r in stroke_index_rows]
# Uten alle 18 hull (f.eks. en egendefinert bane der hull aldri ble lagt
# til) kan slagfordelingen (allocate_over_played_holes) ikke gjøres --
# avvis tydelig FØR beregning i stedet for å krasje rått på tom liste.
if len(all_18_si) != 18:
raise app_error(
400,
"VALIDATION_FAILED",
"Banen mangler registrerte hull -- kan ikke beregne slagfordeling for stroke-modus.",
)
strokes_per_hole: dict[str, dict[int, int]] = {
unit: dict(zip(played, allocate_over_played_holes(total, all_18_si, played)))
for unit, total in relative.items()
}
score_rows = await conn.fetch(
"""
SELECT hole_number, team_side::text AS team_side,
match_participant_id::text AS match_participant_id, gross_strokes
FROM hole_score WHERE match_id = $1
""",
match_id,
)
by_hole_side: dict[tuple[int, str], list] = {}
for r in score_rows:
by_hole_side.setdefault((r["hole_number"], r["team_side"]), []).append(r)
results = []
for h in played:
net_a = _side_net(by_hole_side.get((h, "a"), []), strokes_per_hole, match["format"], h)
net_b = _side_net(by_hole_side.get((h, "b"), []), strokes_per_hole, match["format"], h)
if net_a is None or net_b is None:
break
if net_a < net_b:
results.append(HoleResult.SIDE_A)
elif net_b < net_a:
results.append(HoleResult.SIDE_B)
else:
results.append(HoleResult.HALVED)
return results
async def recompute_and_cache_match_state(conn, match_id: str) -> None:
match = await conn.fetchrow(
"""
SELECT m.id::text, s.format, s.scoring_mode,
s.hole_config::text AS hole_config, s.course_id::text AS course_id,
s.allowance_override::text AS allowance_override,
s.points_per_match::float AS points_per_match,
s.tournament_id::text AS tournament_id, s.shamble_best_n
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
FOR UPDATE OF m
""",
match_id,
)
results = await _compute_hole_results(conn, match_id, match)
played = played_hole_numbers(match["hole_config"])
state = compute_match_state(results, total_holes=len(played))
# ADR-020 Beslutning C: fortegnet av state.lead cachet i egen kolonne
# (i tillegg til den menneskelesbare status_text), slik at frontend kan
# style etter et strukturert felt i stedet for å parse tekst. Gjelder
# BÅDE pågående og avgjorte matcher -- vinnersiden i en avgjort match
# er alltid den samme som lead sitt fortegn allerede pekte på.
if state.lead > 0:
leading_side = "a"
elif state.lead < 0:
leading_side = "b"
else:
leading_side = None
complete = state.is_closed or state.holes_remaining == 0
if complete:
ppm = match["points_per_match"]
if state.lead > 0:
points_a, points_b = ppm, 0.0
elif state.lead < 0:
points_a, points_b = 0.0, ppm
else:
points_a = points_b = ppm / 2
await conn.execute(
"""
UPDATE match SET status_text = $1, points_side_a = $2, points_side_b = $3,
leading_side = $4
WHERE id = $5
""",
state.describe(),
points_a,
points_b,
leading_side,
match_id,
)
else:
await conn.execute(
"UPDATE match SET status_text = $1, leading_side = $2 WHERE id = $3",
state.describe(),
leading_side,
match_id,
)
# ADR-027: "Følg live"-siden lyttes til over WebSocket -- kringkast et
# "noe endret seg, hent på nytt"-signal til enhver som følger DENNE
# turneringen. Trygt å kalle uansett om noen faktisk lytter (no-op da).
await broadcast_live_update(match["tournament_id"])
class ConcedeOut(BaseModel):
match_id: str
status_text: str
points_side_a: float
points_side_b: float
leading_side: str
async def apply_concession(
conn, match_id: str, conceding_side: str, points_per_match: float, tournament_id: str
) -> ConcedeOut:
"""Setter matchen som avgjort ved walkover -- full poengsum til motstanderen,
uansett hvor mange hull som ev. allerede er registrert (marginen betyr
ingenting for poeng i match-play, kun seier/tap/delt gjør). Kaller MÅ selv
ha låst match-raden (FOR UPDATE) og bekreftet at den ikke allerede er
avgjort FØR denne kalles -- se de to kallstedene (denne filens
concede_match, tournaments.py sin concede_tournament).
`tournament_id` trengs KUN for ADR-027 sin "Følg live"-kringkasting."""
winner = "b" if conceding_side == "a" else "a"
points_a = 0.0 if conceding_side == "a" else points_per_match
points_b = points_per_match if conceding_side == "a" else 0.0
status_text = f"Walkover ({winner.upper()})"
await conn.execute(
"""
UPDATE match SET status_text = $1, points_side_a = $2, points_side_b = $3, leading_side = $4
WHERE id = $5
""",
status_text,
points_a,
points_b,
winner,
match_id,
)
await broadcast_live_update(tournament_id)
return ConcedeOut(
match_id=match_id, status_text=status_text, points_side_a=points_a, points_side_b=points_b, leading_side=winner
)
class ConcedeRequest(BaseModel):
conceding_team_id: str
@router.post(
"/orgs/{organization_id}/matches/{match_id}/concede",
response_model=ConcedeOut,
status_code=201,
)
async def concede_match(
match_id: str,
body: ConcedeRequest,
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> ConcedeOut:
"""Walkover/konsesjon (ADR-024): kun kaptein for det TAPENDE laget (laget
som selv gir seg -- speiler ekte golf-etikette, du kan ikke kreve seier
på motstanderens vegne) eller org-eier/admin kan erklære. Ensidig, ingen
bekreftelse fra motparten kreves -- samme tillitsnivå som all annen
scoring. Kan erklæres uansett hvor mange hull som allerede er
registrert."""
async with org_connection(organization_id) as conn, translate_db_errors():
match = await conn.fetchrow(
"""
SELECT m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
m.points_side_a::float AS points_side_a,
s.points_per_match::float AS points_per_match,
s.tournament_id::text AS tournament_id
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
FOR UPDATE OF m
""",
match_id,
)
if match is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if match["points_side_a"] is not None:
raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.")
if body.conceding_team_id == match["team_a_id"]:
conceding_side = "a"
elif body.conceding_team_id == match["team_b_id"]:
conceding_side = "b"
else:
raise app_error(400, "VALIDATION_FAILED", "Laget hører ikke til denne matchen.")
if not await user_is_team_captain(conn, organization_id, body.conceding_team_id, user.user_id):
raise app_error(
403,
"NOT_TEAM_CAPTAIN",
"Du må være kaptein for laget som gir seg (eller organisasjonsadministrator) for å erklære walkover.",
)
result = await apply_concession(
conn, match_id, conceding_side, match["points_per_match"], match["tournament_id"]
)
return result
class HoleScoreCreate(BaseModel):
team_side: str = Field(pattern="^[ab]$")
match_participant_id: str | None = None
hole_number: int = Field(ge=1, le=18)
gross_strokes: int = Field(ge=1, le=20)
# Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 039, samme
# funksjon som frittstående runder fikk 2026-07-25). KUN meningsfullt for
# delt-ball-hull (match_participant_id er None) -- helt valgfritt.
selected_participant_id: str | None = None
# Optimistisk versjonssjekk (ADR-060, samme mønster som round_hole/
# ADR-057). None = hopp over sjekken -- eneste rimelige verdi for et
# hull som ikke er registrert fra før (klienten kan ikke kjenne en
# versjon som ikke finnes ennå), og fortsatt bakoverkompatibelt med
# eldre klienter som ikke sender feltet.
expected_version: int | None = None
class HoleScoreOut(BaseModel):
hole_number: int
team_side: str
match_participant_id: str | None
gross_strokes: int
selected_participant_id: str | None = None
version: int
@router.post(
"/orgs/{organization_id}/matches/{match_id}/hole-scores",
response_model=HoleScoreOut,
status_code=201,
)
async def submit_hole_score(
match_id: str,
body: HoleScoreCreate,
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> HoleScoreOut:
async with org_connection(organization_id) as conn, translate_db_errors():
match = await conn.fetchrow(
"""
SELECT m.points_side_a::float AS points_side_a,
s.format, s.scoring_mode, s.hole_config::text AS hole_config
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if match["points_side_a"] is not None:
raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.")
if match["scoring_mode"] != "stroke":
raise app_error(
400,
"WRONG_SCORING_MODE",
"Denne økten bruker hull-resultat-modus, ikke slagregistrering.",
)
played = played_hole_numbers(match["hole_config"])
if body.hole_number not in played:
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")
is_individual = match["format"] in _INDIVIDUAL_FORMATS
if is_individual and body.match_participant_id is None:
raise app_error(
400,
"WRONG_PARTICIPANT_MODE",
"Dette formatet krever match_participant_id (individuell ball).",
)
if not is_individual and body.match_participant_id is not None:
raise app_error(
400,
"WRONG_PARTICIPANT_MODE",
"Dette formatet bruker delt ball -- oppgi ikke match_participant_id.",
)
if body.match_participant_id is not None:
participant = await conn.fetchrow(
"""
SELECT team_side::text AS team_side FROM match_participant
WHERE id = $1 AND match_id = $2
""",
body.match_participant_id,
match_id,
)
if participant is None:
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke i denne matchen.")
if participant["team_side"] != body.team_side:
raise app_error(
400, "MISMATCHED_SIDE", "match_participant_id tilhører ikke angitt side."
)
# Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 039) --
# gir kun mening for delt-ball-hull, og må tilhøre samme side som
# selve scoren registreres for (samme sjekk som round.py sin
# tilsvarende validering for frittstående runder).
if body.selected_participant_id is not None:
if body.match_participant_id is not None:
raise app_error(
400,
"WRONG_PARTICIPANT_MODE",
"selected_participant_id gjelder kun delt-ball-hull, ikke individuell ball.",
)
selected = await conn.fetchrow(
"SELECT team_side::text AS team_side FROM match_participant WHERE id = $1 AND match_id = $2",
body.selected_participant_id,
match_id,
)
if selected is None:
raise app_error(404, "NOT_FOUND", "Den valgte spilleren finnes ikke i denne matchen.")
if selected["team_side"] != body.team_side:
raise app_error(
400, "VALIDATION_FAILED", "Valgt spiller hører ikke til denne siden."
)
if not await user_is_match_participant(
conn, organization_id, match_id, user.user_id, team_side=body.team_side
):
raise app_error(
403,
"NOT_MATCH_PARTICIPANT",
"Du er ikke en av deltakerne i denne matchen (eller organisasjonsadministrator).",
)
# Versjonssjekken (ADR-060) trengs KUN på DO UPDATE-grenen -- en helt
# ny rad (første registrering av dette hullet) tar alltid den rene
# INSERT-veien og bryr seg aldri om expected_version, uansett hva
# klienten sendte (det finnes ingen tidligere versjon å sjekke mot).
# Et `row is None`-resultat herfra betyr derfor ALLTID en ekte
# versjonskonflikt -- aldri "finnes ikke" (INSERT-grenen dekker det
# tilfellet transparent), ulikt round_hole som trengte en egen
# eksistenssjekk for å skille de to.
if body.match_participant_id is not None:
row = await conn.fetchrow(
"""
INSERT INTO hole_score
(organization_id, match_id, team_side, match_participant_id, hole_number, gross_strokes)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (match_participant_id, hole_number) WHERE match_participant_id IS NOT NULL
DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes, updated_at = now(),
version = hole_score.version + 1
WHERE $7::int IS NULL OR hole_score.version = $7::int
RETURNING hole_number, team_side::text AS team_side,
match_participant_id::text AS match_participant_id, gross_strokes,
selected_participant_id::text AS selected_participant_id, version
""",
organization_id,
match_id,
body.team_side,
body.match_participant_id,
body.hole_number,
body.gross_strokes,
body.expected_version,
)
else:
row = await conn.fetchrow(
"""
INSERT INTO hole_score
(organization_id, match_id, team_side, match_participant_id, hole_number,
gross_strokes, selected_participant_id)
VALUES ($1, $2, $3, NULL, $4, $5, $6)
ON CONFLICT (match_id, team_side, hole_number) WHERE match_participant_id IS NULL
DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes,
selected_participant_id = EXCLUDED.selected_participant_id,
updated_at = now(), version = hole_score.version + 1
WHERE $7::int IS NULL OR hole_score.version = $7::int
RETURNING hole_number, team_side::text AS team_side,
match_participant_id::text AS match_participant_id, gross_strokes,
selected_participant_id::text AS selected_participant_id, version
""",
organization_id,
match_id,
body.team_side,
body.hole_number,
body.gross_strokes,
body.selected_participant_id,
body.expected_version,
)
if row is None:
raise app_error(
409, "STALE_VERSION",
"Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.",
)
await recompute_and_cache_match_state(conn, match_id)
return HoleScoreOut(**dict(row))
class HoleResultCreate(BaseModel):
hole_number: int = Field(ge=1, le=18)
winning_side: str | None = Field(default=None, pattern="^[ab]$")
# Se HoleScoreCreate.expected_version -- samme begrunnelse, samme mønster.
expected_version: int | None = None
class HoleResultOut(BaseModel):
hole_number: int
winning_side: str | None
version: int
@router.post(
"/orgs/{organization_id}/matches/{match_id}/hole-results",
response_model=HoleResultOut,
status_code=201,
)
async def submit_hole_result(
match_id: str,
body: HoleResultCreate,
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> HoleResultOut:
async with org_connection(organization_id) as conn, translate_db_errors():
match = await conn.fetchrow(
"""
SELECT m.points_side_a::float AS points_side_a,
s.scoring_mode, s.hole_config::text AS hole_config
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if match["points_side_a"] is not None:
raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.")
if match["scoring_mode"] != "hole_result":
raise app_error(
400,
"WRONG_SCORING_MODE",
"Denne økten bruker slagregistrering, ikke hull-resultat-modus.",
)
played = played_hole_numbers(match["hole_config"])
if body.hole_number not in played:
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")
# Begge sider kan rapportere et hull-resultat (hvilken som helst side kan
# vinne/tape/dele) -- brukeren må selv være deltaker i DENNE matchen,
# på hvilken som helst av de to sidene (team_side=None).
if not await user_is_match_participant(conn, organization_id, match_id, user.user_id):
raise app_error(
403,
"NOT_MATCH_PARTICIPANT",
"Du er ikke en av deltakerne i denne matchen (eller organisasjonsadministrator).",
)
row = await conn.fetchrow(
"""
INSERT INTO match_hole_result (organization_id, match_id, hole_number, winning_side)
VALUES ($1, $2, $3, $4)
ON CONFLICT (match_id, hole_number)
DO UPDATE SET winning_side = EXCLUDED.winning_side,
version = match_hole_result.version + 1
WHERE $5::int IS NULL OR match_hole_result.version = $5::int
RETURNING hole_number, winning_side::text AS winning_side, version
""",
organization_id,
match_id,
body.hole_number,
body.winning_side,
body.expected_version,
)
if row is None:
raise app_error(
409, "STALE_VERSION",
"Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.",
)
await recompute_and_cache_match_state(conn, match_id)
return HoleResultOut(**dict(row))
class ScorecardHole(BaseModel):
hole_number: int
result: str
class Scorecard(BaseModel):
match_id: str
status_text: str | None
points_side_a: float | None
points_side_b: float | None
holes: list[ScorecardHole]
# Rå oppføringer -- UTEN dette kan ikke skjermen vise/redigere hva som
# faktisk er tastet inn, kun det utledede vinn/tap/delt-resultatet
# (utilstrekkelig for en gjenlastet scorekort-side). Nøyaktig ett av de
# to feltene er fylt ut, avhengig av øktens scoring_mode.
stroke_entries: list[HoleScoreOut] | None = None
hole_result_entries: list[HoleResultOut] | None = None
_RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"}
async def fetch_scorecard(conn, match_id: str) -> Scorecard:
"""Delt mellom den innloggede get_scorecard under og den offentlige
/public/tournaments/{id}/matches/{id}/scorecard (registration.py,
tilskuer-rolle/ADR-026). Ingen egen synlighetsfiltrering her -- den
offentlige ruten avgjør SELV, FØR denne kalles, om matchen i det hele
tatt er avslørt (blind draw, ADR-013) og om turneringen er synlig."""
match = await conn.fetchrow(
"""
SELECT m.id::text, m.status_text, m.points_side_a::float AS points_side_a,
m.points_side_b::float AS points_side_b,
s.format, s.scoring_mode, s.hole_config::text AS hole_config,
s.course_id::text AS course_id, s.allowance_override::text AS allowance_override,
s.shamble_best_n
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
results = await _compute_hole_results(conn, match_id, match)
stroke_entries = None
hole_result_entries = None
if match["scoring_mode"] == "stroke":
rows = await conn.fetch(
"""
SELECT hole_number, team_side::text AS team_side,
match_participant_id::text AS match_participant_id, gross_strokes,
selected_participant_id::text AS selected_participant_id, version
FROM hole_score WHERE match_id = $1 ORDER BY hole_number
""",
match_id,
)
stroke_entries = [HoleScoreOut(**dict(r)) for r in rows]
else:
rows = await conn.fetch(
"""
SELECT hole_number, winning_side::text AS winning_side, version
FROM match_hole_result WHERE match_id = $1 ORDER BY hole_number
""",
match_id,
)
hole_result_entries = [HoleResultOut(**dict(r)) for r in rows]
played = played_hole_numbers(match["hole_config"])
holes = [
ScorecardHole(hole_number=h, result=_RESULT_LABEL[r]) for h, r in zip(played, results)
]
return Scorecard(
stroke_entries=stroke_entries,
hole_result_entries=hole_result_entries,
match_id=match["id"],
status_text=match["status_text"],
points_side_a=match["points_side_a"],
points_side_b=match["points_side_b"],
holes=holes,
)
@router.get(
"/orgs/{organization_id}/matches/{match_id}/scorecard",
response_model=Scorecard,
)
async def get_scorecard(
match_id: str,
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> Scorecard:
"""2026-07-21 (deltaker-tilgang-runden): denne hadde tidligere INGEN
egen sjekk utover get_authorized_org (org-medlemskap) -- å bare fjerne
den ville latt EN HVILKEN SOM HELST autentisert bruker se ethvert
scorekort i organisasjonen. Erstattet med et eksplisitt OR: org-medlem
(bevarer eksisterende oppførsel for dagens brukere uendret) ELLER
deltaker i turneringen matchen hører til (ny -- gir en rostret/påmeldt
spiller uten org-medlemskap lesetilgang til scorekort i EGEN turnering,
samme brede "kan se andres matcher i egen turnering"-nivå som
tilskuer-siden ADR-026 allerede gir enhver som kan se turneringen)."""
async with org_connection(organization_id) as conn:
tournament_id = await conn.fetchval(
"""
SELECT s.tournament_id::text FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if tournament_id is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
conn, organization_id, tournament_id, user.user_id
):
raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
return await fetch_scorecard(conn, match_id)
@router.get(
"/orgs/{organization_id}/matches/{match_id}/holes/{hole_number}/target-points",
response_model=list[TargetPoint],
)
async def get_match_hole_target_points(
match_id: str,
hole_number: int,
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> list[TargetPoint]:
"""Rangefinder-data for hullet (migrasjon 079) -- samme kilde/prinsipp
som frittstående runders og individuell-turnerings target-points, her
for lag-matchplay (Ryder Cup-format). Samme tilgangsnivå/-mønster som
`get_scorecard` over (org-medlem ELLER deltaker i turneringen)."""
async with org_connection(organization_id) as conn:
tournament_id = await conn.fetchval(
"""
SELECT s.tournament_id::text FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if tournament_id is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
conn, organization_id, tournament_id, user.user_id
):
raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
course_key = await resolve_match_course_key(conn, match_id)
points = await get_target_points(conn, course_key, hole_number)
return points
# ---------------------------------------------------------------------------
# Nassau Match Play (2026-07-30) -- INGEN ny motor, INGEN lagring. Tre
# parallelle vinduer (hull 1-9, 10-18, 1-18) av EKSISTERENDE hull-for-hull-
# data (_compute_hole_results, allerede posisjonert 1:1 mot hullnummer for
# hole_config='full_18' -- se played_hole_numbers), hver kjørt gjennom den
# allerede eksisterende compute_match_state. Samme "regn ut ved lesing,
# aldri cache"-filosofi som resten av formatresultat-endepunktene.
# ---------------------------------------------------------------------------
class NassauWindowOut(BaseModel):
lead: int
holes_played: int
holes_remaining: int
is_closed: bool
is_dormie: bool
status_text: str
class NassauResultOut(BaseModel):
ready: bool
front9: NassauWindowOut | None = None
back9: NassauWindowOut | None = None
overall: NassauWindowOut | None = None
def _nassau_window(hole_results: list[HoleResult], total_holes: int) -> NassauWindowOut:
state = compute_match_state(hole_results, total_holes=total_holes)
return NassauWindowOut(
lead=state.lead,
holes_played=state.holes_played,
holes_remaining=state.holes_remaining,
is_closed=state.is_closed,
is_dormie=state.is_dormie,
status_text=state.describe(),
)
@router.get(
"/orgs/{organization_id}/matches/{match_id}/nassau",
response_model=NassauResultOut,
)
async def get_nassau_result(
match_id: str,
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> NassauResultOut:
"""Samme autorisasjon som get_scorecard over -- org-medlem ELLER
deltaker i turneringen matchen hører til."""
async with org_connection(organization_id) as conn:
match = await conn.fetchrow(
"""
SELECT m.id::text AS id, s.tournament_id::text AS tournament_id,
s.format, s.scoring_mode, s.hole_config::text AS hole_config,
s.course_id::text AS course_id,
s.allowance_override::text AS allowance_override, s.shamble_best_n
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
conn, organization_id, match["tournament_id"], user.user_id
):
raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
if match["hole_config"] != "full_18":
raise app_error(400, "VALIDATION_FAILED", "Nassau krever 18 hull.")
hole_results = await _compute_hole_results(conn, match_id, match)
if not hole_results:
return NassauResultOut(ready=False)
return NassauResultOut(
ready=True,
front9=_nassau_window(hole_results[0:9], total_holes=9),
back9=_nassau_window(hole_results[9:18], total_holes=9),
overall=_nassau_window(hole_results[0:18], total_holes=18),
)
# ---------------------------------------------------------------------------
# High-low-high (2026-07-30) -- egen, dedikert visning (som Nassau) --
# passer IKKE inn i den ternære HoleResult-/status_text-cachen (se
# _compute_hole_results sin egen kommentar). Beregnes helt ved LESING.
# ---------------------------------------------------------------------------
class HighLowHighHoleOut(BaseModel):
hole_number: int
points_a: int
points_b: int
class HighLowHighResultOut(BaseModel):
ready: bool
points_a: int | None = None
points_b: int | None = None
holes: list[HighLowHighHoleOut] | None = None
@router.get(
"/orgs/{organization_id}/matches/{match_id}/high-low-high",
response_model=HighLowHighResultOut,
)
async def get_high_low_high_result(
match_id: str,
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> HighLowHighResultOut:
"""Samme autorisasjon som get_scorecard/get_nassau_result over."""
async with org_connection(organization_id) as conn:
match = await conn.fetchrow(
"""
SELECT m.id::text AS id, m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
s.tournament_id::text AS tournament_id, s.format, s.hole_config::text AS hole_config,
s.course_id::text AS course_id, s.allowance_override::text AS allowance_override
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
conn, organization_id, match["tournament_id"], user.user_id
):
raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
if match["format"] != "high_low_high":
raise app_error(400, "VALIDATION_FAILED", "Denne matchen bruker ikke High-low-high.")
allowance_override = json.loads(match["allowance_override"]) if match["allowance_override"] else None
config = parse_allowance_config(match["format"], allowance_override)
relative = await relative_strokes_for_match(conn, match_id, match["format"], config)
if not relative:
return HighLowHighResultOut(ready=False)
participant_rows = await conn.fetch(
"SELECT id::text AS id, team_side::text AS team_side FROM match_participant WHERE match_id = $1",
match_id,
)
by_side: dict[str, list[str]] = {"a": [], "b": []}
for p in participant_rows:
by_side[p["team_side"]].append(p["id"])
if len(by_side["a"]) != 2 or len(by_side["b"]) != 2:
return HighLowHighResultOut(ready=False)
stroke_index_rows = await conn.fetch(
"SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
match["course_id"],
)
all_18_si = [r["stroke_index"] for r in stroke_index_rows]
if len(all_18_si) != 18:
raise app_error(400, "VALIDATION_FAILED", "Banen mangler registrerte hull.")
played = played_hole_numbers(match["hole_config"])
strokes_per_hole = {
pid: dict(zip(played, allocate_over_played_holes(total, all_18_si, played)))
for pid, total in relative.items()
}
score_rows = await conn.fetch(
"SELECT hole_number, match_participant_id::text AS match_participant_id, gross_strokes "
"FROM hole_score WHERE match_id = $1 AND match_participant_id IS NOT NULL",
match_id,
)
par_rows = await conn.fetch(
"SELECT hole_number, par FROM hole WHERE course_id = $1", match["course_id"]
)
par_by_hole = {r["hole_number"]: r["par"] for r in par_rows}
gross_by_hole: dict[int, dict[str, int]] = {}
for r in score_rows:
gross_by_hole.setdefault(r["hole_number"], {})[r["match_participant_id"]] = r["gross_strokes"]
hole_points: list[tuple[int, int]] = []
holes_out = []
for h in played:
scores_this_hole = gross_by_hole.get(h, {})
pids_a, pids_b = by_side["a"], by_side["b"]
if any(pid not in scores_this_hole for pid in pids_a + pids_b):
break
points_a_players = tuple(
stableford_points_for_hole(par_by_hole[h], scores_this_hole[pid], strokes_per_hole[pid][h])
for pid in pids_a
)
points_b_players = tuple(
stableford_points_for_hole(par_by_hole[h], scores_this_hole[pid], strokes_per_hole[pid][h])
for pid in pids_b
)
pa, pb = high_low_high_points_for_hole(points_a_players, points_b_players)
hole_points.append((pa, pb))
holes_out.append(HighLowHighHoleOut(hole_number=h, points_a=pa, points_b=pb))
if not hole_points:
return HighLowHighResultOut(ready=True, points_a=0, points_b=0, holes=[])
total_a, total_b = high_low_high_running_score(hole_points)
return HighLowHighResultOut(ready=True, points_a=total_a, points_b=total_b, holes=holes_out)