""" Turneringsoppsett: turneringer, lag, roster, økter. Autorisasjonsgrense (se plan): kun org-medlemskap (get_authorized_org) kreves her — dette er organisator-arbeid. Å kreve at brukeren allerede er rostret på et lag ville vært sirkulært for lag-/roster-opprettelse (ingen kan være rostret på et lag som ikke finnes ennå). """ import json import secrets import traceback from datetime import date, datetime, timedelta, timezone import asyncpg from fastapi import APIRouter, Depends, UploadFile from pydantic import BaseModel, Field from handicap_engine import allocate_over_played_holes from ..auth import CurrentUser, get_authorized_org, get_current_user from ..blind_draw import locked_team_ids, own_team_ids from ..config import settings from ..db import org_connection from ..email import send_session_result_email from ..errors import app_error, translate_db_errors from ..handicap import compute_and_store_side_handicaps, parse_allowance_config from .auth import _hash_secret from ..team_authz import is_org_admin, is_org_member, user_is_team_captain, user_is_tournament_participant from .scoring import apply_concession, played_hole_numbers, recompute_and_cache_match_state from .. import storage router = APIRouter() # --------------------------------------------------------------------------- # Turneringer # --------------------------------------------------------------------------- class TournamentCreate(BaseModel): name: str = Field(min_length=1, max_length=200) start_date: date | None = None end_date: date | None = None # ADR-037: 'team' (dagens Ryder Cup-modell, uendret default) eller # 'individual' (flatt felt, egen tabellstruktur -- se # 040_individual_tournaments.sql). scoring_method er kun meningsfullt # for individual, håndhevet i app-laget når runder/score bygges på den. format_type: str = Field(default="team", pattern="^(team|individual)$") scoring_method: str | None = Field( default=None, pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag|" "eclectic_gross|eclectic_net|eclectic_stableford)$", ) # KUN meningsfullt når scoring_method='bingo_bango_bongo' -- valgfri # organisator-innstilling (samme som frittstående runders round. # bbb_sweep_bonus_enabled), ikke fast påslått. bbb_sweep_bonus_enabled: bool = False _TOURNAMENT_COLUMNS = """ id::text, name, status::text, start_date, end_date, visibility, description, registration_deadline, registration_capacity, registration_overflow_policy, registration_requires_approval, hero_image_key, join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible, cut_after_round, cut_size, cut_applied_at, tiebreak_winner_method, tiebreak_field_method, tiebreak_playoff_rule, net_display_style """ # Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A). _JOIN_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" def _generate_join_code() -> str: return "".join(secrets.choice(_JOIN_CODE_ALPHABET) for _ in range(6)) class Tournament(BaseModel): id: str name: str status: str start_date: date | None end_date: date | None visibility: str description: str | None registration_deadline: datetime | None registration_capacity: int | None registration_overflow_policy: str registration_requires_approval: bool hero_image_key: str | None hero_image_url: str | None join_code: str format_type: str scoring_method: str | None bbb_sweep_bonus_enabled: bool # Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067). flag_map_visible: bool # Cut (migrasjon 083, 2026-08-18) -- KUN meningsfullt for individuelle # stroke play/net/stableford-turneringer, se app/routers/ # individual_tournaments.py sin apply_cut. cut_applied_at er null inntil # organisator faktisk trykker "Anvend cut" -- cut_after_round/cut_size # alene er bare KONFIGURASJON, ikke en aktiv cut. cut_after_round: int | None cut_size: int | None cut_applied_at: datetime | None # Tiebreak (migrasjon 085, 2026-08-18) -- KUN meningsfullt for # individuelle stroke play/net/stableford-turneringer, se # individual_tournaments.py sin _attach_stroke_play_columns. Påvirker # ALDRI Cut-grensen (ADR-087/088) -- kun sluttresultatets # plasseringsvisning. 'none' (default) = delt plassering, uendret # oppførsel. To UAVHENGIGE innstillinger -- vinneren og resten av # feltet kan ha ulik metode. tiebreak_winner_method: str tiebreak_field_method: str # Omspill (migrasjon 086, 2026-08-18) -- 'playoff' er KUN gyldig for # tiebreak_winner_method (ikke felt-metoden), se individual_ # tournaments.py sin kommentar for hvorfor rangeringen behandler den # likt som 'none'. tiebreak_playoff_rule er fritekst organisator # setter (f.eks. "Sudden death fra hull 18") -- vist proaktivt i # Oppsett OG på leaderboardet, ikke bare når en uavgjort faktisk skjer. tiebreak_playoff_rule: str | None # Visningsmodus for netto-scoring (migrasjon 092, 2026-08-23) -- "Jakter # Jesper": IKKE en ny scoring_method (sluttallet er matematisk identisk # med stroke_net sitt netto-til-par), kun en annen presentasjon av # LØPENDE tall midt i runden -- se individual_tournaments.py sin # _attach_stroke_play_columns. KUN gyldig sammen med # scoring_method='stroke_net', håndhevet i update_tournament/ # create_tournament under. net_display_style: str def _tournament_from_row(row) -> Tournament: """Samme mønster som auth.py sin avatar_url: DB-kolonnen forblir `hero_image_key` (den nøytrale, lagrings-uavhengige verdien), men responsen får i tillegg en ferdig-konstruert URL -- organisator- frontend skal aldri selv måtte kjenne til MinIO-bucket/base-URL.""" data = dict(row) key = data["hero_image_key"] return Tournament(**data, hero_image_url=storage.public_url(key) if key else None) @router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament]) async def list_tournaments( organization_id: str = Depends(get_authorized_org), ) -> list[Tournament]: # ADR-030: dashbord-kortet skal vise turneringens FAKTISKE datospenn. # `tournament.start_date`/`end_date` er egne, eksplisitte felt (ADR-015) # som ingen UI noensinne har hatt en vei til å SETTE -- alltid NULL i # praksis. Eksplisitt satt verdi vinner hvis den noensinne blir satt # (fremtidig PATCH-vei), ellers utledes spennet fra øktenes # `scheduled_at` -- informasjonen finnes jo allerede der. async with org_connection(organization_id) as conn: rows = await conn.fetch( """ SELECT t.id::text, t.name, t.status::text, COALESCE(t.start_date, s.min_date) AS start_date, COALESCE(t.end_date, s.max_date) AS end_date, t.visibility, t.description, t.registration_deadline, t.registration_capacity, t.registration_overflow_policy, t.registration_requires_approval, t.hero_image_key, t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled, t.flag_map_visible, t.cut_after_round, t.cut_size, t.cut_applied_at, t.tiebreak_winner_method, t.tiebreak_field_method, t.tiebreak_playoff_rule, t.net_display_style FROM tournament t LEFT JOIN ( SELECT tournament_id, min(scheduled_at)::date AS min_date, max(scheduled_at)::date AS max_date FROM session WHERE scheduled_at IS NOT NULL GROUP BY tournament_id ) s ON s.tournament_id = t.id ORDER BY t.created_at DESC """ ) return [_tournament_from_row(r) for r in rows] @router.post("/orgs/{organization_id}/tournaments", response_model=Tournament, status_code=201) async def create_tournament( body: TournamentCreate, organization_id: str = Depends(get_authorized_org), ) -> Tournament: async with org_connection(organization_id) as conn, translate_db_errors(): # join_code er globalt unikt (migrasjon 011) -- kollisjon er # astronomisk usannsynlig (33^6 ≈ 1,3 mrd. kombinasjoner mot et # lite antall turneringer totalt), men en liten retry-løkke koster # ingenting og gjør oppførselen presis i stedet for å anta. row = None for _ in range(5): try: row = await conn.fetchrow( f""" INSERT INTO tournament (organization_id, name, start_date, end_date, join_code, format_type, scoring_method, bbb_sweep_bonus_enabled) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING {_TOURNAMENT_COLUMNS} """, organization_id, body.name, body.start_date, body.end_date, _generate_join_code(), body.format_type, body.scoring_method, body.bbb_sweep_bonus_enabled, ) break except asyncpg.UniqueViolationError: continue if row is None: raise app_error(500, "VALIDATION_FAILED", "Klarte ikke å generere en unik invitasjonskode. Prøv igjen.") return _tournament_from_row(row) class TournamentUpdate(BaseModel): """Alle felt valgfrie -- kun de som faktisk sendes med blir endret (PATCH-semantikk via Pydantic sin exclude_unset, se update_tournament).""" # name/start_date/end_date (2026-08-18) -- kolonnene har eksistert # siden 001/006, men INGEN UI har noensinne hatt en vei til å SETTE # dem (ADR-030 sin kommentar i list_tournaments dokumenterer nettopp # dette -- "ingen UI noensinne har hatt en vei til å SETTE" start_date/ # end_date, alltid utledet fra økter i stedet). Lagt til her etter at # bruker påpekte at org-oppsettet generelt mangler nesten alle # turnering-parametrene en organisator faktisk trenger. name: str | None = Field(default=None, min_length=1, max_length=200) start_date: date | None = None end_date: date | None = None status: str | None = Field(default=None, pattern="^(draft|active|completed|archived)$") visibility: str | None = Field(default=None, pattern="^(public|org|participants)$") format_type: str | None = Field(default=None, pattern="^(team|individual)$") scoring_method: str | None = Field( default=None, pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag|" "eclectic_gross|eclectic_net|eclectic_stableford)$", ) description: str | None = None registration_deadline: datetime | None = None registration_capacity: int | None = Field(default=None, gt=0) registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$") registration_requires_approval: bool | None = None bbb_sweep_bonus_enabled: bool | None = None # Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067) # -- ethvert org-medlem (samme tilgang som resten av denne PATCH-en, se # get_authorized_org under). Standard AV, samme "skjult til avslørt"- # filosofi som visibility. flag_map_visible: bool | None = None # Cut-KONFIGURASJON (migrasjon 083, 2026-08-18) -- fritt endre/nullstille # helt til organisator faktisk trykker "Anvend cut" (se apply_cut i # individual_tournaments.py, som setter cut_applied_at -- IKKE satt her, # denne PATCH-en er kun for selve innstillingene). cut_after_round: int | None = Field(default=None, ge=1) cut_size: int | None = Field(default=None, gt=0) # Tiebreak (migrasjon 085, 2026-08-18) -- fritt valgbar av organisator, # se Tournament sin kommentar. 'playoff' (migrasjon 086) er KUN gyldig # for vinner-metoden, ikke felt-metoden. tiebreak_winner_method: str | None = Field( default=None, pattern="^(none|countback|lowest_hcp|playoff|lowest_playing_hcp)$" ) tiebreak_field_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp|lowest_playing_hcp)$") tiebreak_playoff_rule: str | None = Field(default=None, max_length=2000) # Visningsmodus for netto-scoring (migrasjon 092, 2026-08-23) -- se # Tournament sin kommentar. Gyldigheten mot scoring_method='stroke_net' # håndheves i update_tournament under (ikke her -- denne modellen kjenner # ikke turneringens gjeldende scoring_method). net_display_style: str | None = Field(default=None, pattern="^(standard|hunt)$") # hero_image_key er BEVISST IKKE med her -- den settes kun via # upload_hero_image under, aldri som en fritekst-streng klienten sender, # siden den nå alltid kommer fra en ekte, server-konvertert opplasting. @router.patch("/orgs/{organization_id}/tournaments/{tournament_id}", response_model=Tournament) async def update_tournament( tournament_id: str, body: TournamentUpdate, organization_id: str = Depends(get_authorized_org), ) -> Tournament: # exclude_unset: kun felt klienten FAKTISK sendte skal skrives -- et # utelatt felt skal IKKE nullstilles (ekte PATCH-semantikk, ikke PUT). updates = body.model_dump(exclude_unset=True) if not updates: raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.") # Trygt mot injeksjon: nøklene kommer KUN fra TournamentUpdate sine egne # feltnavn (en fast, kjent hviteliste), aldri fra en rå streng brukeren # sender inn. # `status` er -- ulikt de andre feltene her -- en EKTE Postgres ENUM-type # (tournament_status, migrasjon 001), ikke text+CHECK. asyncpg sender # parameteren som ukjent/text; et eksplisitt cast trengs for at # tildelingen skal løses riktig. set_clauses = [ f"{key} = ${i}::tournament_status" if key == "status" else f"{key} = ${i}" for i, key in enumerate(updates, start=1) ] values = list(updates.values()) values.append(tournament_id) async with org_connection(organization_id) as conn, translate_db_errors(): # Fail-loudly (migrasjon 092): 'hunt' gir ingen mening utenfor # stroke_net -- de andre metodene har ingen netto-til-par-visning å # endre presentasjonen av. Sjekker den EFFEKTIVE tilstanden ETTER # denne PATCH-en (kan selv sette scoring_method/net_display_style i # samme kall) -- dekker BEGGE retninger: (a) satt til 'hunt' på en # ikke-stroke_net-turnering, (b) scoring_method byttet bort fra # stroke_net mens 'hunt' henger igjen fra før, uendret i denne # PATCH-en. Unngår en stille, meningsløs kombinasjon i databasen. if "scoring_method" in updates or "net_display_style" in updates: current = await conn.fetchrow( "SELECT scoring_method, net_display_style FROM tournament WHERE id = $1", tournament_id ) effective_method = updates.get("scoring_method", current["scoring_method"] if current else None) effective_style = updates.get("net_display_style", current["net_display_style"] if current else "standard") if effective_style == "hunt" and effective_method != "stroke_net": raise app_error( 400, "VALIDATION_FAILED", "Jakter Jesper-visningen krever scoring_method='stroke_net'.", ) row = await conn.fetchrow( f""" UPDATE tournament SET {', '.join(set_clauses)} WHERE id = ${len(values)} RETURNING {_TOURNAMENT_COLUMNS} """, *values, ) if row is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") return _tournament_from_row(row) @router.delete("/orgs/{organization_id}/tournaments/{tournament_id}", status_code=204) async def delete_tournament( tournament_id: str, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> None: """Sletter HELE turneringen -- permanent, ingen angre (ADR-086, 2026-08-18). Strengere sperre enn resten av denne fila (kun org- medlemskap for oppsett) -- kun org-eier/admin, siden dette er kategorisk mer ødeleggende enn noe annet i turneringsoppsettet (fjerner ALL score-/matchdata permanent, ikke bare tomt oppsett). Eksplisitt, RIKTIG REKKEFØLGE-sletting i stedet for å stole blindt på én kaskaderende `DELETE FROM tournament` -- to steder i skjemaet har en RESTRICT-FK midt INNI kaskade-grafen (`match_participant. team_roster_id` og `tournament_round_participant.tournament_ participant_id`, begge migrasjon 001/040), som kan avvise en enkelt-setnings-kaskade avhengig av Postgres sin interne kaskade-rekkefølge når det faktisk finnes ekte kamper/runder. Fjerner derfor RESTRICT-blokkerende rader FØR det de peker på slettes. `org_connection` sin egen transaksjon gjør alt atomisk uansett -- feiler ett steg, ruller alt tilbake.""" async with org_connection(organization_id) as conn, translate_db_errors(): if not await is_org_admin(conn, organization_id, user.user_id): raise app_error( 403, "NOT_ORG_ADMIN", "Kun organisasjonseier/administrator kan slette en turnering.", ) exists = await conn.fetchval( "SELECT EXISTS(SELECT 1 FROM tournament WHERE id = $1)", tournament_id ) if not exists: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") # Lag-format: fjern match_participant FØR team_roster forsvinner # (RESTRICT-FK-en over) -- kaskaderer selv videre til hole_score. await conn.execute( """ DELETE FROM match_participant WHERE match_id IN ( SELECT m.id FROM match m JOIN session s ON m.session_id = s.id WHERE s.tournament_id = $1 ) """, tournament_id, ) await conn.execute( "DELETE FROM match WHERE session_id IN (SELECT id FROM session WHERE tournament_id = $1)", tournament_id, ) await conn.execute("DELETE FROM session WHERE tournament_id = $1", tournament_id) await conn.execute( "DELETE FROM team_roster WHERE team_id IN (SELECT id FROM team WHERE tournament_id = $1)", tournament_id, ) await conn.execute("DELETE FROM team WHERE tournament_id = $1", tournament_id) # Individuell-format: samme prinsipp -- tournament_round_participant # FØR tournament_participant/tournament_round forsvinner. await conn.execute( """ DELETE FROM tournament_round_participant WHERE tournament_round_id IN (SELECT id FROM tournament_round WHERE tournament_id = $1) OR tournament_participant_id IN (SELECT id FROM tournament_participant WHERE tournament_id = $1) """, tournament_id, ) await conn.execute("DELETE FROM tournament_round WHERE tournament_id = $1", tournament_id) await conn.execute("DELETE FROM tournament_participant WHERE tournament_id = $1", tournament_id) # Resten (klasser, sponsorer, påmelding, feed/chat-meldinger, # OoM-lenke) kaskaderer trygt fra selve tournament-raden -- ingen # RESTRICT-FK-er igjen på dette punktet. await conn.execute("DELETE FROM tournament WHERE id = $1", tournament_id) @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/hero-image", response_model=Tournament, ) async def upload_hero_image( tournament_id: str, file: UploadFile, organization_id: str = Depends(get_authorized_org), ) -> Tournament: """Ekte multipart-opplasting (IKKE en presignert nettleser-URL): filen går gjennom API-et, som konverterer den til AVIF før den lagres i MinIO -- se app/storage.py sin moduldocstring for hvorfor.""" if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES: raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.") raw = await file.read(storage.MAX_UPLOAD_BYTES + 1) if len(raw) > storage.MAX_UPLOAD_BYTES: raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).") 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.") try: key = await storage.upload_image("tournaments", tournament_id, raw) except storage.InvalidImageError: raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.") row = await conn.fetchrow( f"UPDATE tournament SET hero_image_key = $1 WHERE id = $2 RETURNING {_TOURNAMENT_COLUMNS}", key, tournament_id, ) return _tournament_from_row(row) @router.delete( "/orgs/{organization_id}/tournaments/{tournament_id}/hero-image", response_model=Tournament, ) async def delete_hero_image( tournament_id: str, organization_id: str = Depends(get_authorized_org), ) -> Tournament: """Samme mønster som DELETE /auth/profile/avatar -- nullstiller kun referansen, sletter ikke selve MinIO-objektet (samme aksepterte forenkling som avatar-fjerning).""" async with org_connection(organization_id) as conn: row = await conn.fetchrow( f"UPDATE tournament SET hero_image_key = NULL WHERE id = $1 RETURNING {_TOURNAMENT_COLUMNS}", tournament_id, ) if row is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") return _tournament_from_row(row) # --------------------------------------------------------------------------- # Lag (ADR-011: maks to lag per turnering, håndhevet her — ikke i skjemaet — # slik at match-modellen kan holdes generell for fremtidige turneringstyper) # --------------------------------------------------------------------------- class TeamCreate(BaseModel): name: str = Field(min_length=1, max_length=200) color: str | None = None class Team(BaseModel): id: str name: str color: str | None @router.get("/orgs/{organization_id}/tournaments/{tournament_id}/teams", response_model=list[Team]) async def list_teams( tournament_id: str, organization_id: str, user: CurrentUser = Depends(get_current_user), ) -> list[Team]: """2026-07-21 (deltaker-tilgang-runden): org-medlem ELLER deltaker i denne turneringen, samme mønster som scoring.py sin get_scorecard -- laste team-navn/-farge er en forutsetning for at scorekort-siden i det hele tatt kan rendres for en deltaker uten org-medlemskap.""" 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.") if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant( conn, organization_id, tournament_id, user.user_id ): raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.") rows = await conn.fetch( "SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at", tournament_id, ) return [Team(**dict(r)) for r in rows] @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/teams", response_model=Team, status_code=201, ) async def create_team( tournament_id: str, body: TeamCreate, organization_id: str = Depends(get_authorized_org), ) -> Team: async with org_connection(organization_id) as conn, translate_db_errors(): # Lås turneringsraden slik at to samtidige forespørsler ikke begge kan # lese "1 lag" og begge sette inn et andre/tredje lag (TOCTOU). locked = await conn.fetchrow( "SELECT id FROM tournament WHERE id = $1 AND organization_id = $2 FOR UPDATE", tournament_id, organization_id, ) if locked is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") existing = await conn.fetchval( "SELECT count(*) FROM team WHERE tournament_id = $1", tournament_id ) if existing >= 2: raise app_error( 409, "LIMIT_REACHED", "Turneringen har allerede to lag (ADR-011: nøyaktig to lag)." ) row = await conn.fetchrow( """ INSERT INTO team (organization_id, tournament_id, name, color) VALUES ($1, $2, $3, $4) RETURNING id::text, name, color """, organization_id, tournament_id, body.name, body.color, ) return Team(**dict(row)) # --------------------------------------------------------------------------- # Turnering-nivå walkover/konsesjon (ADR-024) # # v1 er låst til nøyaktig to lag (ADR-011), så det finnes bare ÉN motstander # uansett hvor mange sesjoner/matcher turneringen har -- "gi opp turneringen" # er derfor bare "gi opp hver ikke-avgjorte match laget har" i én operasjon, # gjenbruker match-nivå-logikken (apply_concession, scoring.py) per match. # Samme autorisasjon som enkelt-match-konsesjon: kaptein for laget som gir # seg selv, eller org-eier/admin. # --------------------------------------------------------------------------- class ConcedeTournamentRequest(BaseModel): conceding_team_id: str class ConcedeTournamentOut(BaseModel): matches_conceded: int @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/concede", response_model=ConcedeTournamentOut, status_code=201, ) async def concede_tournament( tournament_id: str, body: ConcedeTournamentRequest, organization_id: str, user: CurrentUser = Depends(get_current_user), ) -> ConcedeTournamentOut: async with org_connection(organization_id) as conn, translate_db_errors(): team = await conn.fetchrow( "SELECT tournament_id::text AS tournament_id FROM team WHERE id = $1", body.conceding_team_id, ) if team is None or team["tournament_id"] != tournament_id: raise app_error(404, "NOT_FOUND", "Laget finnes ikke i denne turneringen.") if not await user_is_team_captain(conn, organization_id, body.conceding_team_id, user.user_id): raise app_error( 403, "NOT_TEAM_CAPTAIN", "Du må være kaptein for laget som gir seg (eller organisasjonsadministrator) for å gi opp turneringen.", ) # FOR UPDATE OF m: låser alle berørte match-rader FØR de endres, samme # race-vern som enkelt-match-konsesjon og recompute_and_cache_match_state. matches = await conn.fetch( """ SELECT m.id::text AS id, m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id, s.points_per_match::float AS points_per_match FROM match m JOIN session s ON s.id = m.session_id WHERE s.tournament_id = $1 AND m.points_side_a IS NULL AND (m.team_a_id = $2 OR m.team_b_id = $2) FOR UPDATE OF m """, tournament_id, body.conceding_team_id, ) for m in matches: side = "a" if m["team_a_id"] == body.conceding_team_id else "b" await apply_concession(conn, m["id"], side, m["points_per_match"], tournament_id) return ConcedeTournamentOut(matches_conceded=len(matches)) # --------------------------------------------------------------------------- # Roster (spiller-pool per lag, ADR-007) # --------------------------------------------------------------------------- class RosterEntryCreate(BaseModel): player_id: str is_captain: bool = False # None => fryses fra player.handicap_index ved innsetting (ADR-007). handicap_index_snapshot: float | None = None # Konkurranseklasse (2026-08-03) -- valgfri, se 053_tournament_classes.sql. class_id: str | None = None class RosterEntry(BaseModel): id: str player_id: str display_name: str handicap_index_snapshot: float | None is_captain: bool class_id: str | None class_name: str | None _ROSTER_COLUMNS = """ tr.id::text, tr.player_id::text, tr.handicap_index_snapshot::float AS handicap_index_snapshot, tr.is_captain, p.display_name, tr.class_id::text AS class_id, tc.name AS class_name """ @router.get("/orgs/{organization_id}/teams/{team_id}/roster", response_model=list[RosterEntry]) async def list_roster( team_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[RosterEntry]: async with org_connection(organization_id) as conn: rows = await conn.fetch( f""" SELECT {_ROSTER_COLUMNS} FROM team_roster tr JOIN player p ON p.id = tr.player_id LEFT JOIN tournament_class tc ON tc.id = tr.class_id WHERE tr.team_id = $1 ORDER BY p.display_name """, team_id, ) return [RosterEntry(**dict(r)) for r in rows] @router.post( "/orgs/{organization_id}/teams/{team_id}/roster", response_model=RosterEntry, status_code=201, ) async def add_roster_entry( team_id: str, body: RosterEntryCreate, organization_id: str = Depends(get_authorized_org), ) -> RosterEntry: async with org_connection(organization_id) as conn, translate_db_errors(): team = await conn.fetchrow("SELECT tournament_id FROM team WHERE id = $1", team_id) if team is None: raise app_error(404, "NOT_FOUND", "Laget finnes ikke.") # Skjemaet håndhever kun unikhet PER lag (UNIQUE(team_id, player_id)). # Uten denne sjekken kunne en spiller rostres på BEGGE lag i samme # turnering, og own_team_ids() (blind draw-filteret i matches.py) ville # da regnet hen som "eget lag" på begge sider — hen ville sett # motstanderens skjulte oppstilling før lås (ADR-013). already_elsewhere = await conn.fetchval( """ SELECT EXISTS ( SELECT 1 FROM team_roster tr JOIN team t ON t.id = tr.team_id WHERE t.tournament_id = $1 AND tr.player_id = $2 AND tr.team_id <> $3 ) """, team["tournament_id"], body.player_id, team_id, ) if already_elsewhere: raise app_error( 409, "DUPLICATE", "Spilleren er allerede rostret på det andre laget i denne turneringen.", ) snapshot = body.handicap_index_snapshot if snapshot is None: snapshot = await conn.fetchval( "SELECT handicap_index FROM player WHERE id = $1", body.player_id ) # Samme "kun én kaptein per lag"-invariant som update_roster_entry # (ADR-023) -- håndhevet her også, selv om dagens frontend aldri # sender is_captain=true ved opprettelse, for at API-et er korrekt # uavhengig av klient. if body.is_captain: await conn.execute( "UPDATE team_roster SET is_captain = false WHERE team_id = $1", team_id ) row = await conn.fetchrow( """ WITH inserted AS ( INSERT INTO team_roster (organization_id, team_id, player_id, handicap_index_snapshot, is_captain, class_id) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, player_id, handicap_index_snapshot, is_captain, class_id ) SELECT inserted.id::text, inserted.player_id::text, inserted.handicap_index_snapshot::float AS handicap_index_snapshot, inserted.is_captain, p.display_name, inserted.class_id::text AS class_id, tc.name AS class_name FROM inserted JOIN player p ON p.id = inserted.player_id LEFT JOIN tournament_class tc ON tc.id = inserted.class_id """, organization_id, team_id, body.player_id, snapshot, body.is_captain, body.class_id, ) return RosterEntry(**dict(row)) class RosterEntryUpdate(BaseModel): """Alle felt valgfrie -- exclude_unset, samme PATCH-semantikk som TournamentUpdate/SponsorUpdate. `class_id` lagt til 2026-08-03 (bredere enn tidligere, da feltet KUN var is_captain: bool påkrevd) -- utvidet slik at klasse kan settes/endres uten å tvinge et samtidig kaptein-valg.""" is_captain: bool | None = None class_id: str | None = None @router.patch( "/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}", response_model=RosterEntry, ) async def update_roster_entry( team_id: str, roster_id: str, body: RosterEntryUpdate, organization_id: str = Depends(get_authorized_org), ) -> RosterEntry: updates = body.model_dump(exclude_unset=True) if not updates: raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.") # 2026-07-19 (Brukerroller-runden, ADR-023): kaptein er nå en reell # autorisasjonsrolle (se app/team_authz.py sin user_is_team_captain), så # "kun én kaptein per lag" håndheves eksplisitt her -- å sette en NY # kaptein fjerner automatisk merket fra en ev. tidligere kaptein på # SAMME lag, i samme transaksjon (org_connection åpner allerede én). # Å fjerne kapteinmerket (is_captain=false) rører ingen andre rader. async with org_connection(organization_id) as conn: if updates.get("is_captain"): await conn.execute( "UPDATE team_roster SET is_captain = false WHERE team_id = $1 AND id <> $2", team_id, roster_id, ) set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)] values = list(updates.values()) values.append(roster_id) values.append(team_id) row = await conn.fetchrow( f""" WITH updated AS ( UPDATE team_roster SET {', '.join(set_clauses)} WHERE id = ${len(values) - 1} AND team_id = ${len(values)} RETURNING id, player_id, handicap_index_snapshot, is_captain, class_id ) SELECT updated.id::text, updated.player_id::text, updated.handicap_index_snapshot::float AS handicap_index_snapshot, updated.is_captain, p.display_name, updated.class_id::text AS class_id, tc.name AS class_name FROM updated JOIN player p ON p.id = updated.player_id LEFT JOIN tournament_class tc ON tc.id = updated.class_id """, *values, ) if row is None: raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.") return RosterEntry(**dict(row)) class RosterMove(BaseModel): target_team_id: str @router.post( "/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}/move", response_model=RosterEntry, ) async def move_roster_entry( team_id: str, roster_id: str, body: RosterMove, organization_id: str = Depends(get_authorized_org), ) -> RosterEntry: """FEATURE_BACKLOG.md "Turneringsoppsett: flytte/slette spillere mellom lag" (notert 2026-07-25) -- atomisk flytting i én operasjon i stedet for to separate DELETE+POST-kall (unngår en mellomtilstand der spilleren midlertidig ikke er rostret noe sted).""" async with org_connection(organization_id) as conn, translate_db_errors(): current = await conn.fetchrow( "SELECT tr.id, t.tournament_id::text AS tournament_id " "FROM team_roster tr JOIN team t ON t.id = tr.team_id " "WHERE tr.id = $1 AND tr.team_id = $2", roster_id, team_id, ) if current is None: raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.") if body.target_team_id == team_id: raise app_error(400, "VALIDATION_FAILED", "Spilleren er allerede på dette laget.") target_team = await conn.fetchrow( "SELECT id FROM team WHERE id = $1 AND tournament_id = $2", body.target_team_id, current["tournament_id"], ) if target_team is None: raise app_error(404, "NOT_FOUND", "Mållaget finnes ikke i denne turneringen.") # Roster-raden er referert av match_participant.team_roster_id (ON # DELETE RESTRICT) -- en spiller som allerede er satt opp i minst én # match kan ikke flyttes trygt (matchens team_side ville da ikke # lenger stemme med spillerens faktiske lag). Krev at spilleren # fjernes fra matchen(e) FØRST, i stedet for å tillate en # inkonsistent mellomtilstand. already_in_match = await conn.fetchval( "SELECT EXISTS(SELECT 1 FROM match_participant WHERE team_roster_id = $1)", roster_id, ) if already_in_match: raise app_error( 409, "ALREADY_IN_MATCH", "Spilleren er allerede lagt til i en match og kan ikke flyttes -- " "fjern spilleren fra matchen(e) først.", ) # Kaptein-merket følger IKKE med til det nye laget -- "kun én # kaptein per lag" (ADR-023) håndheves allerede ELLERS via # update_roster_entry, men en flyttet spiller skal aldri bli # kaptein på et nytt lag uten et eksplisitt valg der. row = await conn.fetchrow( """ WITH moved AS ( UPDATE team_roster SET team_id = $1, is_captain = false WHERE id = $2 RETURNING id, player_id, handicap_index_snapshot, is_captain ) SELECT moved.id::text, moved.player_id::text, moved.handicap_index_snapshot::float AS handicap_index_snapshot, moved.is_captain, p.display_name FROM moved JOIN player p ON p.id = moved.player_id """, body.target_team_id, roster_id, ) return RosterEntry(**dict(row)) @router.delete( "/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}", status_code=204, ) async def delete_roster_entry( team_id: str, roster_id: str, organization_id: str = Depends(get_authorized_org), ) -> None: async with org_connection(organization_id) as conn: deleted = await conn.fetchval( "DELETE FROM team_roster WHERE id = $1 AND team_id = $2 RETURNING id", roster_id, team_id, ) if deleted is None: raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.") # --------------------------------------------------------------------------- # Økter (ADR-007: turnering = ordnet sekvens av økter) # --------------------------------------------------------------------------- class SessionCreate(BaseModel): sequence: int name: str | None = None format: str hole_config: str = "full_18" course_id: str points_per_match: float = 1.0 allowance_override: dict | None = None # ADR-012: 'stroke' (hole_score, motoren utleder netto) eller 'hole_result' # (match_hole_result, bare hvem som vant hullet). DB-default er 'stroke'. scoring_mode: str = "stroke" # Klokkeslett-modell (se plan): én starttid + ett intervall per økt. # match.tee_time utledes av disse i matches.py, IKKE lagret per match. scheduled_at: datetime | None = None tee_interval_minutes: int | None = None start_hole: int = 1 # Shotgun-start (migrasjon 088, ADR-096) -- samme mønster som # tournament_round for individuelle turneringer. 'consecutive' # (standard) = uendret oppførsel. 'shotgun' = alle matcher i økten # går ut SAMTIDIG, fra hvert sitt starthull (match.start_hole) i # stedet for staggert klokkeslett. start_mode: str = Field(default="consecutive", pattern="^(consecutive|shotgun)$") # KUN meningsfullt (og påkrevd) når format='shamble' -- antall av # sidens individuelle resultater som teller per hull ("beste N av M"). shamble_best_n: int | None = Field(default=None, ge=1) class SessionOut(BaseModel): id: str sequence: int name: str | None format: str hole_config: str course_id: str points_per_match: float allowance_override: dict | None scoring_mode: str scheduled_at: datetime | None tee_interval_minutes: int | None start_hole: int start_mode: str shamble_best_n: int | None locked_team_ids: list[str] revealed: bool # "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) -- KUN # meningsfullt for format IN ('singles','fourball') OG # scoring_mode='stroke' (samme betingelse fetch_individual_leaderboard # allerede håndhever ved lesing). Øktnivå, ikke turneringnivå -- se # ADR-106-tillegget for hvorfor (lagturneringer har ikke # scoring_method på selve turneringen, og har ingen tilsvarende # innstillings-UI på turneringnivå slik individuelle turneringer har). net_display_style: str def _session_out(row, locked: set[str]) -> SessionOut: return SessionOut( id=row["id"], sequence=row["sequence"], name=row["name"], format=row["format"], hole_config=row["hole_config"], course_id=row["course_id"], points_per_match=row["points_per_match"], allowance_override=json.loads(row["allowance_override"]) if row["allowance_override"] else None, scoring_mode=row["scoring_mode"], scheduled_at=row["scheduled_at"], tee_interval_minutes=row["tee_interval_minutes"], start_hole=row["start_hole"], start_mode=row["start_mode"], shamble_best_n=row["shamble_best_n"], locked_team_ids=sorted(locked), revealed=len(locked) >= 2, net_display_style=row["net_display_style"], ) async def _fetch_sessions(conn, tournament_id: str) -> list[SessionOut]: """Delt mellom den innloggede org-ruten under og det offentlige landingsside-endepunktet (app/routers/registration.py, ADR-018) -- samme spørring, samme blind draw-låsing, ikke duplisert to steder.""" rows = await conn.fetch( """ SELECT id::text, sequence, name, format, hole_config::text AS hole_config, course_id::text, points_per_match::float AS points_per_match, allowance_override::text AS allowance_override, scoring_mode, scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n, net_display_style FROM session WHERE tournament_id = $1 ORDER BY sequence """, tournament_id, ) result = [] for r in rows: locked = await locked_team_ids(conn, r["id"]) result.append(_session_out(r, locked)) return result @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/sessions", response_model=list[SessionOut], ) async def list_sessions( tournament_id: str, organization_id: str, user: CurrentUser = Depends(get_current_user), ) -> list[SessionOut]: """2026-07-21 (deltaker-tilgang-runden): org-medlem ELLER deltaker i denne turneringen, samme mønster som list_teams over -- innholdet er for øvrig identisk med det offentlige `/public/.../sessions`- endepunktet (ADR-018) for enhver som allerede kan se turneringen, så dette utvider ikke hva som er synlig, kun HVEM som kan nå det via denne org-scopede stien.""" 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.") if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant( conn, organization_id, tournament_id, user.user_id ): raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.") return await _fetch_sessions(conn, tournament_id) @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/sessions", response_model=SessionOut, status_code=201, ) async def create_session( tournament_id: str, body: SessionCreate, organization_id: str = Depends(get_authorized_org), ) -> SessionOut: allowance_json = json.dumps(body.allowance_override) if body.allowance_override is not None else None async with org_connection(organization_id) as conn, translate_db_errors(): row = await conn.fetchrow( """ INSERT INTO session (organization_id, tournament_id, sequence, name, format, hole_config, course_id, points_per_match, allowance_override, scoring_mode, scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13, $14, $15) RETURNING id::text, sequence, name, format, hole_config::text AS hole_config, course_id::text, points_per_match::float AS points_per_match, allowance_override::text AS allowance_override, scoring_mode, scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n, net_display_style """, organization_id, tournament_id, body.sequence, body.name, body.format, body.hole_config, body.course_id, body.points_per_match, allowance_json, body.scoring_mode, body.scheduled_at, body.tee_interval_minutes, body.start_hole, body.start_mode, body.shamble_best_n, ) return _session_out(row, set()) class SessionUpdate(BaseModel): """Bevisst IKKE format/scoring_mode/hole_config ennå -- disse har dypere strukturelle konsekvenser for allerede registrerte scores (bytter man scoring_mode havner gamle oppføringer i feil tabell; bytter man format endres reglene for match_participant) som fortjener egen, senere design. course_id er den ENESTE feltet som krever spesialhåndtering her (se _remap_course) -- resten er en vanlig exclude_unset-oppdatering.""" name: str | None = None scheduled_at: datetime | None = None tee_interval_minutes: int | None = None start_hole: int | None = Field(default=None, ge=1, le=18) start_mode: str | None = Field(default=None, pattern="^(consecutive|shotgun)$") points_per_match: float | None = None allowance_override: dict | None = None course_id: str | None = None # "Jakter Jesper"-visningsmodus (migrasjon 093) -- se SessionOut sin # kommentar. Gyldigheten mot format/scoring_mode håndheves i # update_session under (denne modellen kjenner ikke øktens gjeldende # format/scoring_mode). net_display_style: str | None = Field(default=None, pattern="^(standard|hunt)$") async def _remap_course(conn, session_id: str, new_course_id: str) -> None: """Reist av brukeren (2026-07-18): en organisator oppdager MIDT I RUNDEN at feil bane var satt opp -- de faktisk registrerte slagene er ekte og skal IKKE røres, men `match_participant.tee_id` peker til en tee som hører til DEN GAMLE banen, og må flyttes til en tilsvarende tee på den nye for at handicap-beregningen skal bli riktig videre. Matcher på tee-navn (case-insensitive) -- kjønn hører til RATINGEN, ikke selve utslaget (ADR-029), så remap-en sjekker i tillegg at den nye tee-en faktisk har en full_18-rating for hver berørte spillers kjønn. Finnes ingen tilsvarende, gyldig tee på den nye banen for én eller flere spillere, avvises HELE bane-byttet tydelig FØR noe skrives -- heller stoppe enn å gjette feil (samme "fail loudly" prinsipp som ADR-019 sin importvalidering). """ course_exists = await conn.fetchval("SELECT id FROM course WHERE id = $1", new_course_id) if course_exists is None: raise app_error(404, "NOT_FOUND", "Banen finnes ikke.") participants = await conn.fetch( """ SELECT mp.id::text AS id, t.name AS tee_name, p.gender AS player_gender FROM match_participant mp JOIN match m ON m.id = mp.match_id JOIN tee t ON t.id = mp.tee_id JOIN team_roster tr ON tr.id = mp.team_roster_id JOIN player p ON p.id = tr.player_id WHERE m.session_id = $1 """, session_id, ) remap: dict[str, str] = {} missing: set[str] = set() for p in participants: new_tee_id = await conn.fetchval( """ SELECT t.id::text FROM tee t JOIN tee_rating tr ON tr.tee_id = t.id AND tr.scope = 'full_18' AND tr.gender IS NOT DISTINCT FROM $3 WHERE t.course_id = $1 AND lower(t.name) = lower($2) """, new_course_id, p["tee_name"], p["player_gender"], ) if new_tee_id is None: missing.add(p["tee_name"]) else: remap[p["id"]] = new_tee_id if missing: raise app_error( 400, "VALIDATION_FAILED", "Den nye banen mangler en tee som matcher: " + ", ".join(sorted(missing)) + ". Legg til tilsvarende tee på den nye banen før du bytter.", ) for participant_id, new_tee_id in remap.items(): await conn.execute("UPDATE match_participant SET tee_id = $1 WHERE id = $2", new_tee_id, participant_id) await conn.execute("UPDATE session SET course_id = $1 WHERE id = $2", new_course_id, session_id) async def _recompute_session_matches( conn, session_id: str, format_: str, allowance_override: dict | None ) -> None: """Regner om handicap + matchstatus for HVER match i økten -- brukt etter et bane- eller allowance-bytte. Bevisst UAVHENGIG av om en match allerede er avgjort (brukeren bekreftet eksplisitt: en bane-korrigering skal kunne endre et allerede cachet resultat, ikke bare blokkeres av den vanlige ALREADY_DECIDED-sperren som gjelder NYE slag-innsendinger).""" config = parse_allowance_config(format_, allowance_override) matches = await conn.fetch("SELECT id::text AS id FROM match WHERE session_id = $1", session_id) for m in matches: for side in ("a", "b"): await compute_and_store_side_handicaps(conn, m["id"], side, format_, config) await recompute_and_cache_match_state(conn, m["id"]) @router.patch("/orgs/{organization_id}/sessions/{session_id}", response_model=SessionOut) async def update_session( session_id: str, body: SessionUpdate, organization_id: str = Depends(get_authorized_org), ) -> SessionOut: updates = body.model_dump(exclude_unset=True) if not updates: raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.") async with org_connection(organization_id) as conn, translate_db_errors(): session = await conn.fetchrow( "SELECT format, scoring_mode, course_id::text AS course_id FROM session WHERE id = $1", session_id, ) if session is None: raise app_error(404, "NOT_FOUND", "Økten finnes ikke.") # "Jakter Jesper" (migrasjon 093) -- fail-loudly, samme prinsipp som # org-individuell (ADR-106). format/scoring_mode kan ALDRI selv # endres i samme kall (SessionUpdate ekskluderer dem bevisst, se # modellens docstring), så det er alltid ØKTENS LAGREDE verdi som # avgjør -- ingen "effektiv verdi etter denne PATCH-en"-vurdering # nødvendig her, ulikt tournament/round-varianten. if updates.get("net_display_style") == "hunt" and not ( session["format"] in ("singles", "fourball") and session["scoring_mode"] == "stroke" ): raise app_error( 400, "VALIDATION_FAILED", "Jakter Jesper-visningen krever singles/fourball med brutto-/nettoslagspill.", ) new_course_id = updates.pop("course_id", None) allowance_or_points_changed = "allowance_override" in updates or "points_per_match" in updates if updates: set_clauses = [] values: list = [] for i, (key, value) in enumerate(updates.items(), start=1): if key == "allowance_override": set_clauses.append(f"allowance_override = ${i}::jsonb") values.append(json.dumps(value) if value is not None else None) else: set_clauses.append(f"{key} = ${i}") values.append(value) values.append(session_id) await conn.execute( f"UPDATE session SET {', '.join(set_clauses)} WHERE id = ${len(values)}", *values, ) if new_course_id is not None and new_course_id != session["course_id"]: await _remap_course(conn, session_id, new_course_id) allowance_or_points_changed = True # banebytte krever alltid omregning if allowance_or_points_changed: current = await conn.fetchrow( "SELECT allowance_override::text AS allowance_override FROM session WHERE id = $1", session_id, ) current_override = json.loads(current["allowance_override"]) if current["allowance_override"] else None await _recompute_session_matches( conn, session_id, session["format"], current_override ) row = await conn.fetchrow( """ SELECT id::text, sequence, name, format, hole_config::text AS hole_config, course_id::text, points_per_match::float AS points_per_match, allowance_override::text AS allowance_override, scoring_mode, scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n, net_display_style FROM session WHERE id = $1 """, session_id, ) locked = await locked_team_ids(conn, session_id) return _session_out(row, locked) @router.delete("/orgs/{organization_id}/sessions/{session_id}", status_code=204) async def delete_session( session_id: str, organization_id: str = Depends(get_authorized_org), ) -> None: """Kun tomme økter kan slettes (ingen matcher opprettet ennå) -- dekker "jeg satte opp feil økt, fjern den" FØR den er tatt i bruk. En økt med matcher (og dermed potensielt registrerte scores/poeng) må rettes med PATCH (bane-/felt-korrigering) i stedet for å kunne slettes -- hindrer at noen ved et uhell sletter ekte kamper/resultater.""" async with org_connection(organization_id) as conn: session = await conn.fetchrow("SELECT id FROM session WHERE id = $1", session_id) if session is None: raise app_error(404, "NOT_FOUND", "Økten finnes ikke.") match_count = await conn.fetchval("SELECT count(*) FROM match WHERE session_id = $1", session_id) if match_count > 0: raise app_error( 409, "VALIDATION_FAILED", "Økten har allerede matcher og kan ikke slettes -- bruk PATCH for å korrigere den i stedet.", ) await conn.execute("DELETE FROM session WHERE id = $1", session_id) # --------------------------------------------------------------------------- # Midlertidige spillere: etter-runde scorekort + innloggingsinvitasjon, # økt-nivå (FEATURE_BACKLOG.md, avklart 2026-07-28, migrasjon 034). # # Eksplisitt organisator-handling (ikke en automatisk bakgrunnsjobb, se # begrunnelse i backloggen) -- sender KUN til spillere UTEN egen konto ennå # (player.user_id IS NULL) og med en registrert e-post, og KUN én gang per # (økt, deltaker) via match_participant.invitation_sent_at. Locale er # bevisst alltid 'nb' -- en spiller uten konto har ingen lagret # språkpreferanse å lese. # --------------------------------------------------------------------------- class SendInvitationsOut(BaseModel): sent: int skipped_has_account: int skipped_no_email: int skipped_already_sent: int @router.post( "/orgs/{organization_id}/sessions/{session_id}/send-scorecard-invitations", response_model=SendInvitationsOut, ) async def send_scorecard_invitations( session_id: str, organization_id: str = Depends(get_authorized_org), ) -> SendInvitationsOut: async with org_connection(organization_id) as conn: session = await conn.fetchrow( "SELECT tournament_id::text AS tournament_id, name FROM session WHERE id = $1", session_id, ) if session is None: raise app_error(404, "NOT_FOUND", "Økten finnes ikke.") tournament_name = await conn.fetchval( "SELECT name FROM tournament WHERE id = $1", session["tournament_id"] ) session_label = (session["name"] or "").strip() or "en økt" rows = await conn.fetch( """ SELECT mp.id::text AS participant_id, mp.invitation_sent_at, p.display_name, p.email, p.user_id::text AS user_id, m.status_text FROM match_participant mp JOIN match m ON m.id = mp.match_id JOIN team_roster tr ON tr.id = mp.team_roster_id JOIN player p ON p.id = tr.player_id WHERE m.session_id = $1 """, session_id, ) sent = 0 skipped_has_account = 0 skipped_no_email = 0 skipped_already_sent = 0 for r in rows: if r["user_id"] is not None: skipped_has_account += 1 continue if not r["email"]: skipped_no_email += 1 continue if r["invitation_sent_at"] is not None: skipped_already_sent += 1 continue # Kun meningsfull for individuell-ball-formater (singles/ # fourball) -- for delt-ball-formater er match_participant_id # ALDRI satt i hole_score (ADR/scoring.py), så SUM blir naturlig # NULL der uten noen egen format-sjekk her. gross = await conn.fetchval( "SELECT SUM(gross_strokes) FROM hole_score WHERE match_participant_id = $1", r["participant_id"], ) if r["status_text"]: summary = f"Du spilte i {session_label} i {tournament_name} -- resultatet ble {r['status_text']}." else: summary = f"Du var satt opp for {session_label} i {tournament_name}." if gross is not None: summary += f" Du hadde {gross} slag totalt." email = r["email"].strip().lower() now = datetime.now(timezone.utc) raw_token = secrets.token_urlsafe(32) expires_at = now + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES) # Samme "ugyldiggjør eldre uforbrukte lenker"-mønster som # request_magic_link (routers/auth.py) -- begrenser vinduet en # eldre, lekket lenke kan misbrukes i. await conn.execute( "UPDATE magic_link_token SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL", email, ) await conn.execute( "INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, 'nb')", email, _hash_secret(raw_token), expires_at, ) if settings.SMTP_CONFIGURED: try: await send_session_result_email( email, r["display_name"], tournament_name, summary, raw_token, "nb" ) except Exception: # Se app/email.py sitt mønster -- driftsfeil i # utsendingen skal aldri hindre resten av utsendingen # (token-raden består uansett, lenken virker fortsatt # hvis spilleren ber om en ny normalt). traceback.print_exc() elif settings.DEV_LOG_MAGIC_LINKS: # Samme "[DEV] Magic link for ..."-linjeformat som # request_magic_link (routers/auth.py) FØRST -- slik at # eksisterende dev-/test-verktøy som harvester token derfra # fungerer uendret her også -- pluss en egen, lesbar # oppsummeringslinje. print(f"[DEV] Magic link for {email} (nb): {raw_token}", flush=True) print(f"[DEV] Scorekort-invitasjon til {email}: {summary}", flush=True) await conn.execute( "UPDATE match_participant SET invitation_sent_at = now() WHERE id = $1", r["participant_id"], ) sent += 1 return SendInvitationsOut( sent=sent, skipped_has_account=skipped_has_account, skipped_no_email=skipped_no_email, skipped_already_sent=skipped_already_sent, ) # --------------------------------------------------------------------------- # Sponsorer (ADR-018) -- navn+lenke+logo, alle aktive fra MinIO-runden. # --------------------------------------------------------------------------- class SponsorCreate(BaseModel): name: str = Field(min_length=1, max_length=200) url: str | None = None _SPONSOR_COLUMNS = "id::text, name, url, logo_key" class Sponsor(BaseModel): id: str name: str url: str | None logo_key: str | None logo_url: str | None def _sponsor_from_row(row) -> Sponsor: data = dict(row) key = data["logo_key"] return Sponsor(**data, logo_url=storage.public_url(key) if key else None) @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/sponsors", response_model=list[Sponsor], ) async def list_sponsors( tournament_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[Sponsor]: async with org_connection(organization_id) as conn: rows = await conn.fetch( f"SELECT {_SPONSOR_COLUMNS} FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at", tournament_id, ) return [_sponsor_from_row(r) for r in rows] @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/sponsors", response_model=Sponsor, status_code=201, ) async def add_sponsor( tournament_id: str, body: SponsorCreate, organization_id: str = Depends(get_authorized_org), ) -> Sponsor: async with org_connection(organization_id) as conn, translate_db_errors(): tournament = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id) if tournament is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") row = await conn.fetchrow( f""" INSERT INTO tournament_sponsor (organization_id, tournament_id, name, url) VALUES ($1, $2, $3, $4) RETURNING {_SPONSOR_COLUMNS} """, organization_id, tournament_id, body.name, body.url, ) return _sponsor_from_row(row) class SponsorUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=200) url: str | None = None # logo_key er BEVISST IKKE med -- settes kun via upload_sponsor_logo # under, samme begrunnelse som tournament.hero_image_key over. @router.patch( "/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}", response_model=Sponsor, ) async def update_sponsor( tournament_id: str, sponsor_id: str, body: SponsorUpdate, organization_id: str = Depends(get_authorized_org), ) -> Sponsor: 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(sponsor_id) values.append(tournament_id) async with org_connection(organization_id) as conn, translate_db_errors(): row = await conn.fetchrow( f""" UPDATE tournament_sponsor SET {', '.join(set_clauses)} WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)} RETURNING {_SPONSOR_COLUMNS} """, *values, ) if row is None: raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.") return _sponsor_from_row(row) @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}/logo", response_model=Sponsor, ) async def upload_sponsor_logo( tournament_id: str, sponsor_id: str, file: UploadFile, organization_id: str = Depends(get_authorized_org), ) -> Sponsor: if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES: raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.") raw = await file.read(storage.MAX_UPLOAD_BYTES + 1) if len(raw) > storage.MAX_UPLOAD_BYTES: raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).") async with org_connection(organization_id) as conn: exists = await conn.fetchval( "SELECT id FROM tournament_sponsor WHERE id = $1 AND tournament_id = $2", sponsor_id, tournament_id, ) if exists is None: raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.") try: key = await storage.upload_image("sponsors", sponsor_id, raw) except storage.InvalidImageError: raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.") row = await conn.fetchrow( f"UPDATE tournament_sponsor SET logo_key = $1 WHERE id = $2 RETURNING {_SPONSOR_COLUMNS}", key, sponsor_id, ) return _sponsor_from_row(row) @router.delete( "/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}", status_code=204, ) async def delete_sponsor( tournament_id: str, sponsor_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_sponsor WHERE id = $1 AND tournament_id = $2 RETURNING id", sponsor_id, tournament_id, ) if deleted is None: raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.") # --------------------------------------------------------------------------- # Konkurranseklasser (2026-08-03) -- fritt navngitte, valgfritt standard- # utslag. Delt mellom lag- og individuelle turneringer (samme `tournament`- # tabell). Brukes til å forhåndsutfylle riktig utslag når en deltaker legges # til en match (her)/runde (individual_tournaments.py), og -- KUN for # individuelle turneringer -- til å dele opp leaderboardet. Se # 053_tournament_classes.sql for full begrunnelse. # --------------------------------------------------------------------------- class TournamentClassCreate(BaseModel): name: str = Field(min_length=1, max_length=200) default_tee_id: str | None = None class TournamentClassUpdate(BaseModel): """Alle felt valgfrie -- exclude_unset, samme PATCH-semantikk som TournamentUpdate/SponsorUpdate.""" name: str | None = Field(default=None, min_length=1, max_length=200) default_tee_id: str | None = None _CLASS_COLUMNS = """ tc.id::text, tc.name, tc.default_tee_id::text AS default_tee_id, tee.name AS default_tee_name """ class TournamentClass(BaseModel): id: str name: str default_tee_id: str | None default_tee_name: str | None @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/classes", response_model=list[TournamentClass], ) async def list_tournament_classes( tournament_id: str, organization_id: str = Depends(get_authorized_org), ) -> list[TournamentClass]: async with org_connection(organization_id) as conn: rows = await conn.fetch( f""" SELECT {_CLASS_COLUMNS} FROM tournament_class tc LEFT JOIN tee ON tee.id = tc.default_tee_id WHERE tc.tournament_id = $1 ORDER BY tc.created_at """, tournament_id, ) return [TournamentClass(**dict(r)) for r in rows] @router.post( "/orgs/{organization_id}/tournaments/{tournament_id}/classes", response_model=TournamentClass, status_code=201, ) async def create_tournament_class( tournament_id: str, body: TournamentClassCreate, organization_id: str = Depends(get_authorized_org), ) -> TournamentClass: async with org_connection(organization_id) as conn, translate_db_errors(): tournament = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id) if tournament is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") row = await conn.fetchrow( f""" WITH inserted AS ( INSERT INTO tournament_class (organization_id, tournament_id, name, default_tee_id) VALUES ($1, $2, $3, $4) RETURNING id, name, default_tee_id ) SELECT inserted.id::text, inserted.name, inserted.default_tee_id::text AS default_tee_id, tee.name AS default_tee_name FROM inserted LEFT JOIN tee ON tee.id = inserted.default_tee_id """, organization_id, tournament_id, body.name, body.default_tee_id, ) return TournamentClass(**dict(row)) @router.patch( "/orgs/{organization_id}/tournaments/{tournament_id}/classes/{class_id}", response_model=TournamentClass, ) async def update_tournament_class( tournament_id: str, class_id: str, body: TournamentClassUpdate, organization_id: str = Depends(get_authorized_org), ) -> TournamentClass: 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(class_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_class SET {', '.join(set_clauses)} WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)} RETURNING id, name, default_tee_id ) SELECT updated.id::text, updated.name, updated.default_tee_id::text AS default_tee_id, tee.name AS default_tee_name FROM updated LEFT JOIN tee ON tee.id = updated.default_tee_id """, *values, ) if row is None: raise app_error(404, "NOT_FOUND", "Klassen finnes ikke.") return TournamentClass(**dict(row)) @router.delete( "/orgs/{organization_id}/tournaments/{tournament_id}/classes/{class_id}", status_code=204, ) async def delete_tournament_class( tournament_id: str, class_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_class WHERE id = $1 AND tournament_id = $2 RETURNING id", class_id, tournament_id, ) if deleted is None: raise app_error(404, "NOT_FOUND", "Klassen finnes ikke.") # --------------------------------------------------------------------------- # Leaderboard (turnering-total, summert på tvers av alle økter/matcher) # # `match.team_a_id`/`team_b_id` settes PER MATCH ved opprettelse (matches.py), # ikke garantert konsistent på tvers av matcher -- en organisator kunne i # prinsippet opprettet match 1 med team_a=Rød og match 3 med team_a=Blå. Derfor # summeres ALT (både total og per-økt) på det EKTE lag-id-et, aldri på # "a"/"b"-labelen -- se points_by_team under. # --------------------------------------------------------------------------- class TeamStanding(BaseModel): team_id: str team_name: str color: str | None points: float # ADR-020 Beslutning B: hvis pågående matcher holder seg som de står nå -- # ikke-avgjorte matcher bidrar med full poengsum til leading_side (delt # likt ved "AS"/ikke startet). Avgjorte matcher bidrar likt til begge. projected_points: float class SessionStanding(BaseModel): session_id: str sequence: int name: str | None points_by_team: dict[str, float] projected_points_by_team: dict[str, float] matches_total: int matches_decided: int class Leaderboard(BaseModel): teams: list[TeamStanding] matches_total: int matches_decided: int sessions: list[SessionStanding] async def fetch_leaderboard(conn, tournament_id: str) -> Leaderboard: """Delt mellom den innloggede (get_leaderboard under) og den offentlige /public/tournaments/{id}/leaderboard (registration.py, tilskuer-rolle) -- ingen spiller-nivå-data i responsmodellene (kun lag-id/-navn/-farge/ poeng), så ingen ekstra synlighetsfiltrering trengs utover den vanlige tournament.visibility-sjekken den offentlige ruten selv gjør FØR denne kalles.""" teams = await conn.fetch( "SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at", tournament_id, ) if len(teams) != 2: raise app_error(400, "VALIDATION_FAILED", "Turneringen har ikke nøyaktig to lag ennå.") rows = await conn.fetch( """ SELECT s.id::text AS session_id, s.sequence, s.name, s.points_per_match::float AS points_per_match, m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id, m.points_side_a::float AS points_side_a, m.points_side_b::float AS points_side_b, m.leading_side FROM match m JOIN session s ON s.id = m.session_id WHERE s.tournament_id = $1 ORDER BY s.sequence, m.sequence """, tournament_id, ) team_totals: dict[str, float] = {t["id"]: 0.0 for t in teams} team_projected: dict[str, float] = {t["id"]: 0.0 for t in teams} session_order: list[str] = [] sessions: dict[str, dict] = {} matches_total = 0 matches_decided = 0 for r in rows: matches_total += 1 sid = r["session_id"] if sid not in sessions: session_order.append(sid) sessions[sid] = { "session_id": sid, "sequence": r["sequence"], "name": r["name"], "points_by_team": {t["id"]: 0.0 for t in teams}, "projected_points_by_team": {t["id"]: 0.0 for t in teams}, "matches_total": 0, "matches_decided": 0, } sessions[sid]["matches_total"] += 1 # Projisert bidrag: avgjorte matcher gir samme tall som faktisk # (de ER allerede det de blir). Ikke-avgjorte matcher gir hele # øktens poengsum til den som leder NÅ, delt 0,5/0,5 ved "AS" # eller en match som ennå ikke har noen registrerte hull. if r["points_side_a"] is not None: proj_a, proj_b = r["points_side_a"], r["points_side_b"] else: ppm = r["points_per_match"] if r["leading_side"] == "a": proj_a, proj_b = ppm, 0.0 elif r["leading_side"] == "b": proj_a, proj_b = 0.0, ppm else: proj_a = proj_b = ppm / 2 team_projected[r["team_a_id"]] = team_projected.get(r["team_a_id"], 0.0) + proj_a team_projected[r["team_b_id"]] = team_projected.get(r["team_b_id"], 0.0) + proj_b sessions[sid]["projected_points_by_team"][r["team_a_id"]] = ( sessions[sid]["projected_points_by_team"].get(r["team_a_id"], 0.0) + proj_a ) sessions[sid]["projected_points_by_team"][r["team_b_id"]] = ( sessions[sid]["projected_points_by_team"].get(r["team_b_id"], 0.0) + proj_b ) if r["points_side_a"] is None: continue matches_decided += 1 sessions[sid]["matches_decided"] += 1 team_totals[r["team_a_id"]] = team_totals.get(r["team_a_id"], 0.0) + r["points_side_a"] team_totals[r["team_b_id"]] = team_totals.get(r["team_b_id"], 0.0) + r["points_side_b"] sessions[sid]["points_by_team"][r["team_a_id"]] = ( sessions[sid]["points_by_team"].get(r["team_a_id"], 0.0) + r["points_side_a"] ) sessions[sid]["points_by_team"][r["team_b_id"]] = ( sessions[sid]["points_by_team"].get(r["team_b_id"], 0.0) + r["points_side_b"] ) team_standings = [ TeamStanding( team_id=t["id"], team_name=t["name"], color=t["color"], points=team_totals[t["id"]], projected_points=team_projected[t["id"]], ) for t in teams ] return Leaderboard( teams=team_standings, matches_total=matches_total, matches_decided=matches_decided, sessions=[SessionStanding(**sessions[sid]) for sid in sorted(session_order, key=lambda s: sessions[s]["sequence"])], ) @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/leaderboard", response_model=Leaderboard, ) async def get_leaderboard( tournament_id: str, organization_id: str = Depends(get_authorized_org), ) -> Leaderboard: async with org_connection(organization_id) as conn: return await fetch_leaderboard(conn, tournament_id) # --------------------------------------------------------------------------- # Individuell rangering PER ØKT (FEATURE_BACKLOG.md "Leaderboard for runder # og turneringer", 2026-07-25/28 oppfølging) -- ved siden av, ikke i stedet # for, lag-poeng-leaderboardet over. Bevisst AVGRENSET til én økt (ikke # summert på tvers av turneringen, se avklaring i CLAUDE.md) -- og KUN # meningsfullt for scoring_mode='stroke'-økter i individuell-ball-format # (singles/fourball, se scoring.py sin _INDIVIDUAL_FORMATS) siden delt-ball- # formater og hole_result-modus ikke har noen individuell brutto-score. # --------------------------------------------------------------------------- class IndividualLeaderboardEntry(BaseModel): participant_id: str display_name: str team_id: str team_name: str team_color: str | None thru: int gross_score: int | None gross_to_par: int | None net_score: int | None net_to_par: int | None total_points: int | None class IndividualLeaderboard(BaseModel): session_id: str format: str entries: list[IndividualLeaderboardEntry] # "Jakter Jesper" (migrasjon 093) -- frontend viser en forklarende # banner og vet dermed hvorfor "Netto"-tallene ser dype/uvante ut. net_display_style: str async def fetch_individual_leaderboard( conn, organization_id: str, session_id: str, viewer_user_id: str | None ) -> IndividualLeaderboard: session = await conn.fetchrow( """ SELECT tournament_id::text AS tournament_id, format, scoring_mode, hole_config::text AS hole_config, course_id::text AS course_id, net_display_style FROM session WHERE id = $1 """, session_id, ) if session is None: raise app_error(404, "NOT_FOUND", "Økten finnes ikke.") if session["scoring_mode"] != "stroke" or session["format"] not in ("singles", "fourball"): raise app_error( 400, "VALIDATION_FAILED", "Individuell rangering er kun tilgjengelig for slagspill-økter i format singles eller fourball.", ) hole_rows = await conn.fetch( "SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", session["course_id"], ) if len(hole_rows) != 18: raise app_error(400, "VALIDATION_FAILED", "Banen mangler registrerte hull.") par_by_hole = {r["hole_number"]: r["par"] for r in hole_rows} all_18_si = [r["stroke_index"] for r in hole_rows] played = played_hole_numbers(session["hole_config"]) # Samme reveal-respekterende synlighet som fetch_matches (ADR-013/026): # egen side FØR reveal, hele feltet ETTER. locked = await locked_team_ids(conn, session_id) revealed = len(locked) >= 2 own = ( set() if revealed else await own_team_ids(conn, organization_id, session["tournament_id"], viewer_user_id) ) participant_rows = await conn.fetch( """ SELECT mp.id::text AS participant_id, mp.playing_handicap, tr.team_id::text AS team_id, t.name AS team_name, t.color AS team_color, p.display_name FROM match_participant mp JOIN match m ON m.id = mp.match_id JOIN team_roster tr ON tr.id = mp.team_roster_id JOIN team t ON t.id = tr.team_id JOIN player p ON p.id = tr.player_id WHERE m.session_id = $1 AND ($2 OR tr.team_id::text = ANY($3::text[])) ORDER BY p.display_name """, session_id, revealed, list(own), ) entries: list[IndividualLeaderboardEntry] = [] for r in participant_rows: score_rows = await conn.fetch( "SELECT hole_number, gross_strokes FROM hole_score " "WHERE match_participant_id = $1 ORDER BY hole_number", r["participant_id"], ) gross_score = gross_to_par = net_score = net_to_par = total_points = None if score_rows: gross_score = sum(s["gross_strokes"] for s in score_rows) par_total = sum(par_by_hole[s["hole_number"]] for s in score_rows) gross_to_par = gross_score - par_total if r["playing_handicap"] is not None: strokes_list = allocate_over_played_holes(r["playing_handicap"], all_18_si, played) strokes_by_hole = dict(zip(played, strokes_list)) strokes_received_total = sum(strokes_by_hole[s["hole_number"]] for s in score_rows) # "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- kun # net_score/net_to_par bytter til hunt-semantikk (helt # spillehandicap trukket fra med én gang). total_points # (Stableford) er UBERØRT -- poeng tildeles per hull ut fra # DET hullets netto, ingen "hunt"-ekvivalent for en poengsum. if session["net_display_style"] == "hunt": net_score = gross_score - r["playing_handicap"] net_to_par = gross_to_par - r["playing_handicap"] else: net_score = gross_score - strokes_received_total net_to_par = gross_to_par - strokes_received_total # Samme stableford-formel som round-leaderboardets # total_points (rounds.py) -- 2 poeng for netto par, +/-1 # per slag, gulvet på 0. total_points = sum( max( 0, par_by_hole[s["hole_number"]] - (s["gross_strokes"] - strokes_by_hole[s["hole_number"]]) + 2, ) for s in score_rows ) entries.append( IndividualLeaderboardEntry( participant_id=r["participant_id"], display_name=r["display_name"], team_id=r["team_id"], team_name=r["team_name"], team_color=r["team_color"], thru=len(score_rows), gross_score=gross_score, gross_to_par=gross_to_par, net_score=net_score, net_to_par=net_to_par, total_points=total_points, ) ) return IndividualLeaderboard( session_id=session_id, format=session["format"], entries=entries, net_display_style=session["net_display_style"], ) @router.get( "/orgs/{organization_id}/sessions/{session_id}/individual-leaderboard", response_model=IndividualLeaderboard, ) async def get_individual_leaderboard( session_id: str, organization_id: str = Depends(get_authorized_org), user: CurrentUser = Depends(get_current_user), ) -> IndividualLeaderboard: async with org_connection(organization_id) as conn: return await fetch_individual_leaderboard(conn, organization_id, session_id, user.user_id)