""" 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 ..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 class HoleScoreOut(BaseModel): hole_number: int team_side: str match_participant_id: str | None gross_strokes: int selected_participant_id: str | None = None @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).", ) 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, selected_participant_id::text AS selected_participant_id """, 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, 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() 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 """, organization_id, match_id, body.team_side, body.hole_number, body.gross_strokes, body.selected_participant_id, ) 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, 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 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] # 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 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] 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) # --------------------------------------------------------------------------- # 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)