""" Individuelle/flerrunde-turneringer (ADR-037): grunnstruktur oppå `040_individual_tournaments.sql`. Kun for `tournament.format_type = 'individual'` -- lagturneringer (`format_type = 'team'`, standard) er uendret, bygget/vedlikeholdt i tournaments.py/matches.py/scoring.py. Ett flatt felt av spillere (`tournament_participant`), over én eller flere runder (`tournament_round`, samme rolle som `session` for lagturneringer). Rå bruttoslag lagres per hull (`tournament_round_hole`, kilde-sannhet); et ferdig utregnet resultat caches per deltaker per runde (`tournament_round_score`) -- samme "regn på nytt ved hver innsending, SQL summerer bare det cachede ved lesing"-mønster som `match.status_text`/ `points_side_a/b`. Sammenlagt over flere runder summeres VED LESING i leaderboardet, ikke i en egen tredje cache-tabell (ADR-037 Beslutning C). Autorisasjon (strammet inn 2026-07-30, se `user_is_own_tournament_ participant` i `app/team_authz.py`): selve SCORE-REGISTRERINGEN (`update_hole`) krever nå at brukeren ER deltakeren (via `player.user_id`) ELLER org-eier/admin -- samme mønster som `user_is_match_participant` strammet inn lagturneringer i ADR-023. Runde-/deltaker-OPPSETT (opprett/ slett runde, legg til/fjern turnering-/rundedeltaker) forblir bevisst på vanlig org-medlemsnivå (`get_authorized_org`) -- samme presedens som `session`-opprettelse og `team_roster`-tilføyelse i tournaments.py, som heller aldri har vært captain-/admin-gatet. Kun VALGET AV HVEM SOM SPILLER og selve SCORINGEN er gatet strengere noe sted i appen, aldri det generelle oppsettet. Københavner (2026-07-30): scoring_method='copenhagen' -- flatt felt av NØYAKTIG 3 spillere, 6 poeng delt per hull etter relativ NETTO-rangering (handicap_engine.py sin compute_copenhagen_detail). Eneste scoring_method her som krever paring PÅ TVERS av spillere per hull -- _recompute_ copenhagen_points regner derfor om for ALLE tre samtidig (kalt fra update_hole), ikke ett-om-gangen slik gross/net/stableford ellers gjøres. Bevisst UTENFOR omfang (se ADR-037): High-low-high/Robbins/Try all/ Flaggturnering (High-low-high/Robbins/Try all krever to SIDER, ikke et flatt felt -- hører hjemme i org-lagturneringer i stedet, se matches.py/ scoring.py) og Order of Merit (sesong-sammenlagt på tvers av FLERE turneringer). """ from __future__ import annotations from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from handicap_engine import ( allocate_over_played_holes, bbb_points_for_hole, compute_bbb, compute_copenhagen_detail, course_handicap_raw, flag_result, round_half_up, stableford_total, stroke_play_gross_total, stroke_play_net_total, ) from ..auth import CurrentUser, get_authorized_org, get_current_user from ..db import org_connection from ..errors import app_error, translate_db_errors from ..team_authz import user_is_own_tournament_participant from .scoring import played_hole_numbers router = APIRouter() _SCORING_METHODS_REQUIRING_HANDICAP = {"stroke_net", "stableford", "copenhagen", "flag"} # ===================================================================== # Runder (tournament_round) # ===================================================================== class TournamentRoundCreate(BaseModel): sequence: int = Field(ge=1) name: str | None = None hole_config: str = Field(default="full_18", pattern="^(full_18|front_9|back_9)$") course_id: str scheduled_at: str | None = None # ISO 8601, tolkes av asyncpg/Pydantic ved behov tee_interval_minutes: int | None = Field(default=None, gt=0) start_hole: int = Field(default=1, ge=1, le=18) class TournamentRound(BaseModel): id: str tournament_id: str sequence: int name: str | None hole_config: str course_id: str course_name: str scheduled_at: str | None tee_interval_minutes: int | None start_hole: int _ROUND_COLUMNS = """ tr.id::text, tr.tournament_id::text, tr.sequence, tr.name, tr.hole_config::text, tr.course_id::text, c.name AS course_name, tr.scheduled_at::text, tr.tee_interval_minutes, tr.start_hole """ @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds", response_model=TournamentRound, status_code=201, ) async def create_round( tournament_id: str, body: TournamentRoundCreate, organization_id: str = Depends(get_authorized_org), ) -> TournamentRound: async with org_connection(organization_id) as conn, translate_db_errors(): tournament = await conn.fetchrow( "SELECT format_type FROM tournament WHERE id = $1", tournament_id ) if tournament is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") if tournament["format_type"] != "individual": raise app_error( 400, "VALIDATION_FAILED", "Runder kan kun opprettes for individuelle turneringer." ) row = await conn.fetchrow( f""" WITH inserted AS ( INSERT INTO tournament_round (organization_id, tournament_id, sequence, name, hole_config, course_id, scheduled_at, tee_interval_minutes, start_hole) VALUES ($1, $2, $3, $4, $5::hole_scope, $6, $7, $8, $9) RETURNING * ) SELECT {_ROUND_COLUMNS} FROM inserted tr JOIN course c ON c.id = tr.course_id """, organization_id, tournament_id, body.sequence, body.name, body.hole_config, body.course_id, body.scheduled_at, body.tee_interval_minutes, body.start_hole, ) return TournamentRound(**dict(row)) @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds", response_model=list[TournamentRound], ) async def list_rounds( tournament_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[TournamentRound]: async with org_connection(organization_id) as conn: exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id) if exists is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") rows = await conn.fetch( f""" SELECT {_ROUND_COLUMNS} FROM tournament_round tr JOIN course c ON c.id = tr.course_id WHERE tr.tournament_id = $1 ORDER BY tr.sequence """, tournament_id, ) return [TournamentRound(**dict(r)) for r in rows] @router.delete("/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}", status_code=204) async def delete_round( tournament_id: str, round_id: str, organization_id: str = Depends(get_authorized_org), ) -> None: async with org_connection(organization_id) as conn: has_participants = await conn.fetchval( "SELECT EXISTS(SELECT 1 FROM tournament_round_participant WHERE tournament_round_id = $1)", round_id, ) if has_participants: raise app_error( 409, "NOT_EMPTY", "Runden har allerede deltakere -- fjern dem først, eller la runden stå.", ) deleted = await conn.fetchval( "DELETE FROM tournament_round WHERE id = $1 AND tournament_id = $2 RETURNING id", round_id, tournament_id, ) if deleted is None: raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") # ===================================================================== # Turnering-deltakere (tournament_participant -- flatt felt, intet lag) # ===================================================================== class TournamentParticipantCreate(BaseModel): player_id: str class TournamentParticipantOut(BaseModel): id: str player_id: str player_name: str handicap_index_snapshot: float | None @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/participants", response_model=TournamentParticipantOut, status_code=201, ) async def add_tournament_participant( tournament_id: str, body: TournamentParticipantCreate, organization_id: str = Depends(get_authorized_org), ) -> TournamentParticipantOut: async with org_connection(organization_id) as conn, translate_db_errors(): player = await conn.fetchrow( "SELECT display_name, handicap_index FROM player WHERE id = $1", body.player_id ) if player is None: raise app_error(404, "NOT_FOUND", "Spilleren finnes ikke.") row = await conn.fetchrow( """ INSERT INTO tournament_participant (organization_id, tournament_id, player_id, handicap_index_snapshot) VALUES ($1, $2, $3, $4) RETURNING id::text """, organization_id, tournament_id, body.player_id, player["handicap_index"], ) return TournamentParticipantOut( id=row["id"], player_id=body.player_id, player_name=player["display_name"], handicap_index_snapshot=player["handicap_index"], ) @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/participants", response_model=list[TournamentParticipantOut], ) async def list_tournament_participants( tournament_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[TournamentParticipantOut]: async with org_connection(organization_id) as conn: exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id) if exists is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") rows = await conn.fetch( """ SELECT tp.id::text AS id, tp.player_id::text AS player_id, p.display_name AS player_name, tp.handicap_index_snapshot::float AS handicap_index_snapshot FROM tournament_participant tp JOIN player p ON p.id = tp.player_id WHERE tp.tournament_id = $1 ORDER BY p.display_name """, tournament_id, ) return [TournamentParticipantOut(**dict(r)) for r in rows] @router.delete( "/orgs/{organization_id}/tournaments/{tournament_id}/participants/{participant_id}", status_code=204, ) async def remove_tournament_participant( tournament_id: str, participant_id: str, organization_id: str = Depends(get_authorized_org), ) -> None: async with org_connection(organization_id) as conn, translate_db_errors(): deleted = await conn.fetchval( "DELETE FROM tournament_participant WHERE id = $1 AND tournament_id = $2 RETURNING id", participant_id, tournament_id, ) if deleted is None: raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.") # ===================================================================== # Deltaker PÅ ÉN RUNDE (tee + cachet handicap for akkurat den runden) # ===================================================================== class RoundParticipantCreate(BaseModel): tournament_participant_id: str tee_id: str class RoundParticipantOut(BaseModel): id: str tournament_participant_id: str player_name: str tee_id: str tee_name: str course_handicap: int | None playing_handicap: int | None async def _compute_round_participant_handicap(conn, round_participant_id: str) -> None: """Regner course_handicap/playing_handicap for ÉN rundedeltaker. v1 har ingen allowance-prosent for individuelle turneringer (ulikt lagturneringenes AllowanceStrategy-familie, som er bygget for et relativt to-siders oppgjør -- ikke relevant her) -- playing_handicap er derfor alltid identisk med (avrundet) course_handicap. Setter INGENTING hvis spilleren mangler handicap_index_snapshot eller en matchende tee_rating -- kalleren har allerede validert dette FØR innsetting når scoring_method krever det (se add_round_participant). """ row = await conn.fetchrow( """ SELECT tp.handicap_index_snapshot::float AS handicap_index_snapshot, rating.course_rating::float AS course_rating, rating.slope_rating::float AS slope_rating, rating.par AS par FROM tournament_round_participant trp JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id JOIN player p ON p.id = tp.player_id JOIN tee_rating rating ON rating.tee_id = trp.tee_id AND rating.scope = 'full_18' AND rating.gender = p.gender WHERE trp.id = $1 """, round_participant_id, ) if row is None or row["handicap_index_snapshot"] is None: return ch = round_half_up( course_handicap_raw( row["handicap_index_snapshot"], row["slope_rating"], row["course_rating"], row["par"] ) ) await conn.execute( "UPDATE tournament_round_participant SET course_handicap = $1, playing_handicap = $1 WHERE id = $2", ch, round_participant_id, ) @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants", response_model=RoundParticipantOut, status_code=201, ) async def add_round_participant( tournament_id: str, round_id: str, body: RoundParticipantCreate, organization_id: str = Depends(get_authorized_org), ) -> RoundParticipantOut: async with org_connection(organization_id) as conn, translate_db_errors(): round_row = await conn.fetchrow( """ SELECT tr.course_id::text AS course_id, t.scoring_method FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id WHERE tr.id = $1 AND tr.tournament_id = $2 """, round_id, tournament_id, ) if round_row is None: raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") tee_course_id = await conn.fetchval("SELECT course_id::text FROM tee WHERE id = $1", body.tee_id) if tee_course_id is None or tee_course_id != round_row["course_id"]: raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke rundens bane.") requires_handicap = round_row["scoring_method"] in _SCORING_METHODS_REQUIRING_HANDICAP if requires_handicap: player_row = await conn.fetchrow( """ SELECT tp.handicap_index_snapshot, p.gender FROM tournament_participant tp JOIN player p ON p.id = tp.player_id WHERE tp.id = $1 """, body.tournament_participant_id, ) if player_row is None: raise app_error(404, "NOT_FOUND", "Turnering-deltakeren finnes ikke.") if player_row["handicap_index_snapshot"] is None: raise app_error( 400, "VALIDATION_FAILED", "Denne spilleren mangler handicap-indeks -- påkrevd for " "nettoslagspill/Stableford.", ) if player_row["gender"] is None: raise app_error( 400, "VALIDATION_FAILED", "Denne spilleren mangler registrert kjønn -- trengs for å finne riktig tee-rating.", ) has_rating = await conn.fetchval( "SELECT EXISTS(SELECT 1 FROM tee_rating WHERE tee_id = $1 AND scope = 'full_18' AND gender = $2)", body.tee_id, player_row["gender"], ) if not has_rating: kjonn_tekst = "dame" if player_row["gender"] == "f" else "herre" raise app_error( 400, "VALIDATION_FAILED", f"Dette utslaget har ingen {kjonn_tekst}-rating -- velg et annet." ) row = await conn.fetchrow( """ WITH inserted AS ( INSERT INTO tournament_round_participant (organization_id, tournament_round_id, tournament_participant_id, tee_id) VALUES ($1, $2, $3, $4) RETURNING id ) SELECT inserted.id::text AS id FROM inserted """, organization_id, round_id, body.tournament_participant_id, body.tee_id, ) round_participant_id = row["id"] await _compute_round_participant_handicap(conn, round_participant_id) out = await conn.fetchrow( """ SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id, p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name, trp.course_handicap, trp.playing_handicap FROM tournament_round_participant trp JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id JOIN player p ON p.id = tp.player_id JOIN tee ON tee.id = trp.tee_id WHERE trp.id = $1 """, round_participant_id, ) return RoundParticipantOut(**dict(out)) @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants", response_model=list[RoundParticipantOut], ) async def list_round_participants( tournament_id: str, round_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[RoundParticipantOut]: async with org_connection(organization_id) as conn: rows = await conn.fetch( """ SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id, p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name, trp.course_handicap, trp.playing_handicap FROM tournament_round_participant trp JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id JOIN player p ON p.id = tp.player_id JOIN tee ON tee.id = trp.tee_id WHERE trp.tournament_round_id = $1 ORDER BY p.display_name """, round_id, ) return [RoundParticipantOut(**dict(r)) for r in rows] @router.delete( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants/{round_participant_id}", status_code=204, ) async def remove_round_participant( tournament_id: str, round_id: str, round_participant_id: str, organization_id: str = Depends(get_authorized_org), ) -> None: async with org_connection(organization_id) as conn: deleted = await conn.fetchval( "DELETE FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2 RETURNING id", round_participant_id, round_id, ) if deleted is None: raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.") # ===================================================================== # Hull-for-hull-score (tournament_round_hole -- kilde-sannhet) # ===================================================================== class HoleUpdate(BaseModel): gross_strokes: int = Field(ge=1, le=20) class RoundHoleOut(BaseModel): hole_number: int par: int stroke_index: int gross_strokes: int | None strokes_received: int | None async def _recompute_round_score(conn, round_id: str, round_participant_id: str, organization_id: str) -> None: """Regner ferdig totalsum (brutto/netto/Stableford) på nytt for ÉN deltaker i ÉN runde, og cacher resultatet -- kalt etter HVER hull-innsending (samme mønster som recompute_and_cache_match_state). """ ctx = await conn.fetchrow( """ SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config, trp.tournament_participant_id::text AS tournament_participant_id, trp.playing_handicap FROM tournament_round_participant trp JOIN tournament_round tr ON tr.id = trp.tournament_round_id WHERE trp.id = $1 """, round_participant_id, ) holes = await conn.fetch( """ SELECT h.hole_number, h.par, h.stroke_index, trh.gross_strokes FROM hole h LEFT JOIN tournament_round_hole trh ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number WHERE h.course_id = $2 ORDER BY h.hole_number """, round_participant_id, ctx["course_id"], ) played = [h for h in holes if h["gross_strokes"] is not None] holes_played = len(played) gross_total = stroke_play_gross_total([h["gross_strokes"] for h in played]) if played else None net_total = None stableford_points = None if played and ctx["playing_handicap"] is not None: all_18_si = [h["stroke_index"] for h in holes] played_numbers = [h["hole_number"] for h in played] received = allocate_over_played_holes(ctx["playing_handicap"], all_18_si, played_numbers) gross_list = [h["gross_strokes"] for h in played] pars = [h["par"] for h in played] net_total = stroke_play_net_total(gross_list, received) stableford_points = stableford_total(pars, gross_list, received) await conn.execute( """ INSERT INTO tournament_round_score (organization_id, tournament_round_id, tournament_participant_id, holes_played, gross_total, net_total, stableford_points, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, now()) ON CONFLICT (tournament_round_id, tournament_participant_id) DO UPDATE SET holes_played = $4, gross_total = $5, net_total = $6, stableford_points = $7, updated_at = now() """, organization_id, round_id, ctx["tournament_participant_id"], holes_played, gross_total, net_total, stableford_points, ) async def _recompute_copenhagen_points(conn, round_id: str, organization_id: str) -> None: """Københavner (2026-07-30): 6 poeng deles per hull mellom NØYAKTIG 3 deltakere basert på relativ NETTO-rangering -- kan derfor IKKE regnes per deltaker isolert slik gross/net/stableford ellers gjøres (se modulens docstring, "bevisst utenfor omfang" er nå innhentet for dette ene formatet). Regner om for ALLE deltakerne i runden samtidig, kalt etter HVER hull-innsending når scoring_method='copenhagen' -- ETTER at _recompute_round_score allerede har kjørt for alle tre (sikrer at tournament_round_score-raden finnes for alle, ikke bare den som nettopp scoret).""" participants = await conn.fetch( """ SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id, trp.playing_handicap FROM tournament_round_participant trp WHERE trp.tournament_round_id = $1 """, round_id, ) if len(participants) != 3: return # Ikke komplett ennå -- copenhagen_points_for_hole krever nøyaktig 3 course_id = await conn.fetchval("SELECT course_id::text FROM tournament_round WHERE id = $1", round_id) all_18 = await conn.fetch( "SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", course_id ) all_18_si = [h["stroke_index"] for h in all_18] net_by_hole: dict[int, list[tuple[str, int]]] = {} for p in participants: if p["playing_handicap"] is None: continue holes = await conn.fetch( "SELECT hole_number, gross_strokes FROM tournament_round_hole " "WHERE tournament_round_participant_id = $1 AND gross_strokes IS NOT NULL " "ORDER BY hole_number", p["id"], ) if not holes: continue played_numbers = [h["hole_number"] for h in holes] allocation = allocate_over_played_holes(p["playing_handicap"], all_18_si, played_numbers) for h, received in zip(holes, allocation): net = h["gross_strokes"] - received net_by_hole.setdefault(h["hole_number"], []).append((p["tournament_participant_id"], net)) scores_by_hole = [net_by_hole.get(h["hole_number"], []) for h in all_18] totals, _log = compute_copenhagen_detail(scores_by_hole, higher_is_better=False) for p in participants: await conn.execute( """ INSERT INTO tournament_round_score (organization_id, tournament_round_id, tournament_participant_id, copenhagen_points, updated_at) VALUES ($1, $2, $3, $4, now()) ON CONFLICT (tournament_round_id, tournament_participant_id) DO UPDATE SET copenhagen_points = $4, updated_at = now() """, organization_id, round_id, p["tournament_participant_id"], totals.get(p["tournament_participant_id"]), ) async def _recompute_bbb_points(conn, round_id: str, organization_id: str) -> None: """Bingo Bango Bongo (2026-07-30): poeng er en per-HULL-fakta på tvers av ALLE deltakerne i runden, ikke per deltaker isolert -- regner om for alle samtidig, kalt etter HVER bbb-hull-registrering. Krever INGEN handicap (ulikt Københavner) -- rene observasjons-poeng.""" participants = await conn.fetch( "SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id " "FROM tournament_round_participant trp WHERE trp.tournament_round_id = $1", round_id, ) if not participants: return tournament = await conn.fetchrow( "SELECT t.bbb_sweep_bonus_enabled FROM tournament_round tr " "JOIN tournament t ON t.id = tr.tournament_id WHERE tr.id = $1", round_id, ) rows = await conn.fetch( """ SELECT hole_number, bingo_participant_id::text AS bingo_participant_id, bango_participant_id::text AS bango_participant_id, bongo_participant_id::text AS bongo_participant_id FROM tournament_round_bbb_hole WHERE tournament_round_id = $1 ORDER BY hole_number """, round_id, ) hole_log = [(r["bingo_participant_id"], r["bango_participant_id"], r["bongo_participant_id"]) for r in rows] totals = compute_bbb(hole_log, tournament["bbb_sweep_bonus_enabled"]) for p in participants: await conn.execute( """ INSERT INTO tournament_round_score (organization_id, tournament_round_id, tournament_participant_id, bbb_points, updated_at) VALUES ($1, $2, $3, $4, now()) ON CONFLICT (tournament_round_id, tournament_participant_id) DO UPDATE SET bbb_points = $4, updated_at = now() """, organization_id, round_id, p["tournament_participant_id"], totals.get(p["tournament_participant_id"]), ) class BBBHoleUpdate(BaseModel): bingo_participant_id: str | None = None bango_participant_id: str | None = None bongo_participant_id: str | None = None class BBBHoleOut(BaseModel): hole_number: int bingo_participant_id: str | None bango_participant_id: str | None bongo_participant_id: str | None @router.patch( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/bbb/{hole_number}", response_model=BBBHoleOut, ) async def update_bbb_hole( tournament_id: str, round_id: str, hole_number: int, body: BBBHoleUpdate, organization_id: str = Depends(get_authorized_org), ) -> BBBHoleOut: """Runde-/deltaker-oppsett-nivå autorisasjon (get_authorized_org, org- medlem), samme presedens som resten av dette modulens oppsett -- ikke strammet inn til "kun deltakeren selv" slik update_hole er, siden bbb-registrering er en OBSERVASJON om alle deltakerne (hvem som var først på green osv.), ikke én persons egen score.""" async with org_connection(organization_id) as conn, translate_db_errors(): round_row = await conn.fetchrow( """ SELECT tr.hole_config::text AS hole_config, t.scoring_method, t.id::text AS tournament_id FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id WHERE tr.id = $1 """, round_id, ) if round_row is None or round_row["tournament_id"] != tournament_id: raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") if round_row["scoring_method"] != "bingo_bango_bongo": raise app_error(400, "VALIDATION_FAILED", "Denne turneringen bruker ikke Bingo Bango Bongo.") if hole_number not in played_hole_numbers(round_row["hole_config"]): raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor rundens hullomfang.") for pid in (body.bingo_participant_id, body.bango_participant_id, body.bongo_participant_id): if pid is None: continue belongs = await conn.fetchval( "SELECT 1 FROM tournament_participant WHERE id = $1 AND tournament_id = $2", pid, tournament_id, ) if not belongs: raise app_error(400, "VALIDATION_FAILED", "Valgt spiller hører ikke til denne turneringen.") row = await conn.fetchrow( """ INSERT INTO tournament_round_bbb_hole (organization_id, tournament_round_id, hole_number, bingo_participant_id, bango_participant_id, bongo_participant_id) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (tournament_round_id, hole_number) DO UPDATE SET bingo_participant_id = $4, bango_participant_id = $5, bongo_participant_id = $6 RETURNING hole_number, bingo_participant_id::text AS bingo_participant_id, bango_participant_id::text AS bango_participant_id, bongo_participant_id::text AS bongo_participant_id """, organization_id, round_id, hole_number, body.bingo_participant_id, body.bango_participant_id, body.bongo_participant_id, ) await _recompute_bbb_points(conn, round_id, organization_id) return BBBHoleOut(**dict(row)) @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/bbb", response_model=list[BBBHoleOut], ) async def list_bbb_holes( tournament_id: str, round_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[BBBHoleOut]: """Rå bingo/bango/bongo-VALG per hull (ikke de utledede poengene) -- samme begrunnelse som round.py sin frittstående-motpart.""" async with org_connection(organization_id) as conn: exists = await conn.fetchval( "SELECT id FROM tournament_round WHERE id = $1 AND tournament_id = $2", round_id, tournament_id ) if exists is None: raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") rows = await conn.fetch( """ SELECT hole_number, bingo_participant_id::text AS bingo_participant_id, bango_participant_id::text AS bango_participant_id, bongo_participant_id::text AS bongo_participant_id FROM tournament_round_bbb_hole WHERE tournament_round_id = $1 """, round_id, ) return [BBBHoleOut(**dict(r)) for r in rows] @router.patch( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" "/participants/{round_participant_id}/holes/{hole_number}", response_model=RoundHoleOut, ) async def update_hole( tournament_id: str, round_id: str, round_participant_id: str, hole_number: int, body: HoleUpdate, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> RoundHoleOut: async with org_connection(organization_id) as conn, translate_db_errors(): round_row = await conn.fetchrow( """ SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config, t.scoring_method FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id WHERE tr.id = $1 """, round_id, ) if round_row is None: raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") if hole_number not in played_hole_numbers(round_row["hole_config"]): raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor rundens hullomfang.") rp = await conn.fetchrow( """ SELECT trp.id, trp.tournament_participant_id::text AS tournament_participant_id FROM tournament_round_participant trp WHERE trp.id = $1 AND trp.tournament_round_id = $2 """, round_participant_id, round_id, ) if rp is None: raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.") if not await user_is_own_tournament_participant( conn, organization_id, rp["tournament_participant_id"], user.user_id ): raise app_error( 403, "NOT_TOURNAMENT_PARTICIPANT", "Du kan kun registrere score for deg selv (eller være organisasjonsadministrator).", ) hole = await conn.fetchrow( "SELECT par, stroke_index FROM hole WHERE course_id = $1 AND hole_number = $2", round_row["course_id"], hole_number, ) if hole is None: raise app_error( 400, "VALIDATION_FAILED", "Banen mangler hull-data for dette hullnummeret." ) await conn.execute( """ INSERT INTO tournament_round_hole (organization_id, tournament_round_participant_id, hole_number, gross_strokes, updated_at) VALUES ($1, $2, $3, $4, now()) ON CONFLICT (tournament_round_participant_id, hole_number) DO UPDATE SET gross_strokes = $4, updated_at = now() """, organization_id, round_participant_id, hole_number, body.gross_strokes, ) await _recompute_round_score(conn, round_id, round_participant_id, organization_id) if round_row["scoring_method"] == "copenhagen": # Kobenhavner-poeng avhenger av ALLE tre deltakernes netto på # hvert hull -- regn om for de to andre ogsa (sikrer at deres # tournament_round_score-rad finnes) FOR selve poeng-fordelingen. other_ids = await conn.fetch( "SELECT id::text AS id FROM tournament_round_participant " "WHERE tournament_round_id = $1 AND id != $2", round_id, round_participant_id, ) for other in other_ids: await _recompute_round_score(conn, round_id, other["id"], organization_id) await _recompute_copenhagen_points(conn, round_id, organization_id) strokes_received = None playing_handicap = await conn.fetchval( "SELECT playing_handicap FROM tournament_round_participant WHERE id = $1", round_participant_id ) if playing_handicap is not None: all_18 = await conn.fetch( "SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", round_row["course_id"], ) allocation = allocate_over_played_holes( playing_handicap, [h["stroke_index"] for h in all_18], [hole_number] ) strokes_received = allocation[0] return RoundHoleOut( hole_number=hole_number, par=hole["par"], stroke_index=hole["stroke_index"], gross_strokes=body.gross_strokes, strokes_received=strokes_received, ) @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" "/participants/{round_participant_id}/holes", response_model=list[RoundHoleOut], ) async def list_round_participant_holes( tournament_id: str, round_id: str, round_participant_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[RoundHoleOut]: async with org_connection(organization_id) as conn: round_row = await conn.fetchrow( "SELECT course_id::text AS course_id FROM tournament_round WHERE id = $1", round_id ) if round_row is None: raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") playing_handicap = await conn.fetchval( "SELECT playing_handicap FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2", round_participant_id, round_id, ) holes = await conn.fetch( """ SELECT h.hole_number, h.par, h.stroke_index, trh.gross_strokes FROM hole h LEFT JOIN tournament_round_hole trh ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number WHERE h.course_id = $2 ORDER BY h.hole_number """, round_participant_id, round_row["course_id"], ) allocation = ( allocate_over_played_holes( playing_handicap, [h["stroke_index"] for h in holes], [h["hole_number"] for h in holes] ) if playing_handicap is not None else [None] * len(holes) ) return [ RoundHoleOut( hole_number=h["hole_number"], par=h["par"], stroke_index=h["stroke_index"], gross_strokes=h["gross_strokes"], strokes_received=a, ) for h, a in zip(holes, allocation) ] # ===================================================================== # Leaderboard (summert VED LESING på tvers av alle runder, Beslutning C) # ===================================================================== class LeaderboardEntry(BaseModel): tournament_participant_id: str player_name: str rounds_played: int gross_total: int | None net_total: int | None stableford_total: int | None copenhagen_total: int | None bbb_total: int | None @router.get( # IKKE /leaderboard -- den stien er allerede tournaments.py sitt # LAG-leaderboard (points_side_a/b, forutsetter nøyaktig to lag). Samme # klasse feil som /rounds vs. /my-rounds tidligere i prosjektet -- unngått # her ved å gi den et eget, ikke-overlappende navn fra start. "/orgs/{organization_id}/tournaments/{tournament_id}/individual-leaderboard", response_model=list[LeaderboardEntry], ) async def individual_leaderboard( tournament_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[LeaderboardEntry]: async with org_connection(organization_id) as conn: tournament = await conn.fetchrow( "SELECT format_type, scoring_method FROM tournament WHERE id = $1", tournament_id ) if tournament is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") if tournament["format_type"] != "individual": raise app_error(400, "VALIDATION_FAILED", "Kun individuelle turneringer har dette leaderboardet.") rows = await conn.fetch( """ SELECT tp.id::text AS tournament_participant_id, p.display_name AS player_name, count(trs.id) FILTER (WHERE trs.holes_played > 0) AS rounds_played, SUM(trs.gross_total)::int AS gross_total, SUM(trs.net_total)::int AS net_total, SUM(trs.stableford_points)::int AS stableford_total, SUM(trs.copenhagen_points)::int AS copenhagen_total, SUM(trs.bbb_points)::int AS bbb_total FROM tournament_participant tp JOIN player p ON p.id = tp.player_id LEFT JOIN tournament_round_score trs ON trs.tournament_participant_id = tp.id WHERE tp.tournament_id = $1 GROUP BY tp.id, p.display_name """, tournament_id, ) entries = [LeaderboardEntry(**dict(r)) for r in rows] method = tournament["scoring_method"] if method == "stableford": entries.sort(key=lambda e: (e.stableford_total is None, -(e.stableford_total or 0))) elif method == "stroke_net": entries.sort(key=lambda e: (e.net_total is None, e.net_total or 0)) elif method == "copenhagen": # Flest poeng totalt vinner (kilden, spilletyper-og-spilleformer-2023.pdf s.4). entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0))) elif method == "bingo_bango_bongo": entries.sort(key=lambda e: (e.bbb_total is None, -(e.bbb_total or 0))) else: entries.sort(key=lambda e: (e.gross_total is None, e.gross_total or 0)) return entries # --------------------------------------------------------------------------- # Flaggturnering/Flag tournament (2026-07-30) -- egen, PER-RUNDE visning # (ikke summert på tvers av turneringen slik individual_leaderboard gjør # for de andre scoring_method-ene) -- Flag er strukturelt en # enkelt-rundes-konkurranse ("hvem kommer lengst DENNE runden"), ikke et # akkumulerbart poeng-/slagtall på tvers av flere runder. # --------------------------------------------------------------------------- class FlagParticipantResult(BaseModel): tournament_participant_id: str player_name: str holes_completed: int ran_out: bool strokes_remaining: int @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/flag-result", response_model=list[FlagParticipantResult], ) async def flag_round_result( tournament_id: str, round_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[FlagParticipantResult]: async with org_connection(organization_id) as conn: round_row = await conn.fetchrow( """ SELECT tr.id::text AS id, tr.hole_config::text AS hole_config, t.scoring_method FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id WHERE tr.id = $1 AND tr.tournament_id = $2 """, round_id, tournament_id, ) if round_row is None: raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") if round_row["scoring_method"] != "flag": raise app_error(400, "VALIDATION_FAILED", "Denne turneringen bruker ikke Flaggturnering.") play_order = played_hole_numbers(round_row["hole_config"]) participants = await conn.fetch( """ SELECT trp.id::text AS id, trp.course_handicap, tp.id::text AS tournament_participant_id, p.display_name AS player_name FROM tournament_round_participant trp JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id JOIN player p ON p.id = tp.player_id WHERE trp.tournament_round_id = $1 """, round_id, ) results: list[FlagParticipantResult] = [] for p in participants: if p["course_handicap"] is None: continue hole_rows = await conn.fetch( """ SELECT h.hole_number, h.par, trh.gross_strokes FROM hole h LEFT JOIN tournament_round_hole trh ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number WHERE h.course_id = (SELECT course_id FROM tournament_round WHERE id = $2) """, p["id"], round_id, ) by_hole = {r["hole_number"]: r for r in hole_rows} total_par = sum(by_hole[h]["par"] for h in play_order if h in by_hole) gross_prefix = [] for h in play_order: row = by_hole.get(h) if row is None or row["gross_strokes"] is None: break gross_prefix.append(row["gross_strokes"]) budget = total_par + p["course_handicap"] fr = flag_result(gross_prefix, budget) results.append( FlagParticipantResult( tournament_participant_id=p["tournament_participant_id"], player_name=p["player_name"], holes_completed=fr.holes_completed, ran_out=fr.ran_out, strokes_remaining=fr.strokes_remaining, ) ) results.sort(key=lambda r: -r.holes_completed) return results