""" 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 datetime import datetime, timedelta from typing import Literal import asyncpg from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from handicap_engine import ( allocate_over_played_holes, allocate_strokes_by_index, bbb_points_for_hole, compute_bbb, compute_copenhagen_detail, course_handicap_raw, EclecticHoleValue, eclectic_best_per_hole, flag_result, round_half_up, stableford_points_for_hole, 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 ..hole_history import ( HoleHistoryOut, hole_history_for_user, resolve_tournament_round_course_key, ) from ..target_points import TargetPoint, get_target_points 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 # `datetime`, IKKE `str` -- Pydantic parser ISO 8601-tekst til en ekte # datetime selv, som asyncpg krever for en timestamptz-kolonne (en rå # streng feiler med TypeError ved skriving -- funnet 2026-08-18 da # update_round under fikk akkurat samme felt og ble faktisk testet med # en verdi satt, noe create_round aldri hadde blitt før siden # frontend-skjemaet ikke satte dette feltet ved opprettelse). scheduled_at: datetime | None = None 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, 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", "Runder kan kun opprettes for individuelle turneringer." ) # Eclectic (ADR-067-tillegget "Del C"): "beste resultat per hull på # tvers av rundene" krever at hullnumrene faktisk betyr det samme # (samme par/stroke index/fysiske hull) i alle runder -- fail # loudly fremfor å stille sammenligne epler og pærer, samme # ADR-019-filosofi som resten av appen. if tournament["scoring_method"] is not None and tournament["scoring_method"].startswith("eclectic_"): existing_course_id = await conn.fetchval( "SELECT course_id::text FROM tournament_round WHERE tournament_id = $1 LIMIT 1", tournament_id, ) if existing_course_id is not None and existing_course_id != body.course_id: raise app_error( 400, "VALIDATION_FAILED", "Eclectic krever samme bane for alle turneringens runder.", ) 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)) class TournamentRoundUpdate(BaseModel): """Alle felt valgfrie -- kun de som faktisk sendes med blir endret (PATCH-semantikk via Pydantic sin exclude_unset). Lagt til 2026-08-18 (utslagsgrupper, migrasjon 084) -- runden hadde INGEN vei til å endre scheduled_at/tee_interval_minutes/start_hole etter opprettelse tidligere, kun ved selve INSERT-en (og `create_round`-skjemaet i frontend satte dem aldri i utgangspunktet). Trengs for at organisator faktisk skal kunne sette et første utslagstidspunkt + intervall FØR utslagsgrupper gir noen mening (gruppenes klokkeslett er UTLEDET herfra, se `_compute_group_tee_time`). course_id/hole_config er BEVISST IKKE med -- mer strukturelle endringer enn dette skjemaet er ment for, og ville krevd samme eclectic-samme-bane-sjekk som create_round.""" name: str | None = None scheduled_at: datetime | None = None # se TournamentRoundCreate for hvorfor datetime, ikke str tee_interval_minutes: int | None = Field(default=None, gt=0) start_hole: int | None = Field(default=None, ge=1, le=18) @router.patch( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}", response_model=TournamentRound, ) async def update_round( tournament_id: str, round_id: str, body: TournamentRoundUpdate, organization_id: str = Depends(get_authorized_org), ) -> TournamentRound: updates = body.model_dump(exclude_unset=True) if not updates: raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.") set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)] values = list(updates.values()) values.append(round_id) values.append(tournament_id) async with org_connection(organization_id) as conn, translate_db_errors(): row = await conn.fetchrow( f""" WITH updated AS ( UPDATE tournament_round SET {', '.join(set_clauses)} WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)} RETURNING * ) SELECT {_ROUND_COLUMNS} FROM updated tr JOIN course c ON c.id = tr.course_id """, *values, ) if row is None: raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") 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) # ===================================================================== # Statistikknivå (migrasjon 075, ADR-071) -- samme tre verdier og samme # semantikk som round_participant.stat_level (rounds.py sin StatLevel): # hvor mye detalj UI-et ber om utover selve bruttoslaget. Tournament-bredt # (ikke per runde) -- en deltaker spiller normalt flere runder i samme # turnering og skal ikke måtte velge på nytt hver gang. StatLevel = Literal["strokes_only", "strokes_and_putts", "full"] class TournamentParticipantCreate(BaseModel): player_id: str # Konkurranseklasse (2026-08-03) -- valgfri, se 053_tournament_classes.sql. class_id: str | None = None stat_level: StatLevel = "strokes_only" class TournamentParticipantUpdate(BaseModel): """exclude_unset, samme PATCH-semantikk som resten av appen. `handicap_index_snapshot` (2026-08-18) -- bruker spurte "hvordan justerer jeg hcp på spillere?": å redigere spillerens indeks i selve spillerpoolen (ADR-085) endrer KUN grunnlaget for FREMTIDIGE turnering-påmeldinger -- en allerede satt `handicap_index_snapshot` på en eksisterende `tournament_participant` ble ALDRI rørt (samme "frosset snapshot"-filosofi som `PlayerUpdate` sin docstring allerede dokumenterer for lagturneringers `team_roster`). Dette feltet er den manglende veien til å rette EN SPESIFIKK deltakers indeks direkte i turneringen den faktisk gjelder for. Endrer BEVISST KUN selve tournament_participant-raden -- allerede opprettede `tournament_ round_participant.course_handicap`/`playing_handicap` for runder deltakeren allerede er satt opp i, forblir UENDRET (samme "ingen retroaktiv omskriving av frosne runde-verdier"-prinsipp) -- kun NYE rundepåmeldinger fra dette punktet av bruker den rettede indeksen.""" class_id: str | None = None stat_level: StatLevel | None = None handicap_index_snapshot: float | None = Field(default=None, ge=-10, le=54) # Manuell status (migrasjon 087, 2026-08-18) -- "Vi må manuelt kunne # sette Dsq, Rtd, Dnf, og Dns." Tournament-BREDT (ikke per runde, # samme granularitet som total_label på leaderboardet). Overstyrer # BÅDE det beregnede til-par-tallet OG en ev. Cut-status der (satt # SIST, vinner alltid) -- blokkerer også videre score-registrering # i update_hole, i ALLE runder (ikke bare "etter et gitt punkt" slik # Cut gjør). 'active' = uendret oppførsel, ingen regresjon. status: str | None = Field(default=None, pattern="^(active|dsq|rtd|dnf|dns)$") class TournamentParticipantOut(BaseModel): id: str player_id: str player_name: str handicap_index_snapshot: float | None class_id: str | None class_name: str | None stat_level: str status: str _TOURNAMENT_PARTICIPANT_COLUMNS = """ 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, tp.class_id::text AS class_id, tc.name AS class_name, tp.stat_level, tp.status """ @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( f""" WITH inserted AS ( INSERT INTO tournament_participant (organization_id, tournament_id, player_id, handicap_index_snapshot, class_id, stat_level) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, player_id, handicap_index_snapshot, class_id, stat_level, status ) SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS} FROM inserted tp JOIN player p ON p.id = tp.player_id LEFT JOIN tournament_class tc ON tc.id = tp.class_id """, organization_id, tournament_id, body.player_id, player["handicap_index"], body.class_id, body.stat_level, ) return TournamentParticipantOut(**dict(row)) @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( f""" SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS} FROM tournament_participant tp JOIN player p ON p.id = tp.player_id LEFT JOIN tournament_class tc ON tc.id = tp.class_id WHERE tp.tournament_id = $1 ORDER BY p.display_name """, tournament_id, ) return [TournamentParticipantOut(**dict(r)) for r in rows] @router.patch( "/orgs/{organization_id}/tournaments/{tournament_id}/participants/{participant_id}", response_model=TournamentParticipantOut, ) async def update_tournament_participant( tournament_id: str, participant_id: str, body: TournamentParticipantUpdate, organization_id: str = Depends(get_authorized_org), ) -> TournamentParticipantOut: updates = body.model_dump(exclude_unset=True) if not updates: raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.") set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)] values = list(updates.values()) values.append(participant_id) values.append(tournament_id) async with org_connection(organization_id) as conn, translate_db_errors(): row = await conn.fetchrow( f""" WITH updated AS ( UPDATE tournament_participant SET {', '.join(set_clauses)} WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)} RETURNING id, player_id, handicap_index_snapshot, class_id, stat_level, status ) SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS} FROM updated tp JOIN player p ON p.id = tp.player_id LEFT JOIN tournament_class tc ON tc.id = tp.class_id """, *values, ) if row is None: raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.") return TournamentParticipantOut(**dict(row)) @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 RoundParticipantTeeUpdate(BaseModel): """Kun utslagssted -- ADR-093. Endrer raden i stedet for slett+ gjenopprett nettopp for å BEVARE tournament_round_group_id (slett+ gjenopprett ville stille fjernet spilleren fra en ev. allerede tildelt utslagsgruppe -- se migrasjon 084, gruppemedlemskap er en kolonne direkte på denne raden, ikke en egen koblingstabell).""" 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 # Statistikknivå (migrasjon 075, ADR-071) -- tournament-bredt (tp.stat_ # level), speilet ut her slik at frontend vet om HoleGrid-cellen skal # åpne det utvidede HoleStatsSheet-et for denne deltakeren. stat_level: str 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, ) async def _validate_round_tee_choice( conn, tournament_id: str, round_id: str, tournament_participant_id: str, tee_id: str ) -> None: """Delt av add_round_participant OG update_round_participant_tee (PATCH, ADR-093) -- selve valideringen (utslaget hører til rundens bane, deltakeren har HCP+kjønn og valgt utslag har en rating for det kjønnet når scoring_method krever det) er identisk uansett om det er en NY rundedeltaker eller en ENDRING av utslag på en eksisterende -- kun hva som skjer ETTERPÅ (INSERT vs. UPDATE) skiller dem.""" 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", 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 """, 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)", 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." ) @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(): await _validate_round_tee_choice(conn, tournament_id, round_id, body.tournament_participant_id, body.tee_id) 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, tp.stat_level 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, tp.stat_level 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.") @router.patch( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants/{round_participant_id}", response_model=RoundParticipantOut, ) async def update_round_participant_tee( tournament_id: str, round_id: str, round_participant_id: str, body: RoundParticipantTeeUpdate, organization_id: str = Depends(get_authorized_org), ) -> RoundParticipantOut: async with org_connection(organization_id) as conn, translate_db_errors(): existing = await conn.fetchrow( "SELECT tournament_participant_id::text AS tournament_participant_id " "FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2", round_participant_id, round_id, ) if existing is None: raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.") await _validate_round_tee_choice( conn, tournament_id, round_id, existing["tournament_participant_id"], body.tee_id ) await conn.execute( "UPDATE tournament_round_participant SET tee_id = $1 WHERE id = $2", body.tee_id, round_participant_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, tp.stat_level 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)) # ===================================================================== # Utslagsgrupper (migrasjon 084, 2026-08-18) -- hvem spiller med hvem, # klokka når. Bruker: "Jeg må jo kunne bulksjekke hvem som skal spille # fra hvor, med hvem, klokka når?" Design bekreftet: TRADISJONELT utslag # (samme starthull for alle, allerede rundens eget start_hole -- ingen # endring der), staggerte klokkeslett per gruppe med FAST intervall # (tournament_round.tee_interval_minutes), gruppesammensetning MANUELL # MED et forslag å justere, FAST gruppestørrelse (organisator velger # tallet). Se ADR-088. # ===================================================================== def _compute_group_tee_time( scheduled_at: datetime | None, tee_interval_minutes: int | None, sequence: int, override: datetime | None, ) -> datetime | None: """Samme formel/filosofi som matches.py sin `_compute_tee_time` for lagturneringers økter -- utledet, ikke lagret, med en valgfri override som vinner (f.eks. én gruppe forsinket).""" if override is not None: return override if scheduled_at is None or tee_interval_minutes is None: return None return scheduled_at + timedelta(minutes=(sequence - 1) * tee_interval_minutes) class RoundGroupParticipant(BaseModel): round_participant_id: str player_name: str class RoundGroupOut(BaseModel): # `id=None` for en FORESLÅTT (ikke lagret) gruppe -- se suggest_round_groups. id: str | None sequence: int tee_time: str | None # utledet (eller override), ISO-tekst tee_time_override: str | None participants: list[RoundGroupParticipant] class RoundGroupsOut(BaseModel): groups: list[RoundGroupOut] # Rundedeltakere som IKKE er i noen gruppe ennå (nylig lagt til runden, # eller aldri gruppert) -- alltid vist separat, aldri stille utelatt. ungrouped: list[RoundGroupParticipant] async def _round_scheduling(conn, tournament_id: str, round_id: str) -> asyncpg.Record: round_row = await conn.fetchrow( "SELECT scheduled_at, tee_interval_minutes FROM tournament_round WHERE id = $1 AND tournament_id = $2", round_id, tournament_id, ) if round_row is None: raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") return round_row async def _fetch_round_groups(conn, round_id: str, round_row) -> RoundGroupsOut: group_rows = await conn.fetch( "SELECT id::text, sequence, tee_time_override " "FROM tournament_round_group WHERE tournament_round_id = $1 ORDER BY sequence", round_id, ) participant_rows = await conn.fetch( """ SELECT trp.id::text AS round_participant_id, p.display_name AS player_name, trp.tournament_round_group_id::text AS group_id 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 ORDER BY p.display_name """, round_id, ) by_group: dict[str, list[RoundGroupParticipant]] = {} ungrouped: list[RoundGroupParticipant] = [] for r in participant_rows: entry = RoundGroupParticipant(round_participant_id=r["round_participant_id"], player_name=r["player_name"]) if r["group_id"] is None: ungrouped.append(entry) else: by_group.setdefault(r["group_id"], []).append(entry) groups = [] for g in group_rows: tee_time = _compute_group_tee_time( round_row["scheduled_at"], round_row["tee_interval_minutes"], g["sequence"], g["tee_time_override"] ) groups.append( RoundGroupOut( id=g["id"], sequence=g["sequence"], tee_time=tee_time.isoformat() if tee_time is not None else None, tee_time_override=g["tee_time_override"].isoformat() if g["tee_time_override"] else None, participants=by_group.get(g["id"], []), ) ) return RoundGroupsOut(groups=groups, ungrouped=ungrouped) @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/groups", response_model=RoundGroupsOut, ) async def list_round_groups( tournament_id: str, round_id: str, organization_id: str = Depends(get_authorized_org), ) -> RoundGroupsOut: async with org_connection(organization_id) as conn: round_row = await _round_scheduling(conn, tournament_id, round_id) return await _fetch_round_groups(conn, round_id, round_row) class SuggestGroupsIn(BaseModel): group_size: int = Field(ge=1, le=12) @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/groups/suggest", response_model=RoundGroupsOut, ) async def suggest_round_groups( tournament_id: str, round_id: str, body: SuggestGroupsIn, organization_id: str = Depends(get_authorized_org), ) -> RoundGroupsOut: """Regner ut et FORSLAG (skriver INGENTING) -- organisator justerer i frontend og lagrer med save_round_groups når de er fornøyd. Fordeler ALLE rundens deltakere (uansett eksisterende gruppe) i rekkefølge etter spillernavn, i biter av `group_size` -- enkel og forutsigbar startfordeling, ikke ment å være den endelige (manuell justering med forslag å justere fra var det bekreftede designet, ikke helautomatisk).""" async with org_connection(organization_id) as conn: round_row = await _round_scheduling(conn, tournament_id, round_id) participant_rows = await conn.fetch( """ SELECT trp.id::text AS round_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 ORDER BY p.display_name """, round_id, ) entries = [ RoundGroupParticipant(round_participant_id=r["round_participant_id"], player_name=r["player_name"]) for r in participant_rows ] groups = [] for i in range(0, len(entries), body.group_size): sequence = i // body.group_size + 1 chunk = entries[i : i + body.group_size] tee_time = _compute_group_tee_time( round_row["scheduled_at"], round_row["tee_interval_minutes"], sequence, None ) groups.append( RoundGroupOut( id=None, sequence=sequence, tee_time=tee_time.isoformat() if tee_time is not None else None, tee_time_override=None, participants=chunk, ) ) return RoundGroupsOut(groups=groups, ungrouped=[]) class RoundGroupIn(BaseModel): sequence: int = Field(ge=1) tee_time_override: datetime | None = None round_participant_ids: list[str] class SaveRoundGroupsIn(BaseModel): groups: list[RoundGroupIn] @router.put( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/groups", response_model=RoundGroupsOut, ) async def save_round_groups( tournament_id: str, round_id: str, body: SaveRoundGroupsIn, organization_id: str = Depends(get_authorized_org), ) -> RoundGroupsOut: """Erstatter HELE rundens gruppeoppsett i én transaksjon -- samme "lagre hele brettet på nytt"-mønster som spillerimport-tabellen (enklere og mer forutsigbart enn å diffe mot forrige lagring). Rundedeltakere som ikke er nevnt i noen gruppe blir/forblir ugrupperte (ikke fjernet fra runden -- kun gruppetilhørigheten endres).""" async with org_connection(organization_id) as conn, translate_db_errors(): round_row = await _round_scheduling(conn, tournament_id, round_id) # Nullstiller koblingen EKSPLISITT før sletting -- se migrasjon 084 # sin kommentar for hvorfor FK-en bevisst IKKE har ON DELETE SET # NULL (en sammensatt FK med SET NULL ville korrumpert # organization_id, ikke bare selve gruppe-referansen). await conn.execute( "UPDATE tournament_round_participant SET tournament_round_group_id = NULL " "WHERE tournament_round_id = $1", round_id, ) await conn.execute("DELETE FROM tournament_round_group WHERE tournament_round_id = $1", round_id) for g in body.groups: group_row = await conn.fetchrow( """ INSERT INTO tournament_round_group (organization_id, tournament_round_id, sequence, tee_time_override) VALUES ($1, $2, $3, $4) RETURNING id """, organization_id, round_id, g.sequence, g.tee_time_override, ) if g.round_participant_ids: await conn.execute( """ UPDATE tournament_round_participant SET tournament_round_group_id = $1 WHERE id = ANY($2::uuid[]) AND tournament_round_id = $3 """, group_row["id"], g.round_participant_ids, round_id, ) return await _fetch_round_groups(conn, round_id, round_row) # ===================================================================== # Hull-for-hull-score (tournament_round_hole -- kilde-sannhet) # ===================================================================== class HoleUpdate(BaseModel): gross_strokes: int = Field(ge=1, le=20) # Full statistikkdybde (migrasjon 075, ADR-071) -- speiler rounds.py sin # HoleUpdate feltnavn-for-feltnavn (samme CHECK-betingelser i skjemaet). putts: int | None = Field(default=None, ge=0, le=10) club_off_tee: str | None = Field(default=None, max_length=50) tee_shot_result: Literal["fairway", "left", "right"] | None = None approach_result: Literal["hit", "long", "short", "left", "right"] | None = None chip_count: int | None = Field(default=None, ge=0) bunker_shot_count: int | None = Field(default=None, ge=0) penalty_strokes: int | None = Field(default=None, ge=0) first_putt_distance_bucket: Literal["<1m", "<2m", "<3m", "<5m", "<8m", "8m+"] | None = None anyway_strokes: int | None = Field(default=None, ge=0) # Optimistisk versjonssjekk (ADR-057-mønster, ny for denne tabellen). # None = hopp over sjekken (samme bakoverkompatible mønster som rounds.py). expected_version: int | None = None class RoundHoleOut(BaseModel): hole_number: int par: int stroke_index: int gross_strokes: int | None putts: int | None club_off_tee: str | None tee_shot_result: str | None approach_result: str | None chip_count: int | None bunker_shot_count: int | None penalty_strokes: int | None first_putt_distance_bucket: str | None anyway_strokes: int | None version: int 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, tr.sequence, t.scoring_method, t.cut_after_round 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, tp.cut, tp.status FROM tournament_round_participant trp JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id 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.") # Cut (migrasjon 083, 2026-08-18): en kuttet deltaker kan fortsatt # se/rette score i runder T.O.M. cut_after_round (historikken deres # skal ikke låses), men er blokkert fra å registrere score i runder # ETTER cut-punktet -- det er selve poenget med en cut. if ( rp["cut"] and round_row["cut_after_round"] is not None and round_row["sequence"] > round_row["cut_after_round"] ): raise app_error( 403, "CUT", "Denne spilleren er kuttet og kan ikke registrere score i denne runden." ) # Manuell status (migrasjon 087, 2026-08-18): en deltaker som er # satt til DSQ/RTD/DNF/DNS er ute av HELE turneringen (ikke bare # "etter et gitt punkt" slik Cut er) -- blokkert i ALLE runder, # ingen unntak for allerede spilte runder (i motsetning til Cut, # som bevisst lar historikken t.o.m. cut-punktet stå urørt). if rp["status"] != "active": raise app_error( 403, "PARTICIPANT_STATUS", f"Denne spilleren har status {rp['status'].upper()} og kan ikke registrere score.", ) 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." ) # Optimistisk versjonssjekk (ADR-057-mønster): WHERE-betingelsen på # DO UPDATE-grenen gjelder KUN når raden allerede finnes (konflikt) # -- første innsending av et hull treffer alltid INSERT-grenen og # skrives uansett expected_version, samme som round_hole sitt # migrasjon-062-mønster forutsetter (raden "finnes" fra scratch der # fordi alle 18 opprettes ved rundestart -- her opprettes den først # ved første score, så INSERT-grenen MÅ være ubetinget). updated_row = await conn.fetchrow( """ INSERT INTO tournament_round_hole (organization_id, tournament_round_participant_id, hole_number, gross_strokes, putts, club_off_tee, tee_shot_result, approach_result, chip_count, bunker_shot_count, penalty_strokes, first_putt_distance_bucket, anyway_strokes, version, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 1, now()) ON CONFLICT (tournament_round_participant_id, hole_number) DO UPDATE SET gross_strokes = $4, putts = $5, club_off_tee = $6, tee_shot_result = $7, approach_result = $8, chip_count = $9, bunker_shot_count = $10, penalty_strokes = $11, first_putt_distance_bucket = $12, anyway_strokes = $13, version = tournament_round_hole.version + 1, updated_at = now() WHERE ($14::int IS NULL OR tournament_round_hole.version = $14::int) RETURNING gross_strokes, putts, club_off_tee, tee_shot_result, approach_result, chip_count, bunker_shot_count, penalty_strokes, first_putt_distance_bucket, anyway_strokes, version """, organization_id, round_participant_id, hole_number, body.gross_strokes, body.putts, body.club_off_tee, body.tee_shot_result, body.approach_result, body.chip_count, body.bunker_shot_count, body.penalty_strokes, body.first_putt_distance_bucket, body.anyway_strokes, body.expected_version, ) if updated_row is None: raise app_error( 409, "STALE_VERSION", "Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.", ) 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"], strokes_received=strokes_received, **dict(updated_row), ) # --------------------------------------------------------------------------- # Spillerens per-hull-historikk (ADR-072) -- se app/hole_history.py for hele # bane-bro-utledningen mellom frittstående runder og org-turneringer. Samme # lesetilgang som list_round_participant_holes under (ethvert org-medlem, # ikke self-only slik selve SCORINGEN er) -- aggregerte historikktall # (snitt/GIR%) er ikke mer sensitivt enn selve scorekortet. # --------------------------------------------------------------------------- @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" "/participants/{round_participant_id}/holes/{hole_number}/history", response_model=HoleHistoryOut | None, ) async def get_round_participant_hole_history( tournament_id: str, round_id: str, round_participant_id: str, hole_number: int, organization_id: str = Depends(get_authorized_org), ) -> HoleHistoryOut | None: 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.") target_user_id = await conn.fetchval( """ SELECT p.user_id::text 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.id = $1 AND trp.tournament_round_id = $2 """, round_participant_id, round_id, ) if target_user_id is None: # Gjest (ingen app_user-kobling) -- ingen historikk å finne. return None key = await resolve_tournament_round_course_key(conn, round_id) return await hole_history_for_user(target_user_id, key, hole_number) @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" "/holes/{hole_number}/target-points", response_model=list[TargetPoint], ) async def get_tournament_round_hole_target_points( tournament_id: str, round_id: str, hole_number: int, organization_id: str = Depends(get_authorized_org), ) -> list[TargetPoint]: """Rangefinder-data for hullet (migrasjon 079) -- samme kilde/prinsipp som frittstående runders `GET /rounds/{id}/holes/{n}/target-points`, her for en org-turnering-runde (individuell slagspill). Tom liste for håndlagde baner, rangefinder-UI-et skjules da bare.""" 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.") course_key = await resolve_tournament_round_course_key(conn, round_id) points = await get_target_points(conn, course_key, hole_number) return points @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, trh.putts, trh.club_off_tee, trh.tee_shot_result, trh.approach_result, trh.chip_count, trh.bunker_shot_count, trh.penalty_strokes, trh.first_putt_distance_bucket, trh.anyway_strokes, COALESCE(trh.version, 1) AS version 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(strokes_received=a, **dict(h)) for h, a in zip(holes, allocation) ] # ===================================================================== # Leaderboard (summert VED LESING på tvers av alle runder, Beslutning C) # ===================================================================== class RoundCellOut(BaseModel): round_number: int label: str tone: str | None # "under" | "even" | "over" -- matches stroke-play-leaderboard.tsx sin RoundCell class EclecticHoleCellOut(BaseModel): hole_number: int par: int value: int # brutto/nettoslag eller Stableford-poeng, avhengig av scoring_method round_number: int # HVILKEN runde (sequence) det beste resultatet kom fra ("beste-kilde") class LeaderboardEntry(BaseModel): tournament_participant_id: str # Order of Merit (2026-08-04, order_of_merit.py) trenger den EKTE # spilleren, ikke bare denne ENE turneringens deltaker-rad, for å # summere resultater på tvers av flere lenkede turneringer. player_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 # Konkurranseklasse (2026-08-03) -- frontend grupperer den allerede # sorterte listen under på class_id (stabil gruppering bevarer riktig # rangering per klasse). Sorteringen under er UENDRET -- ren tillegg. class_id: str | None class_name: str | None # Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx) -- # KUN populert for scoring_method i (stroke_gross, stroke_net, stableford). # Alltid None/tom liste for copenhagen/bingo_bango_bongo/flag -- frontend # bruker fortsatt den enkle listen for de formatene. position: str | None = None is_leader: bool = False today_label: str | None = None thru_label: str | None = None total_label: str | None = None rounds: list[RoundCellOut] = [] # Nøytral visning for spillere som ikke har startet gjeldende/neste runde # ennå (2026-08-18). next_start_hole settes alltid når det finnes en # referanse-runde (start_hole har alltid en verdi -- default 1). # next_tee_time settes kun når runden faktisk har et oppgitt klokkeslett # (scheduled_at er nullable -- ikke alle turneringer setter det). next_tee_time: str | None = None next_start_hole: int | None = None # Cut (migrasjon 083, 2026-08-18) -- KUN populert (og kun meningsfullt) # for scoring_method i (stroke_gross, stroke_net, stableford), samme # begrensning som resten av Augusta-resultattavlen. `rank` er den RÅ # konkurranse-rangeringen (uten "T"-prefiks/uavgjort-tekst -- se # `position` for visningsklar streng) -- brukt internt av apply_cut for # å finne cut-grensen numerisk, men trygt å eksponere. `cut=True` når # organisator har trykket "Anvend cut" og denne deltakeren falt utenfor # cut_size+uavgjort-grensen (eller ikke hadde spilt noe gjennom # cut_after_round i det hele tatt). rank: int | None = None cut: bool = False # Manuell status (migrasjon 087, 2026-08-18) -- "active" (uendret # oppførsel) eller "dsq"/"rtd"/"dnf"/"dns". Overstyrer BÅDE det # beregnede til-par-tallet og en ev. cut-status i total_label (satt # SIST i _compute_individual_standings -- en DSQ-et spiller er DSQ, # uansett cut). status: str = "active" # Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- beste resultat # per hull på tvers av ALLE turneringens runder (samme bane, håndhevet # ved rundeopprettelse). Alltid None/tom liste for øvrige scoring_ # method-verdier. eclectic_total: int | None = None eclectic_holes: list[EclecticHoleCellOut] = [] async def _compute_individual_standings( conn, tournament_id: str, max_sequence: int | None = None ) -> tuple[dict, list[LeaderboardEntry]]: """Delt mellom `individual_leaderboard` under (denne ENE turneringens egen visning) og Order of Merit sitt leaderboard-endepunkt (`order_of_merit.py`, som summerer resultater over FLERE lenkede turneringer) -- faktorisert ut 2026-08-04 for å unngå å duplisere posisjon-/til-par-beregningen (inkl. den til-par-normaliserte rangeringsnøkkelen, se kommentar i `_attach_stroke_play_columns`). `max_sequence` (2026-08-18, Cut): når satt, regnes stillingen KUN ut fra runder t.o.m. den sekvensen -- brukt av `apply_cut` til å beregne "stillingen som den var etter cut_after_round", uavhengig av hvor langt turneringen faktisk har kommet i mellomtiden. `None` (standard) = alle runder, uendret oppførsel for den vanlige leaderboard-visningen. Returnerer `(tournament_row, entries)` -- kalleren i OOM-sammenheng trenger `tournament_row["scoring_method"]` for å vite hvilket felt på hver entry (`gross_total`/`net_total`/`stableford_total`/`position`) som faktisk er meningsfullt å lese videre.""" tournament = await conn.fetchrow( "SELECT format_type, scoring_method, tiebreak_winner_method, tiebreak_field_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, tp.player_id::text AS player_id, p.display_name AS player_name, tp.cut, tp.status, 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, tc.id::text AS class_id, tc.name AS class_name 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 AND ($2::smallint IS NULL OR trs.tournament_round_id IN ( SELECT id FROM tournament_round WHERE tournament_id = $1 AND sequence <= $2 )) LEFT JOIN tournament_class tc ON tc.id = tp.class_id WHERE tp.tournament_id = $1 GROUP BY tp.id, p.display_name, tc.id, tc.name """, tournament_id, max_sequence, ) entries = [LeaderboardEntry(**dict(r)) for r in rows] method = tournament["scoring_method"] if method in ("stroke_gross", "stroke_net", "stableford"): # VIKTIG: kan IKKE sorteres på rå gross_total/stableford_total her # (slik de andre metodene gjør) -- i en flerrunde-turnering har # deltakere spilt ULIKT antall hull til enhver tid (noen midt i # runde 2, andre ikke startet ennå), så rå sum er ikke # sammenlignbar. _attach_stroke_play_columns sorterer selv, på # til-par (normalisert for antall hull spilt), FØR posisjon # tildeles -- reell bug funnet under scratch-verifisering: en # deltaker med KUN 18 hull spilt (lavere rå sum) rangerte foran # deltakere med -5 til par over 27-36 hull, kun fordi rått # slagtall er mindre jo færre hull man har spilt. await _attach_stroke_play_columns( conn, tournament_id, method, entries, max_sequence=max_sequence, tiebreak_winner_method=tournament["tiebreak_winner_method"], tiebreak_field_method=tournament["tiebreak_field_method"], ) # Cut (2026-08-18): kuttede spillere vises samlet BAKERST, som en # egen seksjon -- ALDRI innimellom fortsatt-med-spillere basert på # frossent til-par-tall alene (en kuttet spiller sin score endrer # seg jo aldri mer, mens resten av feltet fortsetter å spille -- # uten dette ville en kuttet spiller med et godt frosset tall kunne # ende opp foran en som fortsatt spiller og har hatt en dårlig # runde). `total_label` overstyres til "CUT" -- gjenbruker # STATUS-mekanismen som WD/DQ/DNS/NR allerede har i # stroke-play-leaderboard.tsx (dempet rad, egen badge), pluss en # visuell skillelinje i komponenten mellom siste ikke-kuttede og # første kuttede rad. `_attach_stroke_play_columns` har ALLEREDE # sortert `entries` riktig internt (beste til-par først) -- en # STABIL sortering på kun `cut` her bevarer den rekkefølgen innad i # begge gruppene, ingen ny rangeringsnøkkel trengs. entries.sort(key=lambda e: e.cut) for e in entries: if e.cut: e.total_label = "CUT" e.is_leader = False # Manuell status (migrasjon 087, 2026-08-18) -- "Vi må manuelt # kunne sette Dsq, Rtd, Dnf, og Dns." Anvendes SIST, vinner # alltid over BÅDE det beregnede til-par-tallet og en ev. # cut-status over -- en DSQ-et spiller er DSQ, uansett om de # tilfeldigvis også er kuttet. Ingen ny sortering nødvendig # (gjenbruker rekkefølgen fra cut-sorteringen over -- disse # spillerne ender uansett med en status-rad, ikke en rangert # plassering, se position=None under). for e in entries: if e.status != "active": e.total_label = e.status.upper() e.is_leader = False e.position = None 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))) elif method in ("eclectic_gross", "eclectic_net", "eclectic_stableford"): await _attach_eclectic_totals(conn, tournament_id, method, entries) if method == "eclectic_stableford": entries.sort(key=lambda e: (e.eclectic_total is None, -(e.eclectic_total or 0))) else: entries.sort(key=lambda e: (e.eclectic_total is None, e.eclectic_total or 0)) else: entries.sort(key=lambda e: (e.gross_total is None, e.gross_total or 0)) return dict(tournament), entries @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: _, entries = await _compute_individual_standings(conn, tournament_id) return entries class CutResult(BaseModel): cut_after_round: int cut_size: int survivors: int cut_count: int cut_participant_ids: list[str] added_to_later_rounds: int async def _sync_post_cut_round_participation( conn, organization_id: str, tournament_id: str, cut_after_round: int, cut_ids: list[str] ) -> int: """Kaskader cut-resultatet til ALLE runder etter cut_after_round (2026-08-20, bruker bekreftet eksplisitt: "Har du klart cut'en spiller du alle påfølgende runder" -- ikke bare den aller neste). Overlevere: legges automatisk til enhver senere runde de ikke allerede er meldt på i, med samme utslagssted som de spilte cut-runden med (eneste faktiske data å basere det på -- ingen "kopier fra forrige runde"-mekanisme fantes fra før). Kuttede: fjernes fra enhver senere runde de er meldt på i -- MEN KUN dersom de ikke allerede har registrert score der (sletting av tournament_round_participant kaskader til tournament_round_hole, se migrasjon 040 -- ville slettet ekte scorer). Dersom scorer allerede finnes, la raden stå urørt; den eksisterende cut-sperren blokkerer likevel videre scoreregistrering for dem. IDEMPOTENT, som resten av apply-cut -- trygt å kjøre på nytt etter en score-rettelse. Returnerer antall rader lagt til (for tilbakemelding til organisator).""" later_rounds = await conn.fetch( """ SELECT id::text AS id FROM tournament_round WHERE tournament_id = $1 AND sequence > $2 ORDER BY sequence """, tournament_id, cut_after_round, ) if not later_rounds: return 0 cut_round = await conn.fetchrow( "SELECT id::text AS id FROM tournament_round WHERE tournament_id = $1 AND sequence = $2", tournament_id, cut_after_round, ) survivor_tees: dict[str, str] = {} if cut_round is not None: rows = await conn.fetch( "SELECT tournament_participant_id::text AS pid, tee_id::text AS tee_id " "FROM tournament_round_participant WHERE tournament_round_id = $1", cut_round["id"], ) survivor_tees = {r["pid"]: r["tee_id"] for r in rows} all_participants = await conn.fetch( "SELECT id::text AS id FROM tournament_participant WHERE tournament_id = $1", tournament_id, ) survivor_ids = [r["id"] for r in all_participants if r["id"] not in cut_ids] added = 0 for round_row in later_rounds: round_id = round_row["id"] existing = await conn.fetch( "SELECT id::text AS id, tournament_participant_id::text AS pid " "FROM tournament_round_participant WHERE tournament_round_id = $1", round_id, ) existing_by_pid = {r["pid"]: r["id"] for r in existing} for pid in survivor_ids: if pid in existing_by_pid: continue tee_id = survivor_tees.get(pid) if tee_id is None: continue # ingen kjent tee å bruke -- hopp over, ikke gjett new_id = await conn.fetchval( """ INSERT INTO tournament_round_participant (organization_id, tournament_round_id, tournament_participant_id, tee_id) VALUES ($1, $2, $3, $4) RETURNING id """, organization_id, round_id, pid, tee_id, ) await _compute_round_participant_handicap(conn, str(new_id)) added += 1 for pid, rp_id in existing_by_pid.items(): if pid not in cut_ids: continue has_scores = await conn.fetchval( "SELECT EXISTS(SELECT 1 FROM tournament_round_hole WHERE tournament_round_participant_id = $1)", rp_id, ) if has_scores: continue await conn.execute("DELETE FROM tournament_round_participant WHERE id = $1", rp_id) return added @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/apply-cut", response_model=CutResult, ) async def apply_cut( tournament_id: str, organization_id: str = Depends(get_authorized_org), ) -> CutResult: """Anvender cutten MANUELT (migrasjon 083, 2026-08-18) -- bruker bekreftet eksplisitt: IKKE automatisk ved fullspilt runde, organisator trykker selv en "Anvend cut"-knapp når de er klare (unngår at cutten slår inn mens noen fortsatt er midt i runden). Topp `cut_size` OG delt plass slipper gjennom -- alle med samme til-par-tall som grenseplassen regnes som "innenfor". Deltakere som ikke har spilt ETT eneste hull gjennom `cut_after_round` kuttes alltid, uansett `cut_size` (ingen resultat å beskytte). IDEMPOTENT -- regner cut-linjen på nytt fra bunnen av HELE feltet hver gang (aldri inkrementelt), så et nytt trykk etter en score-rettelse i en tidligere runde gir riktig oppdatert resultat, ikke et lag oppå forrige kjøring. Kan derfor trykkes flere ganger.""" async with org_connection(organization_id) as conn, translate_db_errors(): tournament = await conn.fetchrow( "SELECT format_type, scoring_method, cut_after_round, cut_size 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" or tournament["scoring_method"] not in ( "stroke_gross", "stroke_net", "stableford" ): raise app_error( 400, "VALIDATION_FAILED", "Cut er kun støttet for brutto-, netto- eller stableford-turneringer." ) cut_after_round = tournament["cut_after_round"] cut_size = tournament["cut_size"] if cut_after_round is None or cut_size is None: raise app_error( 400, "VALIDATION_FAILED", "Sett rundenummer og antall som går videre til cut før du anvender den." ) _, entries = await _compute_individual_standings(conn, tournament_id, max_sequence=cut_after_round) ranked = [e for e in entries if e.rank is not None] ranked.sort(key=lambda e: e.rank) if ranked and cut_size < len(ranked): cutoff_rank = ranked[cut_size - 1].rank else: cutoff_rank = ranked[-1].rank if ranked else 0 cut_ids = [e.tournament_participant_id for e in entries if e.rank is None or e.rank > cutoff_rank] await conn.execute( "UPDATE tournament_participant SET cut = (id = ANY($2::uuid[])) WHERE tournament_id = $1", tournament_id, cut_ids, ) added_to_later_rounds = await _sync_post_cut_round_participation( conn, organization_id, tournament_id, cut_after_round, cut_ids ) await conn.execute("UPDATE tournament SET cut_applied_at = now() WHERE id = $1", tournament_id) return CutResult( cut_after_round=cut_after_round, cut_size=cut_size, survivors=len(entries) - len(cut_ids), cut_count=len(cut_ids), cut_participant_ids=cut_ids, added_to_later_rounds=added_to_later_rounds, ) _HOLE_COUNT_BY_CONFIG = {"full_18": 18, "front_9": 9, "back_9": 9} def _to_par_label(v: int) -> str: if v == 0: return "E" return f"+{v}" if v > 0 else str(v) def _to_par_tone(v: int) -> str: if v < 0: return "under" if v > 0: return "over" return "even" async def _lowest_hcp_sort_keys(conn, participant_ids: list[str]) -> dict[str, tuple]: """Tiebreak 'lowest_hcp' (migrasjon 085) -- laveste handicap-indeks vinner. Manglende indeks sorteres SIST (kan aldri vinne en tiebreak ved å mangle data).""" rows = await conn.fetch( "SELECT id::text AS id, handicap_index_snapshot::float AS hcp " "FROM tournament_participant WHERE id = ANY($1::uuid[])", participant_ids, ) return {r["id"]: (r["hcp"] if r["hcp"] is not None else float("inf"),) for r in rows} async def _countback_sort_keys(conn, tournament_id: str, method: str, participant_ids: list[str]) -> dict[str, tuple]: """Tiebreak 'countback' (migrasjon 085) -- kaskaderende "matching cards": siste 9 -> siste 6 -> siste 3 -> siste hull i turneringens SISTE runde (standard klubbgolf-metode, ingen egen dybde-innstilling -- kaskaden er alltid full). Python sin tuple-sammenligning gjør selve kaskaden gratis: (siste9, siste6, siste3, siste1), sammenlignet elementvis i rekkefølge, akkurat slik en reell countback fungerer. ASCENDING sortering, lavere er alltid bedre -- Stableford-poeng negeres for å passe samme konvensjon (flere poeng er bedre der). Bruker turneringens ABSOLUTT SISTE runde (høyest sequence) som grunnlag, uansett hvor mange runder DENNE deltakeren faktisk har spilt -- en ekte uavgjort sluttstilling forutsetter normalt at begge har spilt like mange runder. Mangler en deltaker data for den runden (har ikke spilt den ennå), får de en nøytral midtverdi (0,0,0,0) -- ingen krasj, bare ingen reell tiebreak-fordel/ulempe.""" last_round = await conn.fetchrow( "SELECT id, course_id FROM tournament_round WHERE tournament_id = $1 ORDER BY sequence DESC LIMIT 1", tournament_id, ) if last_round is None: return {pid: (0, 0, 0, 0) for pid in participant_ids} holes_meta = await conn.fetch( "SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", last_round["course_id"], ) par_by_hole = {h["hole_number"]: h["par"] for h in holes_meta} all_18_si = [h["stroke_index"] for h in holes_meta] keys: dict[str, tuple] = {} for pid in participant_ids: rp = await conn.fetchrow( "SELECT id, playing_handicap FROM tournament_round_participant " "WHERE tournament_round_id = $1 AND tournament_participant_id = $2", last_round["id"], pid, ) if rp is None: keys[pid] = (0, 0, 0, 0) continue hole_rows = 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", rp["id"], ) if not hole_rows: keys[pid] = (0, 0, 0, 0) continue played_numbers = [h["hole_number"] for h in hole_rows] gross_by_hole = {h["hole_number"]: h["gross_strokes"] for h in hole_rows} received = [0] * len(played_numbers) if method != "stroke_gross" and rp["playing_handicap"] is not None: received = allocate_over_played_holes(rp["playing_handicap"], all_18_si, played_numbers) per_hole_values = [] for hole_number, strokes_received in zip(played_numbers, received): gross = gross_by_hole[hole_number] par = par_by_hole[hole_number] if method == "stableford": per_hole_values.append(stableford_points_for_hole(par, gross, strokes_received)) else: per_hole_values.append((gross - strokes_received) - par) def suffix_sum(n: int, values: list[int] = per_hole_values) -> int: return sum(values[-n:]) if values else 0 last9, last6, last3, last1 = suffix_sum(9), suffix_sum(6), suffix_sum(3), suffix_sum(1) keys[pid] = (-last9, -last6, -last3, -last1) if method == "stableford" else (last9, last6, last3, last1) return keys def _assign_ranks_within_group( entries: list[LeaderboardEntry], indices: list[int], sort_keys: dict[str, tuple], base_rank: int ) -> None: """Tildeler posisjon/rank til en (tidligere fullt uavgjort) gruppe ETTER at en tiebreak-nøkkel er beregnet -- samme "vanlig konkurranse- rangering, uavgjort deler plass"-algoritme som hovedløkken under, men startet på `base_rank` i stedet for 1. Gjenværende uavgjorte (samme nøkkel også etter tiebreak) deler fortsatt plass innbyrdes -- tiebreak GARANTERER ikke en total orden, den reduserer bare hvor ofte uavgjort faktisk skjer.""" ordered = sorted(indices, key=lambda idx: sort_keys[entries[idx].tournament_participant_id]) n = len(ordered) i = 0 while i < n: j = i key_i = sort_keys[entries[ordered[i]].tournament_participant_id] while j < n and sort_keys[entries[ordered[j]].tournament_participant_id] == key_i: j += 1 rank = base_rank + i tied = (j - i) > 1 for k in range(i, j): idx = ordered[k] entries[idx].position = f"T{rank}" if tied else str(rank) entries[idx].rank = rank entries[idx].is_leader = rank == 1 i = j async def _attach_stroke_play_columns( conn, tournament_id: str, method: str, entries: list[LeaderboardEntry], max_sequence: int | None = None, tiebreak_winner_method: str = "none", tiebreak_field_method: str = "none", ) -> None: """Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx): POS/TODAY/THRU/TOTAL/R1-Rn. Muterer `entries` (allerede riktig sortert av kalleren) i stedet for å bygge en ny liste -- gjenbruker eksisterende gross_total/net_total/stableford_total som ALLEREDE er summert riktig, legger kun til det som mangler: par-for-spilte-hull (for til-par-tall), per-runde nedbrytning, og posisjon-med-uavgjort-håndtering. `max_sequence` (2026-08-18, Cut) -- se `_compute_individual_standings`. `tiebreak_winner_method`/`tiebreak_field_method` (migrasjon 085, 2026-08-18) -- BEVISST kun brukt når `max_sequence is None`. Cut- beregningen (apply_cut) kaller alltid denne funksjonen MED en satt max_sequence, og skal ALDRI la tiebreak påvirke hvem som slipper gjennom (bruker bekreftet eksplisitt: "Kun sluttresultat, ikke cut") -- cut-grensen forblir ren rangering med full delt plass uansett. Den vanlige leaderboard-visningen (`individual_leaderboard`) sender aldri max_sequence, så tiebreak gjelder der -- inkludert midt i turneringen, ikke bare ved siste runde ferdigspilt (samme "vis det beste vi vet akkurat nå"-filosofi som resten av leaderboardet). """ rounds_meta = await conn.fetch( """ SELECT sequence, hole_config::text AS hole_config, scheduled_at::text AS scheduled_at, start_hole FROM tournament_round WHERE tournament_id = $1 ORDER BY sequence """, tournament_id, ) hole_count_by_seq = { r["sequence"]: _HOLE_COUNT_BY_CONFIG[r["hole_config"]] for r in rounds_meta } round_meta_by_seq = {r["sequence"]: r for r in rounds_meta} first_sequence = rounds_meta[0]["sequence"] if rounds_meta else None # Én rad per (runde, deltaker) som faktisk er PÅBEGYNT (holes_played > 0). # par_played = summen av par for KUN de hullene deltakeren faktisk har # ført score på i den runden -- riktig grunnlag for et til-par-tall # midt i en runde (ikke hele rundens par, som ville misvist før runden # er ferdigspilt). round_rows = await conn.fetch( """ SELECT tr.sequence, trs.tournament_participant_id::text AS tournament_participant_id, trs.holes_played, trs.gross_total, trs.net_total, trs.stableford_points, COALESCE(pp.par_played, 0)::int AS par_played FROM tournament_round tr JOIN tournament_round_score trs ON trs.tournament_round_id = tr.id LEFT JOIN LATERAL ( SELECT SUM(h.par) AS par_played FROM tournament_round_participant trp JOIN tournament_round_hole trh ON trh.tournament_round_participant_id = trp.id JOIN hole h ON h.course_id = tr.course_id AND h.hole_number = trh.hole_number WHERE trp.tournament_round_id = tr.id AND trp.tournament_participant_id = trs.tournament_participant_id AND trh.gross_strokes IS NOT NULL ) pp ON true WHERE tr.tournament_id = $1 AND trs.holes_played > 0 AND ($2::smallint IS NULL OR tr.sequence <= $2) ORDER BY tr.sequence """, tournament_id, max_sequence, ) by_participant: dict[str, dict[int, object]] = {} started_sequences: set[int] = set() for r in round_rows: by_participant.setdefault(r["tournament_participant_id"], {})[r["sequence"]] = r started_sequences.add(r["sequence"]) # "I dag" = runden med høyest sekvensnummer som NOEN har påbegynt -- ikke # nødvendigvis fullført (bekreftet med bruker: "det er den sist # påbegynte"). Ingen eksplisitt status-/aktiv-markering finnes på # tournament_round i dag, så dette utledes. today_sequence = max(started_sequences) if started_sequences else None value_col = "stableford_points" if method == "stableford" else ( "net_total" if method == "stroke_net" else "gross_total" ) # Rangeringsnøkkel PER DELTAKER, til-par-normalisert (lavere = bedre, # uniformt for alle tre metodene -- stableford negeres siden flere poeng # er bedre der). Kan IKKE bruke rå gross_total/stableford_total: i en # flerrunde-turnering har deltakere spilt ulikt antall hull til enhver # tid, så rå sum er ikke sammenlignbar (se kallstedets kommentar). NO_SCORE = 1_000_000_000 rank_key_by_id: dict[str, int] = {} for e in entries: pmap = by_participant.get(e.tournament_participant_id, {}) rounds_out: list[RoundCellOut] = [] total_par_played = 0 total_holes_played = 0 for seq, row in sorted(pmap.items()): if row["holes_played"] == 0: continue total_par_played += row["par_played"] total_holes_played += row["holes_played"] if method == "stableford": points = row["stableford_points"] or 0 expected = 2 * row["holes_played"] rounds_out.append( RoundCellOut(round_number=seq, label=f"{points} p", tone=_to_par_tone(points - expected)) ) else: val = row[value_col] if val is None: continue rounds_out.append( RoundCellOut( round_number=seq, label=str(val), tone=_to_par_tone(val - row["par_played"]), ) ) e.rounds = rounds_out # TODAY / THRU today_row = pmap.get(today_sequence) if today_sequence is not None else None if today_row is not None and today_row["holes_played"] > 0: hole_count = hole_count_by_seq.get(today_sequence, 18) e.thru_label = "F" if today_row["holes_played"] >= hole_count else str(today_row["holes_played"]) if method == "stableford": e.today_label = f"{today_row['stableford_points'] or 0} p" else: val = today_row[value_col] e.today_label = _to_par_label(val - today_row["par_played"]) if val is not None else None else: e.thru_label = "-" e.today_label = None # Ikke-startet-info (2026-08-18): så lenge deltakeren ikke har # startet den gjeldende/neste runden ("-" over), vis tee-tid/ # starthull for DEN runden -- referanserunden er selve gjeldende # runden (today_sequence) hvis turneringen er i gang, ellers # runde 1 (ingen har startet noe ennå). if e.thru_label == "-": reference_sequence = today_sequence if today_sequence is not None else first_sequence meta = round_meta_by_seq.get(reference_sequence) if reference_sequence is not None else None if meta is not None: e.next_tee_time = meta["scheduled_at"] e.next_start_hole = meta["start_hole"] # TOTAL -- gjenbruker den allerede korrekt summerte gross_total/ # net_total/stableford_total (uendret av denne funksjonen), trekker # kun fra summert par-for-spilte-hull for til-par-formatet. Ingen # runde spilt ennå (total_holes_played == 0) -> total_label forblir # None (nøytral -- IKKE "E"/"0 p", som ville sett ut som en ekte, # jevn/poengløs score i stedet for "ikke startet"). started_any = total_holes_played > 0 if method == "stableford": e.total_label = f"{e.stableford_total or 0} p" if started_any else None rank_key_by_id[e.tournament_participant_id] = ( NO_SCORE if e.stableford_total is None or not started_any else -(e.stableford_total - 2 * total_holes_played) ) else: total_val = e.net_total if method == "stroke_net" else e.gross_total e.total_label = ( _to_par_label(total_val - total_par_played) if (total_val is not None and started_any) else None ) rank_key_by_id[e.tournament_participant_id] = ( NO_SCORE if total_val is None or total_par_played == 0 else total_val - total_par_played ) # Sorter på til-par-nøkkelen (lavere = bedre), IKKE på rå total -- se # kallstedets kommentar for hvorfor rå total ikke er sammenlignbar på # tvers av ulikt antall hull spilt. entries.sort(key=lambda e: rank_key_by_id[e.tournament_participant_id]) # POS -- vanlig konkurranse-rangering (uavgjort deler plass, neste # rangering hopper over like mange plasser som antall uavgjorte). # Deltakere som ikke har spilt ETT eneste hull ennå (NO_SCORE) får INGEN # posisjon -- de er ikke "sist rangert", de er ennå ikke en del av # rangeringen (nøytral, se next_tee_time/next_start_hole over). i, n = 0, len(entries) while i < n: j = i key_i = rank_key_by_id[entries[i].tournament_participant_id] while j < n and rank_key_by_id[entries[j].tournament_participant_id] == key_i: j += 1 rank = i + 1 tied = (j - i) > 1 not_started_group = key_i == NO_SCORE # Tiebreak (migrasjon 085) -- kun for en FAKTISK uavgjort gruppe, # aldri for cut-beregningen (se _attach_stroke_play_columns sin # docstring for hvorfor max_sequence is None er selve porten). tiebreak_method = "none" if tied and not not_started_group and max_sequence is None: tiebreak_method = tiebreak_winner_method if rank == 1 else tiebreak_field_method # 'playoff' (migrasjon 086) behandles IKKE her -- appen kan ikke # selv avgjøre et omspill (det skjer på banen), så en "playoff"- # gruppe faller gjennom til else-grenen under akkurat som 'none': # delt plassering, ingen automatisk oppløsning. Forskjellen er # ren informasjon (tiebreak_playoff_rule), ikke rangeringslogikk. if tiebreak_method in ("countback", "lowest_hcp"): group_ids = [entries[k].tournament_participant_id for k in range(i, j)] if tiebreak_method == "countback": sort_keys = await _countback_sort_keys(conn, tournament_id, method, group_ids) else: sort_keys = await _lowest_hcp_sort_keys(conn, group_ids) _assign_ranks_within_group(entries, list(range(i, j)), sort_keys, base_rank=rank) else: for k in range(i, j): entries[k].position = None if not_started_group else (f"T{rank}" if tied else str(rank)) entries[k].is_leader = rank == 1 and not not_started_group entries[k].rank = None if not_started_group else rank i = j # --------------------------------------------------------------------------- # Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- "drømmerunde" satt # sammen av BESTE resultat per hullnummer på tvers av ALLE turneringens # runder. Krever samme bane for alle runder (håndhevet ved rundeopprettelse, # se create_round over) -- hullnummer 5 i runde 1 og hullnummer 5 i runde 2 # er da GARANTERT samme fysiske hull/par/stroke index, så sammenligningen er # meningsfull. Motoren (eclectic_best_per_hole) vet ingenting om db/HCP -- # denne funksjonen forbereder per-hull-per-runde-verdiene (brutto/netto/ # Stableford-poeng, avhengig av `method`) og mater dem inn. # --------------------------------------------------------------------------- async def _attach_eclectic_totals(conn, tournament_id: str, method: str, entries: list[LeaderboardEntry]) -> None: course_id = await conn.fetchval( "SELECT course_id::text FROM tournament_round WHERE tournament_id = $1 LIMIT 1", tournament_id ) if course_id is None: return hole_rows = await conn.fetch( "SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", course_id, ) par_by_hole = {r["hole_number"]: r["par"] for r in hole_rows} si_by_hole = [r["stroke_index"] for r in hole_rows] # hull 1..18, i rekkefølge round_rows = await conn.fetch( """ SELECT tr.sequence, trp.tournament_participant_id::text AS tournament_participant_id, trp.course_handicap, trh.hole_number, trh.gross_strokes FROM tournament_round tr JOIN tournament_round_participant trp ON trp.tournament_round_id = tr.id JOIN tournament_round_hole trh ON trh.tournament_round_participant_id = trp.id WHERE tr.tournament_id = $1 AND trh.gross_strokes IS NOT NULL ORDER BY tr.sequence """, tournament_id, ) # round_index i motorens forstand = rekkefølgen sequence-verdiene faktisk # dukker opp i (stabil, sortert) -- brukt kun til å spore "beste-kilde" # tilbake til et ekte rundenummer (sequence) for visning. sequence_by_index: dict[int, int] = {} for r in round_rows: if r["sequence"] not in sequence_by_index.values(): sequence_by_index[len(sequence_by_index)] = r["sequence"] index_by_sequence = {seq: i for i, seq in sequence_by_index.items()} # Cache slagfordelingen per (course_handicap) -- samme fordeling for # enhver rad med samme handicap, uansett hvilken runde/hull den gjelder. allocation_cache: dict[int, list[int]] = {} values_by_participant: dict[str, list[list[EclecticHoleValue]]] = {} for r in round_rows: if r["course_handicap"] is None: continue pid = r["tournament_participant_id"] if pid not in values_by_participant: values_by_participant[pid] = [[] for _ in range(18)] strokes_received = 0 if method != "eclectic_gross": hcp = r["course_handicap"] if hcp not in allocation_cache: allocation_cache[hcp] = allocate_strokes_by_index(hcp, si_by_hole) strokes_received = allocation_cache[hcp][r["hole_number"] - 1] if method == "eclectic_gross": value = r["gross_strokes"] elif method == "eclectic_net": value = r["gross_strokes"] - strokes_received else: value = stableford_points_for_hole(par_by_hole[r["hole_number"]], r["gross_strokes"], strokes_received) values_by_participant[pid][r["hole_number"] - 1].append( EclecticHoleValue(round_index=index_by_sequence[r["sequence"]], value=value) ) result_type = "gross" if method == "eclectic_gross" else "net" if method == "eclectic_net" else "stableford" for e in entries: values_by_hole = values_by_participant.get(e.tournament_participant_id) if values_by_hole is None: continue result = eclectic_best_per_hole(values_by_hole, result_type) if not result.holes: continue e.eclectic_total = result.total e.eclectic_holes = [ EclecticHoleCellOut( hole_number=h.hole_number, par=par_by_hole[h.hole_number], value=h.value, round_number=sequence_by_index[h.round_index], ) for h in result.holes ] # --------------------------------------------------------------------------- # 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 # --------------------------------------------------------------------------- # Flaggturnering: GPS-flaggplanting + runde 2+ (migrasjon 070, 2026-08-14). # Speiler rounds.py sin frittstående-variant presist -- se der for full # begrunnelse (egen isolert overflow-tabell, server-side beregnet hull/lap, # aldri stolt blindt på fra klienten). Ulikt frittstående runder er # scoreregistrering her SELV-only (user_is_own_tournament_participant), # samme presedens som update_hole over -- ikke flight-styrt. # --------------------------------------------------------------------------- async def _flag_current_position_org( conn, tournament_round_participant_id: str, hole_config: str ) -> tuple[int, int]: """Org-ekvivalent av rounds.py sin _flag_current_position -- samme logikk, men leser tournament_round_hole/tournament_round_hole_flag_ overflow, og play_order kommer fra played_hole_numbers(hole_config) (ingen start_hole-rotasjon på org-siden).""" play_order = played_hole_numbers(hole_config) lap1_rows = await conn.fetch( "SELECT hole_number FROM tournament_round_hole " "WHERE tournament_round_participant_id = $1 AND gross_strokes IS NOT NULL", tournament_round_participant_id, ) lap1_played = {r["hole_number"] for r in lap1_rows} for h in play_order: if h not in lap1_played: return 1, h lap = 2 while True: overflow_rows = await conn.fetch( "SELECT hole_number FROM tournament_round_hole_flag_overflow " "WHERE tournament_round_participant_id = $1 AND lap = $2 AND played", tournament_round_participant_id, lap, ) overflow_played = {r["hole_number"] for r in overflow_rows} for h in play_order: if h not in overflow_played: return lap, h lap += 1 class FlagPlantIn(BaseModel): lat: float = Field(ge=-90, le=90) lng: float = Field(ge=-180, le=180) hole_number: int = Field(ge=1, le=18) lap: int = Field(ge=1) on_green: bool = False distance_to_pin_cm: int | None = Field(default=None, ge=0) class FlagPlantOut(BaseModel): id: str lap: int hole_number: int lat: float lng: float on_green: bool distance_to_pin_cm: int | None planted_by_user_id: str planted_at: str def _flag_plant_out(row) -> FlagPlantOut: return FlagPlantOut( id=row["id"], lap=row["lap"], hole_number=row["hole_number"], lat=row["lat"], lng=row["lng"], on_green=row["on_green"], distance_to_pin_cm=row["distance_to_pin_cm"], planted_by_user_id=row["planted_by_user_id"], planted_at=row["planted_at"].isoformat(), ) async def _get_round_participant_or_404(conn, tournament_id: str, round_id: str, round_participant_id: str): round_row = await conn.fetchrow( """ SELECT 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.") rp = await conn.fetchrow( "SELECT id, tournament_participant_id::text AS tournament_participant_id " "FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2", round_participant_id, round_id, ) if rp is None: raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.") return round_row, rp async def _require_own_tournament_participant(conn, organization_id: str, tournament_participant_id: str, user_id: str) -> None: if not await user_is_own_tournament_participant(conn, organization_id, tournament_participant_id, user_id): raise app_error( 403, "NOT_TOURNAMENT_PARTICIPANT", "Du kan kun plante/registrere flagg for deg selv (eller være organisasjonsadministrator).", ) @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" "/participants/{round_participant_id}/flag-plant", response_model=FlagPlantOut, ) async def plant_flag( tournament_id: str, round_id: str, round_participant_id: str, body: FlagPlantIn, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> FlagPlantOut: if body.on_green and body.distance_to_pin_cm is None: raise app_error(400, "VALIDATION_FAILED", "Oppgi avstand til hullet når flagget plantes på green.") if not body.on_green and body.distance_to_pin_cm is not None: raise app_error( 400, "VALIDATION_FAILED", "Avstand til hullet gir kun mening når flagget er plantet på green." ) async with org_connection(organization_id) as conn: round_row, rp = await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id) await _require_own_tournament_participant(conn, organization_id, rp["tournament_participant_id"], user.user_id) actual_lap, actual_hole = await _flag_current_position_org( conn, round_participant_id, round_row["hole_config"] ) if (body.lap, body.hole_number) != (actual_lap, actual_hole): raise app_error( 400, "VALIDATION_FAILED", f"Registrert scoredata sier spilleren er på hull {actual_hole} (lap {actual_lap}), " f"ikke hull {body.hole_number} (lap {body.lap}). Før inn score for gjenstående hull først.", ) async with conn.transaction(): await conn.execute( "DELETE FROM tournament_round_participant_flag_plant WHERE tournament_round_participant_id = $1", round_participant_id, ) row = await conn.fetchrow( """ INSERT INTO tournament_round_participant_flag_plant (organization_id, tournament_round_participant_id, lap, hole_number, lat, lng, on_green, distance_to_pin_cm, planted_by_user_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id::text AS id, lap, hole_number, lat, lng, on_green, distance_to_pin_cm, planted_by_user_id::text AS planted_by_user_id, planted_at """, organization_id, round_participant_id, body.lap, body.hole_number, body.lat, body.lng, body.on_green, body.distance_to_pin_cm, user.user_id, ) return _flag_plant_out(row) @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" "/participants/{round_participant_id}/flag-plant", response_model=FlagPlantOut | None, ) async def get_flag_plant( tournament_id: str, round_id: str, round_participant_id: str, organization_id: str = Depends(get_authorized_org), ) -> FlagPlantOut | None: async with org_connection(organization_id) as conn: await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id) row = await conn.fetchrow( """ SELECT id::text AS id, lap, hole_number, lat, lng, on_green, distance_to_pin_cm, planted_by_user_id::text AS planted_by_user_id, planted_at FROM tournament_round_participant_flag_plant WHERE tournament_round_participant_id = $1 """, round_participant_id, ) return _flag_plant_out(row) if row is not None else None @router.delete( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" "/participants/{round_participant_id}/flag-plant", status_code=204, ) async def unplant_flag( tournament_id: str, round_id: str, round_participant_id: str, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> None: async with org_connection(organization_id) as conn: _, rp = await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id) await _require_own_tournament_participant(conn, organization_id, rp["tournament_participant_id"], user.user_id) await conn.execute( "DELETE FROM tournament_round_participant_flag_plant WHERE tournament_round_participant_id = $1", round_participant_id, ) class FlagOverflowHoleIn(BaseModel): gross_strokes: int = Field(gt=0, le=20) class FlagOverflowHoleOut(BaseModel): lap: int hole_number: int gross_strokes: int | None played: bool @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" "/participants/{round_participant_id}/flag-overflow", response_model=list[FlagOverflowHoleOut], ) async def list_flag_overflow_holes( tournament_id: str, round_id: str, round_participant_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[FlagOverflowHoleOut]: async with org_connection(organization_id) as conn: await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id) rows = await conn.fetch( "SELECT lap, hole_number, gross_strokes, played FROM tournament_round_hole_flag_overflow " "WHERE tournament_round_participant_id = $1 ORDER BY lap, hole_number", round_participant_id, ) return [FlagOverflowHoleOut(**dict(r)) for r in rows] @router.put( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" "/participants/{round_participant_id}/flag-overflow/{lap}/holes/{hole_number}", response_model=FlagOverflowHoleOut, ) async def update_flag_overflow_hole( tournament_id: str, round_id: str, round_participant_id: str, lap: int, hole_number: int, body: FlagOverflowHoleIn, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> FlagOverflowHoleOut: if lap < 2: raise app_error(400, "VALIDATION_FAILED", "Lap må være 2 eller høyere -- lap 1 føres som vanlig scoreføring.") if not (1 <= hole_number <= 18): raise app_error(400, "VALIDATION_FAILED", "Ugyldig hullnummer.") async with org_connection(organization_id) as conn: round_row, rp = await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id) await _require_own_tournament_participant(conn, organization_id, rp["tournament_participant_id"], user.user_id) actual_lap, _ = await _flag_current_position_org(conn, round_participant_id, round_row["hole_config"]) if lap > actual_lap: raise app_error( 400, "VALIDATION_FAILED", f"Lap {lap} kan ikke føres ennå -- spilleren er fortsatt i lap {actual_lap}.", ) row = await conn.fetchrow( """ INSERT INTO tournament_round_hole_flag_overflow (organization_id, tournament_round_participant_id, lap, hole_number, gross_strokes, played) VALUES ($1, $2, $3, $4, $5, true) ON CONFLICT (tournament_round_participant_id, lap, hole_number) DO UPDATE SET gross_strokes = $5, played = true RETURNING lap, hole_number, gross_strokes, played """, organization_id, round_participant_id, lap, hole_number, body.gross_strokes, ) return FlagOverflowHoleOut(**dict(row)) # --------------------------------------------------------------------------- # Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) -- speiler # rounds.py sin frittstående-variant presist. Styrt av tournament.flag_map_ # visible (ethvert org-medlem kan slå av/på via update_tournament, # tournaments.py -- se TournamentUpdate.flag_map_visible). Bevisst KUN # org-medlem-/deltaker-tilgang (samme presedens som flag_round_result over) # -- ingen offentlig tilskuer-gren, individuelle turneringer har ingen # offentlig spectator-side i det hele tatt ennå (ulikt frittstående # rundevisning), se ADR-067 for avklaringen. # --------------------------------------------------------------------------- class FlagMapEntryOut(BaseModel): participant_id: str display_name: str lap: int hole_number: int lat: float lng: float on_green: bool distance_to_pin_cm: int | None planted_at: str class FlagMapOut(BaseModel): visible_to_all: bool flags: list[FlagMapEntryOut] @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/flag-map", response_model=FlagMapOut, ) async def get_flag_map( tournament_id: str, round_id: str, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> FlagMapOut: async with org_connection(organization_id) as conn: round_row = await conn.fetchrow( """ SELECT t.flag_map_visible 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.") visible_to_all = round_row["flag_map_visible"] base_query = """ SELECT trpfp.tournament_round_participant_id::text AS participant_id, p.display_name AS display_name, trpfp.lap, trpfp.hole_number, trpfp.lat, trpfp.lng, trpfp.on_green, trpfp.distance_to_pin_cm, trpfp.planted_at FROM tournament_round_participant_flag_plant trpfp JOIN tournament_round_participant trp ON trp.id = trpfp.tournament_round_participant_id 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 """ if visible_to_all: rows = await conn.fetch(base_query, round_id) else: rows = await conn.fetch( base_query + " AND p.user_id = $2", round_id, user.user_id, ) return FlagMapOut( visible_to_all=visible_to_all, flags=[ FlagMapEntryOut( participant_id=r["participant_id"], display_name=r["display_name"], lap=r["lap"], hole_number=r["hole_number"], lat=r["lat"], lng=r["lng"], on_green=r["on_green"], distance_to_pin_cm=r["distance_to_pin_cm"], planted_at=r["planted_at"].isoformat(), ) for r in rows ], )