2026-07-16 14:38:42 +02:00
|
|
|
"""
|
|
|
|
|
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).
|
|
|
|
|
|
2026-07-19 11:41:57 +02:00
|
|
|
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).
|
2026-07-16 14:38:42 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-07-17 21:40:42 +02:00
|
|
|
from fastapi import APIRouter, Depends
|
2026-07-16 14:38:42 +02:00
|
|
|
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
|
2026-07-17 21:40:42 +02:00
|
|
|
from ..errors import app_error, translate_db_errors
|
2026-07-16 14:38:42 +02:00
|
|
|
from ..handicap import parse_allowance_config, relative_strokes_for_match
|
2026-07-19 11:41:57 +02:00
|
|
|
from ..team_authz import user_is_match_participant
|
2026-07-16 14:38:42 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 22:21:22 +02:00
|
|
|
async def recompute_and_cache_match_state(conn, match_id: str) -> None:
|
2026-07-16 14:38:42 +02:00
|
|
|
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))
|
|
|
|
|
|
ackend for the join-code, leading_side, and projected-standings work is fully verified against a fresh scratch database (migrations 001–011, test_isolation.sql 12/12, and live end-to-end checks: join-code generation/uniqueness, code resolution, visibility bypass on both the tournament view and registration — including case-insensitivity and rejection of a wrong code — plus a full hole-by-hole match simulation confirming leading_side/status_text stay in sync through "1 UP" → "AS" → a decided "9&7", with the leaderboard's projected points matching at each stage: 1.0/0.0 while A led, 0.5/0.5 at all-square, and settling to equal actual/projected once decided).
Ready to deploy to the real system:
Migration: 011_join_code_and_leading_side.sql against real teecup_db (adds tournament.join_code — backfills existing tournaments with generated codes — and match.leading_side, plus the public_tournament_by_code() function).
Redeploy: teecup_api only (no frontend changes yet — those come next).
2026-07-19 09:23:35 +02:00
|
|
|
# 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
|
|
|
|
|
|
2026-07-16 14:38:42 +02:00
|
|
|
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(
|
ackend for the join-code, leading_side, and projected-standings work is fully verified against a fresh scratch database (migrations 001–011, test_isolation.sql 12/12, and live end-to-end checks: join-code generation/uniqueness, code resolution, visibility bypass on both the tournament view and registration — including case-insensitivity and rejection of a wrong code — plus a full hole-by-hole match simulation confirming leading_side/status_text stay in sync through "1 UP" → "AS" → a decided "9&7", with the leaderboard's projected points matching at each stage: 1.0/0.0 while A led, 0.5/0.5 at all-square, and settling to equal actual/projected once decided).
Ready to deploy to the real system:
Migration: 011_join_code_and_leading_side.sql against real teecup_db (adds tournament.join_code — backfills existing tournaments with generated codes — and match.leading_side, plus the public_tournament_by_code() function).
Redeploy: teecup_api only (no frontend changes yet — those come next).
2026-07-19 09:23:35 +02:00
|
|
|
"""
|
|
|
|
|
UPDATE match SET status_text = $1, points_side_a = $2, points_side_b = $3,
|
|
|
|
|
leading_side = $4
|
|
|
|
|
WHERE id = $5
|
|
|
|
|
""",
|
2026-07-16 14:38:42 +02:00
|
|
|
state.describe(),
|
|
|
|
|
points_a,
|
|
|
|
|
points_b,
|
ackend for the join-code, leading_side, and projected-standings work is fully verified against a fresh scratch database (migrations 001–011, test_isolation.sql 12/12, and live end-to-end checks: join-code generation/uniqueness, code resolution, visibility bypass on both the tournament view and registration — including case-insensitivity and rejection of a wrong code — plus a full hole-by-hole match simulation confirming leading_side/status_text stay in sync through "1 UP" → "AS" → a decided "9&7", with the leaderboard's projected points matching at each stage: 1.0/0.0 while A led, 0.5/0.5 at all-square, and settling to equal actual/projected once decided).
Ready to deploy to the real system:
Migration: 011_join_code_and_leading_side.sql against real teecup_db (adds tournament.join_code — backfills existing tournaments with generated codes — and match.leading_side, plus the public_tournament_by_code() function).
Redeploy: teecup_api only (no frontend changes yet — those come next).
2026-07-19 09:23:35 +02:00
|
|
|
leading_side,
|
2026-07-16 14:38:42 +02:00
|
|
|
match_id,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
await conn.execute(
|
ackend for the join-code, leading_side, and projected-standings work is fully verified against a fresh scratch database (migrations 001–011, test_isolation.sql 12/12, and live end-to-end checks: join-code generation/uniqueness, code resolution, visibility bypass on both the tournament view and registration — including case-insensitivity and rejection of a wrong code — plus a full hole-by-hole match simulation confirming leading_side/status_text stay in sync through "1 UP" → "AS" → a decided "9&7", with the leaderboard's projected points matching at each stage: 1.0/0.0 while A led, 0.5/0.5 at all-square, and settling to equal actual/projected once decided).
Ready to deploy to the real system:
Migration: 011_join_code_and_leading_side.sql against real teecup_db (adds tournament.join_code — backfills existing tournaments with generated codes — and match.leading_side, plus the public_tournament_by_code() function).
Redeploy: teecup_api only (no frontend changes yet — those come next).
2026-07-19 09:23:35 +02:00
|
|
|
"UPDATE match SET status_text = $1, leading_side = $2 WHERE id = $3",
|
2026-07-16 14:38:42 +02:00
|
|
|
state.describe(),
|
ackend for the join-code, leading_side, and projected-standings work is fully verified against a fresh scratch database (migrations 001–011, test_isolation.sql 12/12, and live end-to-end checks: join-code generation/uniqueness, code resolution, visibility bypass on both the tournament view and registration — including case-insensitivity and rejection of a wrong code — plus a full hole-by-hole match simulation confirming leading_side/status_text stay in sync through "1 UP" → "AS" → a decided "9&7", with the leaderboard's projected points matching at each stage: 1.0/0.0 while A led, 0.5/0.5 at all-square, and settling to equal actual/projected once decided).
Ready to deploy to the real system:
Migration: 011_join_code_and_leading_side.sql against real teecup_db (adds tournament.join_code — backfills existing tournaments with generated codes — and match.leading_side, plus the public_tournament_by_code() function).
Redeploy: teecup_api only (no frontend changes yet — those come next).
2026-07-19 09:23:35 +02:00
|
|
|
leading_side,
|
2026-07-16 14:38:42 +02:00
|
|
|
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(
|
|
|
|
|
"""
|
2026-07-19 11:41:57 +02:00
|
|
|
SELECT m.points_side_a::float AS points_side_a,
|
2026-07-16 14:38:42 +02:00
|
|
|
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:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
|
2026-07-16 14:49:42 +02:00
|
|
|
if match["points_side_a"] is not None:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.")
|
2026-07-16 14:38:42 +02:00
|
|
|
if match["scoring_mode"] != "stroke":
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(
|
|
|
|
|
400,
|
|
|
|
|
"WRONG_SCORING_MODE",
|
|
|
|
|
"Denne økten bruker hull-resultat-modus, ikke slagregistrering.",
|
2026-07-16 14:38:42 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
played = _played_hole_numbers(match["hole_config"])
|
|
|
|
|
if body.hole_number not in played:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")
|
2026-07-16 14:38:42 +02:00
|
|
|
|
|
|
|
|
is_individual = match["format"] in _INDIVIDUAL_FORMATS
|
|
|
|
|
if is_individual and body.match_participant_id is None:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(
|
|
|
|
|
400,
|
|
|
|
|
"WRONG_PARTICIPANT_MODE",
|
|
|
|
|
"Dette formatet krever match_participant_id (individuell ball).",
|
2026-07-16 14:38:42 +02:00
|
|
|
)
|
|
|
|
|
if not is_individual and body.match_participant_id is not None:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(
|
|
|
|
|
400,
|
|
|
|
|
"WRONG_PARTICIPANT_MODE",
|
|
|
|
|
"Dette formatet bruker delt ball -- oppgi ikke match_participant_id.",
|
2026-07-16 14:38:42 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke i denne matchen.")
|
2026-07-16 14:38:42 +02:00
|
|
|
if participant["team_side"] != body.team_side:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(
|
|
|
|
|
400, "MISMATCHED_SIDE", "match_participant_id tilhører ikke angitt side."
|
2026-07-16 14:38:42 +02:00
|
|
|
)
|
|
|
|
|
|
2026-07-19 11:41:57 +02:00
|
|
|
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).",
|
|
|
|
|
)
|
2026-07-16 14:38:42 +02:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-18 22:21:22 +02:00
|
|
|
await recompute_and_cache_match_state(conn, match_id)
|
2026-07-16 14:38:42 +02:00
|
|
|
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(
|
|
|
|
|
"""
|
2026-07-19 11:41:57 +02:00
|
|
|
SELECT m.points_side_a::float AS points_side_a,
|
2026-07-16 14:38:42 +02:00
|
|
|
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:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
|
2026-07-16 14:49:42 +02:00
|
|
|
if match["points_side_a"] is not None:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.")
|
2026-07-16 14:38:42 +02:00
|
|
|
if match["scoring_mode"] != "hole_result":
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(
|
|
|
|
|
400,
|
|
|
|
|
"WRONG_SCORING_MODE",
|
|
|
|
|
"Denne økten bruker slagregistrering, ikke hull-resultat-modus.",
|
2026-07-16 14:38:42 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
played = _played_hole_numbers(match["hole_config"])
|
|
|
|
|
if body.hole_number not in played:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")
|
2026-07-16 14:38:42 +02:00
|
|
|
|
2026-07-19 11:41:57 +02:00
|
|
|
# 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):
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(
|
2026-07-19 11:41:57 +02:00
|
|
|
403,
|
|
|
|
|
"NOT_MATCH_PARTICIPANT",
|
|
|
|
|
"Du er ikke en av deltakerne i denne matchen (eller organisasjonsadministrator).",
|
2026-07-16 14:38:42 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-18 22:21:22 +02:00
|
|
|
await recompute_and_cache_match_state(conn, match_id)
|
2026-07-16 14:38:42 +02:00
|
|
|
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]
|
2026-07-18 17:47:33 +02:00
|
|
|
# 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
|
2026-07-16 14:38:42 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
_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:
|
2026-07-17 21:40:42 +02:00
|
|
|
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
|
2026-07-16 14:38:42 +02:00
|
|
|
results = await _compute_hole_results(conn, match_id, match)
|
|
|
|
|
|
2026-07-18 17:47:33 +02:00
|
|
|
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
|
|
|
|
|
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
|
|
|
|
|
FROM match_hole_result WHERE match_id = $1 ORDER BY hole_number
|
|
|
|
|
""",
|
|
|
|
|
match_id,
|
|
|
|
|
)
|
|
|
|
|
hole_result_entries = [HoleResultOut(**dict(r)) for r in rows]
|
|
|
|
|
|
2026-07-16 14:38:42 +02:00
|
|
|
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(
|
2026-07-18 17:47:33 +02:00
|
|
|
stroke_entries=stroke_entries,
|
|
|
|
|
hole_result_entries=hole_result_entries,
|
2026-07-16 14:38:42 +02:00
|
|
|
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,
|
|
|
|
|
)
|