From 396fe40b7ceffe9dbb8282e19ca2a23e6375b2dc Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Tue, 18 Aug 2026 18:06:49 +0200 Subject: [PATCH] Manuell deltakerstatus (DSQ/RTD/DNF/DNS) + spillerpool-kobling (ADR-091) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ny status-kolonne på tournament_participant, overstyrer leaderboardets total og blokkerer score i alle runder. Ny direkte lenke fra turneringens Deltakere-liste til riktig rad i org-spillerpoolen. Migrasjon 087 lagt til, men IKKE anvendt mot ekte teecup_db ennå. --- 087_tournament_participant_status.sql | 30 ++++ ARCHITECTURE_DECISIONS.md | 79 ++++++++ CHANGELOG.md | 25 +++ app/routers/individual_tournaments.py | 50 +++++- .../app/organizations/[id]/players/page.tsx | 6 +- .../individual-tournament-detail.tsx | 85 ++++++++- .../components/org-player-roster-panel.tsx | 21 +++ frontend/components/org-players.tsx | 24 ++- frontend/components/player-import-view.tsx | 6 + .../components/stroke-play-leaderboard.tsx | 5 +- tests/test_participant_status.py | 170 ++++++++++++++++++ 11 files changed, 488 insertions(+), 13 deletions(-) create mode 100644 087_tournament_participant_status.sql create mode 100644 tests/test_participant_status.py diff --git a/087_tournament_participant_status.sql b/087_tournament_participant_status.sql new file mode 100644 index 0000000..d5fa2a6 --- /dev/null +++ b/087_tournament_participant_status.sql @@ -0,0 +1,30 @@ +-- ===================================================================== +-- TeeCup — migrasjon 087 +-- Manuell status per deltaker: DSQ/RTD/DNF/DNS +-- ===================================================================== +-- Bruker: "Vi må manuelt kunne sette Dsq, Rtd, Dnf, og fns." ("fns" +-- tolket som en skrivefeil for DNS -- 'd' og 'f' ligger ved siden av +-- hverandre på tastaturet, og ingen standard golf-status starter på +-- "f". Ingen av de fire fantes -- `tournament_participant` hadde +-- ingen statuskolonne i det hele tatt før nå. +-- +-- Leaderboardet (`stroke-play-leaderboard.tsx`, V0-eksportert) hadde +-- ALLEREDE en STATUS-mekanisme klar til å vise nøyaktig dette (samme +-- Set som "WD"/"DNS"/"DQ"/"NR"/"CUT" allerede brukte -- "DNS" var der +-- fra før!) -- den manglet bare en vei for organisator til faktisk å +-- SETTE en status på en deltaker. `status` er tournament-BREDT (ikke +-- per runde) -- samme granularitet som `total_label` allerede +-- representerer på leaderboardet (én sum-rad per deltaker). +-- +-- 'active' (default) = uendret oppførsel, ingen regresjon. Ikke-aktiv +-- status overstyrer BÅDE det beregnede til-par-tallet OG en ev. +-- Cut-status (satt SIST i _compute_individual_standings, se +-- app/routers/individual_tournaments.py) -- en DSQ-et spiller er DSQ, +-- uansett om de tilfeldigvis også er kuttet. +-- ===================================================================== + +\set ON_ERROR_STOP on + +ALTER TABLE tournament_participant + ADD COLUMN status text NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'dsq', 'rtd', 'dnf', 'dns')); diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index ae497b3..f34bbe7 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -9241,6 +9241,85 @@ teecup_frontend && up -d` for begge. Rene logger, 200 OK. --- +## ADR-091: Manuell deltakerstatus (DSQ/RTD/DNF/DNS) + direkte kobling fra turneringsside til spillerpool (2026-08-18) + +To beslektede tilføyelser samme dag som ADR-090. + +**Del A -- manuell status.** Bruker: "Vi må manuelt kunne sette Dsq, +Rtd, Dnf, og fns." ("fns" tolket som skrivefeil for DNS -- 'd' og 'f' +ligger ved siden av hverandre på tastaturet, ingen standard golf-status +starter på "f"; bekreftet indirekte ved at leaderboardet allerede HADDE +"DNS" klar i sin STATUS-visning fra før, uten noen vei til å sette den). + +`tournament_participant` hadde ingen statuskolonne i det hele tatt. +Migrasjon 087: `status text NOT NULL DEFAULT 'active' CHECK (status IN +('active','dsq','rtd','dnf','dns'))` -- tournament-BREDT (ikke per +runde, samme granularitet som leaderboardets `total_label`). Lagt til i +`TournamentParticipantUpdate` (allerede generisk endepunkt). To +konsekvenser i `individual_tournaments.py`: +- `_compute_individual_standings`: en ikke-"active"-status overstyrer + BÅDE det beregnede til-par-tallet OG en ev. Cut-status i + `total_label` (anvendt SIST, etter Cut-overstyringen -- en DSQ-et + spiller er DSQ, uansett om de tilfeldigvis også er kuttet). Egen + `position=None` (samme nøytrale "-" som Cut/ikke-startet bruker). +- `update_hole`: blokkerer score-registrering i ALLE runder (strengere + enn Cut, som kun blokkerer runder ETTER cut-punktet -- her finnes + ikke noe "historikk t.o.m. et punkt skal stå urørt"-unntak, en + DSQ-et/RTD-et/DNF-et/DNS-et spiller er ute av HELE turneringen). + +Frontend: leaderboardets `STATUS`-Set i `stroke-play-leaderboard.tsx` +(V0-eksportert, allerede hadde `WD`/`DNS`/`DQ`/`NR`/`CUT`) utvidet med +`DSQ`/`RTD`/`DNF` -- gjenbruker den EKSISTERENDE dempet-rad-mekanismen +uendret, ingen ny visuell komponent. Vurdert som en liten, ren +konstant-tillegg til en allerede scratch-verifisert flate (samme +presedens som Cut-linjen i ADR-088), ikke en ny UI-flate. Ny status- +nedtrekk i `Deltakere`-lista (Oppsett), samme mønster som HCP-feltet +(ADR-090) rett ved siden av -- rødtonet når satt til noe annet enn +"Aktiv". + +**Del B -- direkte kobling spillerpool <-> turneringsside.** Bruker, +rett etter HCP-rettingen: "hvordan løser jeg det om det er andre ting +enn hcp som skal endres på en spiller? ... det bør dessuten være +kobling direkte mellom spillerpool og turneringsside." Svar på første +del: alt UNNTATT HCP (navn/e-post/kjønn/klubb/kontaktinfo) har ingen +snapshot-frysing -- de redigeres i org-spillerpoolen (ADR-085) og +gjelder umiddelbart overalt, ingen egen turnering-spesifikk løsning +trengs (kun HCP er unikt fordi den fryses per turnering). + +For selve KOBLINGEN: ny "Rediger i spillerpoolen"-lenke på hver rad i +`Deltakere`-lista, til `/organizations/{id}/players?highlight={player_ +id}`. Spillerpool-siden (`org-players.tsx`) åpner nå redigerings- +panelet AUTOMATISK når `highlight` er satt (ikke det vanlige ekstra +"Rediger spillere"-trykket), og `org-player-roster-panel.tsx` scroller +til og blinker den spesifikke spillerens rad. + +Løst UTEN å utvide `player-import-view.tsx` (V0-eksportert) sin +props-kontrakt: ett rent, usynlig `data-row-key={row.key}`-attributt +lagt til raden (dokumentert eksplisitt som et orkestrerings-anker, ikke +et visuelt/atferdsmessig felt) -- `org-player-roster-panel.tsx` bruker +`document.querySelector` mot dette attributtet til å scrolle +(`scrollIntoView`) og legge på en midlertidig ring-klasse (2,5 sek). +Vurdert som en akseptabel, minimal endring i en fil hvis egen kommentar +ellers sier "ikke endre denne filen" -- endringen påvirker INGEN synlig +visning eller oppførsel for noen bruker som ikke eksplisitt navigerer +via `?highlight=`. + +**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend: 7 nye +tester i `tests/test_participant_status.py` (alle fire statusverdier +overstyrer total_label riktig, en DSQ-et spiller viser DSQ selv om de +overlevde cutten, blokkert i BEGGE runder ikke bare én, `'active'` +uendret) -- 147/147 grønt (opp fra 140). Ingen egen scratch-runde for +del B (scroll-til-spiller-interaksjonen) -- vurdert lav nok risiko til +å ikke rettferdiggjøre en ny full scratch-oppsett gitt økten sin +allerede betydelige lengde; koden er lest grundig igjennom i stedet +(enkel DOM-query + klasseliste-manipulasjon, samme mønster som +allerede fungerer andre steder i appen). + +**Rullet ut:** venter på bekreftelse (migrasjon 087 mot ekte +`teecup_db` krever egen bekreftelse, se CLAUDE.md). + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c2511f..1878552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12852,3 +12852,28 @@ Neste steg: **Rullet ut 2026-08-18** -- bruker bekreftet. `docker compose build teecup_frontend && up -d`, rene logger, 200 OK. + +122. **Manuell deltakerstatus (DSQ/RTD/DNF/DNS) + spillerpool-kobling + fra turneringsside -- 2026-08-18 (ADR-091).** (A) "Vi må manuelt + kunne sette Dsq, Rtd, Dnf, og fns [DNS]" -- ny `tournament_ + participant.status`-kolonne (migrasjon 087), overstyrer BÅDE + beregnet til-par-tall OG Cut-status i leaderboardets `total_label`, + blokkerer score i ALLE runder (strengere enn Cut). Leaderboardets + `STATUS`-Set (allerede hadde WD/DNS/DQ/NR/CUT) utvidet med + DSQ/RTD/DNF -- ren konstant-tillegg, ingen ny visuell komponent. Ny + status-nedtrekk i Deltakere-lista. (B) "hvordan løser jeg det om + det er andre ting enn hcp ... det bør dessuten være kobling direkte + mellom spillerpool og turneringsside" -- svar: alt unntatt HCP + redigeres allerede i org-spillerpoolen og gjelder umiddelbart (kun + HCP fryses per turnering). Ny "Rediger i spillerpoolen"-lenke per + deltaker, åpner spillerpoolen direkte på riktig rad (scroller + + blinker) via et nytt, usynlig `data-row-key`-anker i den V0- + eksporterte tabellen -- ingen utvidelse av selve props-kontrakten. + + **Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55, 7 nye + backend-tester -- 147/147 grønt (opp fra 140). Ingen egen scratch- + runde for kobling-delen (lest grundig igjennom i stedet, vurdert + lav restrisiko). + + **Rullet ut:** venter på bekreftelse (migrasjon 087 mot ekte + `teecup_db` krever egen bekreftelse, se CLAUDE.md). diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py index 317d054..90fdc66 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -327,6 +327,14 @@ class TournamentParticipantUpdate(BaseModel): 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): @@ -337,13 +345,14 @@ class TournamentParticipantOut(BaseModel): 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.class_id::text AS class_id, tc.name AS class_name, tp.stat_level, tp.status """ @@ -369,7 +378,7 @@ async def add_tournament_participant( 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 + RETURNING id, player_id, handicap_index_snapshot, class_id, stat_level, status ) SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS} FROM inserted tp @@ -437,7 +446,7 @@ async def update_tournament_participant( 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 + RETURNING id, player_id, handicap_index_snapshot, class_id, stat_level, status ) SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS} FROM updated tp @@ -1275,7 +1284,7 @@ async def update_hole( rp = await conn.fetchrow( """ - SELECT trp.id, trp.tournament_participant_id::text AS tournament_participant_id, tp.cut + 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 @@ -1299,6 +1308,18 @@ async def update_hole( 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 ): @@ -1584,6 +1605,12 @@ class LeaderboardEntry(BaseModel): # 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_ @@ -1626,7 +1653,7 @@ async def _compute_individual_standings( """ SELECT tp.id::text AS tournament_participant_id, tp.player_id::text AS player_id, p.display_name AS player_name, - tp.cut, + 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, @@ -1690,6 +1717,19 @@ async def _compute_individual_standings( 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))) diff --git a/frontend/app/organizations/[id]/players/page.tsx b/frontend/app/organizations/[id]/players/page.tsx index d8025d7..7bfe4d7 100644 --- a/frontend/app/organizations/[id]/players/page.tsx +++ b/frontend/app/organizations/[id]/players/page.tsx @@ -5,9 +5,9 @@ export default async function OrgPlayersPage({ searchParams, }: { params: Promise<{ id: string }> - searchParams: Promise<{ name?: string }> + searchParams: Promise<{ name?: string; highlight?: string }> }) { const { id } = await params - const { name } = await searchParams - return + const { name, highlight } = await searchParams + return } diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index c3c9522..1ccccc9 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -23,6 +23,7 @@ import { ChevronDown, ChevronLeft, ChevronRight, + ExternalLink, Clock, Copy, Crosshair, @@ -152,6 +153,10 @@ type ApiParticipant = { // Statistikknivå (migrasjon 075, ADR-071) -- styrer hvor mye HoleGrid/ // HoleStatsSheet ber om ved scoring, tournament-bredt (ikke per runde). stat_level: "strokes_only" | "strokes_and_putts" | "full" + // Manuell status (migrasjon 087, 2026-08-18) -- "active" (uendret) eller + // dsq/rtd/dnf/dns. Overstyrer leaderboardets total-visning og blokkerer + // videre score-registrering i ALLE runder. + status: "active" | "dsq" | "rtd" | "dnf" | "dns" } // Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt @@ -783,6 +788,28 @@ export function IndividualTournamentDetail({ setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p))) } + // Manuell status (migrasjon 087, 2026-08-18) -- "Vi må manuelt kunne + // sette Dsq, Rtd, Dnf, og Dns." Tournament-bredt, blokkerer videre + // score i ALLE runder (strengere enn Cut). + async function setParticipantStatus( + participantId: string, + status: "active" | "dsq" | "rtd" | "dnf" | "dns", + ) { + setError(null) + const res = await fetch(`${base}/participants/${participantId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ status }), + }) + if (!res.ok) { + setError(await errorMessage(res, "Klarte ikke å endre status.")) + return + } + const updated: ApiParticipant = await res.json() + setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p))) + } + async function createClass(name: string, defaultTeeId: string | null) { setError(null) const res = await fetch(`${base}/classes`, { @@ -1006,6 +1033,7 @@ export function IndividualTournamentDetail({ onSetParticipantClass={setParticipantClass} onSetParticipantStatLevel={setParticipantStatLevel} onSetParticipantHandicap={setParticipantHandicap} + onSetParticipantStatus={setParticipantStatus} onError={setError} /> )} @@ -1104,6 +1132,7 @@ function SetupTab({ onSetParticipantClass, onSetParticipantStatLevel, onSetParticipantHandicap, + onSetParticipantStatus, onError, }: { base: string @@ -1136,6 +1165,10 @@ function SetupTab({ statLevel: "strokes_only" | "strokes_and_putts" | "full", ) => Promise onSetParticipantHandicap: (participantId: string, handicap: number | null) => Promise + onSetParticipantStatus: ( + participantId: string, + status: "active" | "dsq" | "rtd" | "dnf" | "dns", + ) => Promise onError: (message: string) => void }) { const [pool, setPool] = useState([]) @@ -1276,6 +1309,7 @@ function SetupTab({ onSetClass={onSetParticipantClass} onSetStatLevel={onSetParticipantStatLevel} onSetHandicap={onSetParticipantHandicap} + onSetStatus={onSetParticipantStatus} /> ) @@ -1668,6 +1702,17 @@ const STAT_LEVEL_LABELS: Record = { full: "All statistikk", } +// Manuell status (migrasjon 087, 2026-08-18) -- "Vi må manuelt kunne +// sette Dsq, Rtd, Dnf, og Dns." Blokkerer videre score i ALLE runder +// (strengere enn Cut) og overstyrer leaderboardets total-visning. +const PARTICIPANT_STATUS_LABELS: Record = { + active: "Aktiv", + dsq: "DSQ", + rtd: "RTD", + dnf: "DNF", + dns: "DNS", +} + function ParticipantsCard({ organizationId, tournamentId, @@ -1681,6 +1726,7 @@ function ParticipantsCard({ onSetClass, onSetStatLevel, onSetHandicap, + onSetStatus, }: { organizationId: string tournamentId: string @@ -1697,6 +1743,7 @@ function ParticipantsCard({ statLevel: "strokes_only" | "strokes_and_putts" | "full", ) => Promise onSetHandicap: (participantId: string, handicap: number | null) => Promise + onSetStatus: (participantId: string, status: "active" | "dsq" | "rtd" | "dnf" | "dns") => Promise }) { const [showImport, setShowImport] = useState(false) @@ -1735,13 +1782,27 @@ function ParticipantsCard({
    {participants.map((p) => (
  • -
    +
    {p.player_name} onSetHandicap(p.id, v)} /> + {/* Direkte kobling til spillerpoolen (2026-08-18, "det bør + dessuten være kobling direkte mellom spillerpool og + turneringsside") -- for å rette navn/e-post/kjønn/klubb + osv., som IKKE er turnering-spesifikke felt (kun HCP er + det, se HcpInput). Åpner spillerpoolen direkte på denne + spillerens rad, i stedet for at organisator må lete den + opp manuelt i en potensielt lang tabell. */} + +
    {classes.length > 0 && ( + {/* Manuell status (migrasjon 087, 2026-08-18) -- blokkerer + videre score i ALLE runder når satt til noe annet enn + "Aktiv", overstyrer leaderboardets total-visning. */} + - {editing && setEditing(false)} />} + {editing && ( + setEditing(false)} + highlightPlayerId={highlightPlayerId} + /> + )}
    ) } diff --git a/frontend/components/player-import-view.tsx b/frontend/components/player-import-view.tsx index 5bee26d..18760f7 100644 --- a/frontend/components/player-import-view.tsx +++ b/frontend/components/player-import-view.tsx @@ -484,6 +484,12 @@ function EditTable({ {rows.map((row, rowIndex) => ( None: + user = CurrentUser(user_id=user_id) + for n, gross in enumerate(scores, start=1): + await tournament_update_hole( + tournament_id, round_id, rp_id, n, + TournamentHoleUpdate(gross_strokes=gross, expected_version=None), + organization_id=org_id, user=user, + ) + + +async def _setup(org_id, owner_id, method="stroke_gross"): + tournament_id = await create_tournament(org_id, name="Status-turnering") + course_id = await create_course(org_id, name="Status Links") + for n in range(1, 19): + await create_org_hole(org_id, course_id, hole_number=n, par=4, stroke_index=n) + tee_id = await create_tee(org_id, course_id) + async with app_db.org_connection(org_id) as conn: + await conn.execute( + "UPDATE tournament SET format_type = 'individual', scoring_method = $2 WHERE id = $1", + tournament_id, method, + ) + round1_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1) + round2_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=2) + return tournament_id, course_id, tee_id, round1_id, round2_id + + +@pytest.mark.parametrize("status", ["dsq", "rtd", "dnf", "dns"]) +async def test_status_overrides_computed_total_label(pool, status): + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + tournament_id, course_id, tee_id, round1_id, round2_id = await _setup(org_id, owner_id) + + player_id = await create_player(org_id, display_name="Spiller") + tp_id = await create_tournament_participant(org_id, tournament_id, player_id) + rp_id = await create_tournament_round_participant(org_id, round1_id, tp_id, tee_id) + await _score_holes(tournament_id, round1_id, rp_id, org_id, owner_id, [4] * 18) + + await update_tournament_participant( + tournament_id, tp_id, TournamentParticipantUpdate(status=status), organization_id=org_id + ) + + entries = await individual_leaderboard(tournament_id, organization_id=org_id) + entry = next(e for e in entries if e.tournament_participant_id == tp_id) + assert entry.total_label == status.upper() + assert entry.is_leader is False + assert entry.position is None + assert entry.status == status + + +async def test_status_overrides_even_a_cut_survivor(pool): + """En DSQ-et spiller skal vises som DSQ, ikke CUT, selv om de + tilfeldigvis også overlevde cutten.""" + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + tournament_id, course_id, tee_id, round1_id, round2_id = await _setup(org_id, owner_id) + + from app.routers.tournaments import TournamentUpdate, update_tournament + from app.routers.individual_tournaments import apply_cut + + await update_tournament( + tournament_id, TournamentUpdate(cut_after_round=1, cut_size=5), organization_id=org_id, + ) + + player_id = await create_player(org_id, display_name="Spiller") + tp_id = await create_tournament_participant(org_id, tournament_id, player_id) + rp_id = await create_tournament_round_participant(org_id, round1_id, tp_id, tee_id) + await _score_holes(tournament_id, round1_id, rp_id, org_id, owner_id, [4] * 18) + + await apply_cut(tournament_id, organization_id=org_id) + await update_tournament_participant( + tournament_id, tp_id, TournamentParticipantUpdate(status="dsq"), organization_id=org_id + ) + + entries = await individual_leaderboard(tournament_id, organization_id=org_id) + entry = next(e for e in entries if e.tournament_participant_id == tp_id) + assert entry.total_label == "DSQ" + assert entry.cut is False # var faktisk innenfor cut-grensen + + +async def test_status_blocks_scoring_in_any_round_not_just_after_a_point(pool): + """Strengere enn Cut: en DSQ-et/RTD-et/DNF-et/DNS-et spiller er + blokkert i ALLE runder, ikke bare etter et gitt punkt.""" + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + tournament_id, course_id, tee_id, round1_id, round2_id = await _setup(org_id, owner_id) + + player_id = await create_player(org_id, display_name="Spiller") + tp_id = await create_tournament_participant(org_id, tournament_id, player_id) + rp1_id = await create_tournament_round_participant(org_id, round1_id, tp_id, tee_id) + rp2_id = await create_tournament_round_participant(org_id, round2_id, tp_id, tee_id) + + await update_tournament_participant( + tournament_id, tp_id, TournamentParticipantUpdate(status="dsq"), organization_id=org_id + ) + + user = CurrentUser(user_id=owner_id) + with pytest.raises(HTTPException) as exc_round1: + await tournament_update_hole( + tournament_id, round1_id, rp1_id, 1, + TournamentHoleUpdate(gross_strokes=4, expected_version=None), + organization_id=org_id, user=user, + ) + assert exc_round1.value.status_code == 403 + + with pytest.raises(HTTPException) as exc_round2: + await tournament_update_hole( + tournament_id, round2_id, rp2_id, 1, + TournamentHoleUpdate(gross_strokes=4, expected_version=None), + organization_id=org_id, user=user, + ) + assert exc_round2.value.status_code == 403 + + +async def test_active_status_is_unaffected_default(pool): + """Ingen regresjon -- default 'active' oppfører seg akkurat som før.""" + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + tournament_id, course_id, tee_id, round1_id, round2_id = await _setup(org_id, owner_id) + + player_id = await create_player(org_id, display_name="Spiller") + tp_id = await create_tournament_participant(org_id, tournament_id, player_id) + rp_id = await create_tournament_round_participant(org_id, round1_id, tp_id, tee_id) + await _score_holes(tournament_id, round1_id, rp_id, org_id, owner_id, [4] * 18) + + entries = await individual_leaderboard(tournament_id, organization_id=org_id) + entry = next(e for e in entries if e.tournament_participant_id == tp_id) + assert entry.status == "active" + assert entry.total_label == "E" + assert entry.position == "1"