teecup/app/routers/scoring.py
Erol Haagenrud c521641c2e Match-lås-fiksen er ferdig og verifisert. En liten, kirurgisk endring i app/routers/scoring.py: begge skrive-endepunktene (submit_hole_score, submit_hole_result) sjekker nå match.points_side_a IS NOT NULL (allerede et pålitelig signal for "avgjort", ingen ny kolonne/migrasjon nødvendig) og avviser med 409 før noen upsert kjøres — både for nye hull og korrigering av allerede talte hull.
Alle 5 punktene i planen bestått mot scratch-databasen:

Gjenskapte en avgjort match ("10&8 (A)")
Nytt hull (14) på avgjort match → 409 ✓
Korrigering av allerede talt hull (1) → 409 ✓
GET scorecard fortsatt leselig for avgjort match ✓
Fersk, ikke-avgjort match tar fortsatt imot hull normalt ✓
2026-07-16 14:49:42 +02:00

451 lines
17 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: samme "rostret på laget"-grense som matches.py (team_authz.py),
IKKE kapteins-only ennå (FEATURE_BACKLOG: åpent spørsmål).
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.
"""
import json
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from handicap_engine import HoleResult, allocate_over_played_holes, compute_match_state
from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..db import org_connection
from ..errors import translate_db_errors
from ..handicap import parse_allowance_config, relative_strokes_for_match
from ..team_authz import user_may_act_for_team
router = APIRouter()
_INDIVIDUAL_FORMATS = {"singles", "fourball"}
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_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
# '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]
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
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))
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 WHERE id = $4",
state.describe(),
points_a,
points_b,
match_id,
)
else:
await conn.execute(
"UPDATE match SET status_text = $1 WHERE id = $2",
state.describe(),
match_id,
)
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)
class HoleScoreOut(BaseModel):
hole_number: int
team_side: str
match_participant_id: str | None
gross_strokes: 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 = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> HoleScoreOut:
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.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 HTTPException(status.HTTP_404_NOT_FOUND, detail="Matchen finnes ikke.")
if match["points_side_a"] is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="Matchen er avgjort og kan ikke lenger endres.",
)
if match["scoring_mode"] != "stroke":
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Denne økten bruker hull-resultat-modus, ikke slagregistrering.",
)
played = _played_hole_numbers(match["hole_config"])
if body.hole_number not in played:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail="Hullnummeret er utenfor øktens spilte omfang."
)
is_individual = match["format"] in _INDIVIDUAL_FORMATS
if is_individual and body.match_participant_id is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Dette formatet krever match_participant_id (individuell ball).",
)
if not is_individual and body.match_participant_id is not None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Dette formatet bruker delt ball -- oppgi ikke match_participant_id.",
)
expected_team_id = match["team_a_id"] if body.team_side == "a" else match["team_b_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 HTTPException(
status.HTTP_404_NOT_FOUND, detail="Deltakeren finnes ikke i denne matchen."
)
if participant["team_side"] != body.team_side:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail="match_participant_id tilhører ikke angitt side."
)
if not await user_may_act_for_team(conn, expected_team_id, user.user_id):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget.")
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()
RETURNING hole_number, team_side::text AS team_side,
match_participant_id::text AS match_participant_id, gross_strokes
""",
organization_id,
match_id,
body.team_side,
body.match_participant_id,
body.hole_number,
body.gross_strokes,
)
else:
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, NULL, $4, $5)
ON CONFLICT (match_id, team_side, hole_number) WHERE match_participant_id IS NULL
DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes, updated_at = now()
RETURNING hole_number, team_side::text AS team_side,
match_participant_id::text AS match_participant_id, gross_strokes
""",
organization_id,
match_id,
body.team_side,
body.hole_number,
body.gross_strokes,
)
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]$")
class HoleResultOut(BaseModel):
hole_number: int
winning_side: str | None
@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 = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> HoleResultOut:
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.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 HTTPException(status.HTTP_404_NOT_FOUND, detail="Matchen finnes ikke.")
if match["points_side_a"] is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="Matchen er avgjort og kan ikke lenger endres.",
)
if match["scoring_mode"] != "hole_result":
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Denne økten bruker slagregistrering, ikke hull-resultat-modus.",
)
played = _played_hole_numbers(match["hole_config"])
if body.hole_number not in played:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail="Hullnummeret er utenfor øktens spilte omfang."
)
# Begge lag kan rapportere et hull-resultat (hvilken som helst side kan
# vinne/tape/dele) -- brukeren må være rostret på ETT av de to lagene.
if not (
await user_may_act_for_team(conn, match["team_a_id"], user.user_id)
or await user_may_act_for_team(conn, match["team_b_id"], user.user_id)
):
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på noen av lagene i denne matchen."
)
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
RETURNING hole_number, winning_side::text AS winning_side
""",
organization_id,
match_id,
body.hole_number,
body.winning_side,
)
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]
_RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"}
@router.get(
"/orgs/{organization_id}/matches/{match_id}/scorecard",
response_model=Scorecard,
)
async def get_scorecard(
match_id: str,
organization_id: str = Depends(get_authorized_org),
) -> Scorecard:
async with org_connection(organization_id) as conn:
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
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Matchen finnes ikke.")
results = await _compute_hole_results(conn, match_id, match)
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(
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,
)