Cut i individuelle turneringer: topp N og delt plass (ADR-087)

Manuelt anvendt (organisator trykker "Anvend cut"), etter et valgt
rundenummer. Kuttede spillere blokkeres fra scoring i senere runder og
vises i egen seksjon på leaderboardet. Migrasjon 083 lagt til, men IKKE
anvendt mot ekte teecup_db ennå -- venter på bekreftelse.
This commit is contained in:
Erol Haagenrud 2026-08-18 15:07:21 +02:00
parent 9f81258ef7
commit 19c527f834
8 changed files with 771 additions and 14 deletions

37
083_tournament_cut.sql Normal file
View file

@ -0,0 +1,37 @@
-- =====================================================================
-- TeeCup — migrasjon 083
-- Cut: topp N og delt plass, etter et valgt rundenummer, manuelt anvendt
-- =====================================================================
-- Kun for individuelle turneringer (stroke_gross/stroke_net/stableford
-- -- samme sett som Augusta-resultattavlen, se ADR-*/CHANGELOG #104/112).
-- Bruker bekreftet design 2026-08-17/18: "Topp N og delt plass" (den
-- N-te plassen og alle uavgjort med den slipper gjennom), "etter et
-- bestemt rundenummer" (organisator velger selv hvilken runde cutten
-- gjelder fra), "vises i egen 'Cut'-seksjon, blokkert fra videre
-- scoring" -- og eksplisitt et MANUELT "Anvend cut"-trykk, IKKE
-- automatisk ved fullspilt runde (for å unngå at cutten slår inn for
-- tidlig mens noen fortsatt spiller den runden).
--
-- cut_after_round/cut_size er KONFIGURASJON (satt i Oppsett, kan endres
-- fritt inntil cutten faktisk anvendes). cut_applied_at er null inntil
-- organisator trykker "Anvend cut" -- selve anvendelsen er et eksplisitt
-- API-kall (`app/routers/individual_tournaments.py`) som beregner
-- stillingen etter cut_after_round og setter tournament_participant.cut
-- = true for alle UTENFOR cut_size+uavgjort-grensen. Idempotent: kan
-- trykkes på nytt (f.eks. etter en rettelse) -- regner alt på nytt fra
-- bunnen av, ikke bare inkrementelt.
--
-- tournament_participant.cut er IKKE nullable (false = fortsatt med,
-- samme "ingen regresjon for eksisterende data"-prinsipp som resten av
-- prosjektets nullable/default-valg).
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE tournament
ADD COLUMN cut_after_round smallint,
ADD COLUMN cut_size smallint CHECK (cut_size IS NULL OR cut_size > 0),
ADD COLUMN cut_applied_at timestamptz;
ALTER TABLE tournament_participant
ADD COLUMN cut boolean NOT NULL DEFAULT false;

View file

@ -8902,6 +8902,111 @@ ned.
--- ---
## ADR-087: Cut i individuelle turneringer -- topp N og delt plass, manuelt anvendt (2026-08-18)
Bruker spurte om Cut var tenkt på ("Delt 18. plass eller bedre klarer
Cut'en"). Undersøkt FØR bygging: bekreftet at konseptet ikke fantes noe
sted -- verken i skjema, backend eller frontend. Design bekreftet med
bruker over to runder med spørsmål/svar:
- **Topp N og delt plass.** Alle med samme til-par-tall som den N-te
plassen slipper gjennom -- IKKE et strengt kutt til nøyaktig N stykker.
- **Etter et organisator-valgt rundenummer.** Ikke fast (f.eks. "alltid
etter runde 1") -- fritt konfigurerbart per turnering.
- **Vises i egen "Cut"-seksjon på leaderboardet, blokkert fra videre
scoring.** Kuttede spillere skal IKKE bare vises dempet innimellom
resten -- egen, tydelig atskilt del av tabellen.
- **MANUELT anvendt, ikke automatisk.** Bruker eksplisitt: en "Anvend
cut"-knapp organisator trykker selv, IKKE noe som slår inn automatisk
idet runden er fullspilt -- unngår at cutten treffer feil mens noen
fortsatt er midt i runden.
**Migrasjon 083:** `tournament.cut_after_round`/`cut_size`/
`cut_applied_at` (ren KONFIGURASJON + tidsstempel for når cutten faktisk
ble anvendt -- de to første er frie å endre helt til organisator trykker
"Anvend cut"). `tournament_participant.cut boolean NOT NULL DEFAULT
false` (selve utfallet, satt i bulk av selve anvendelsen).
**Backend (`app/routers/individual_tournaments.py`):**
- `_compute_individual_standings`/`_attach_stroke_play_columns` fikk et
nytt, valgfritt `max_sequence`-parameter -- lar standings beregnes KUN
ut fra runder t.o.m. et gitt rundenummer, uavhengig av hvor langt
turneringen faktisk har kommet siden (brukt av selve cut-beregningen
til å regne "stillingen som den var etter cut_after_round", ikke
dagens stilling). `LeaderboardEntry` fikk `rank: int | None` (rå
konkurranse-rangering uten "T"-prefiks, til numerisk cut-grense-
sammenligning) og `cut: bool`.
- Ny `POST .../apply-cut`: henter stillingen med `max_sequence=
cut_after_round`, finner cut-grensen som `rank` til deltakeren på
`cut_size`-plassen (uavgjorte på nøyaktig den rangeringen slipper
også gjennom -- "delt plass"), setter `tournament_participant.cut` i
ETT bulk-`UPDATE ... SET cut = (id = ANY($ids))` for HELE feltet
(idempotent -- regner alt på nytt fra bunnen hver gang, ikke
inkrementelt, så en rettelse i en tidligere runde og et nytt trykk gir
korrekt oppdatert resultat). Deltakere som ikke har spilt ETT eneste
hull gjennom cut-punktet kuttes alltid, uansett `cut_size`.
- `update_hole`: ny sperre -- en kuttet deltaker (`tournament_participant.
cut = true`) blokkeres (403) fra å registrere score i runder MED
høyere `sequence` enn `cut_after_round`. Runder T.O.M. cut-punktet
forblir fritt redigerbare (cutten låser ikke historikken, kun
fremtiden).
- `_compute_individual_standings` sin vanlige (ikke-cut-begrensede)
variant: kuttede deltakere sorteres ALLTID etter alle ikke-kuttede
(stabil sortering på `cut` alene, bevarer den allerede riktige til-
par-rekkefølgen innad i begge gruppene -- uten dette kunne en kuttet
spiller med et godt frosset tall endt opp foran en som fortsatt
spiller og nettopp har hatt en dårlig runde). `total_label`
overstyres til `"CUT"` -- gjenbruker STATUS-mekanismen WD/DQ/DNS/NR
allerede har i `stroke-play-leaderboard.tsx`.
**Frontend:**
- `stroke-play-leaderboard.tsx` (liten, håndkodet tilføyelse til den
eksisterende V0-komponenten -- IKKE en ny UI-flate, se
[[feedback_frontend_via_v0]]): ny `CutLineRow`, en tydelig, tilgjengelig
skillelinje ("CUT -- spillerne under gikk ikke videre") satt inn RETT
FØR første rad med `totalLabel === "CUT"` -- komponenten hadde
allerede en kommentar fra V0-rundens prompt om at nettopp dette skulle
være enkelt å legge til senere ("A future 'cut line' divider row can be
inserted between any two FieldRows here without restructuring").
- `individual-tournament-detail.tsx`: ny `CutCard` i Oppsett-fanen
(samme "vanlig innstillingskort"-mønster som Scoringsmetode/BBB-bonus/
flaggkart-seksjonene rett over den -- alle allerede håndkodet, ikke
V0-eksportert, så dette er en tilføyelse i samme stil, ikke en ny
UI-flate). To tallfelt (rundenummer, antall som går videre) + "Lagre
innstilling", og en separat "Anvend cut"-knapp med `confirm()` (samme
føre-var-mønster som andre konsekvensfulle handlinger i appen, f.eks.
slett turnering) -- vises kun for brutto/netto/stableford-turneringer.
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend: 6 nye
tester i `tests/test_cut.py` (topp N + delt plass slipper en ekstra
spiller gjennom, idempotens, avvist uten konfigurasjon satt, kuttet
spiller blokkert i senere runde MEN fortsatt kan rette runden t.o.m.
cut-punktet, leaderboard-sortering+status) -- 126/126 grønt (opp fra
120), kjørt via `scripts/run_backend_tests.sh` (egen scratch-database,
rører aldri ekte `teecup_db`). Frontend: midlertidig lokal
`/cutlinepreviewtmp`-side (kun i `next dev`, ALDRI committet) rendret
`StrokePlayLeaderboard` med `MOCK_ROWS` utvidet med to nye "CUT"-rader --
skjermbilde + a11y-snapshot bekreftet skillelinjen dukker opp nøyaktig
rett før første kuttede rad, med riktig R1-tall fortsatt synlig (kun
TODAY/HULL/R2+ blir dashet ut, samme mønster som WD/DQ).
**IKKE fullt nettleser-verifisert denne runden:** selve `CutCard`-
skjemaet (Oppsett-fanen) er IKKE klikket gjennom i en ekte scratch-
nettleserøkt -- kun `tsc --noEmit` (feltnavnene i fetch-kallene matcher
`TournamentUpdate`/`apply_cut` strukturelt) og gjenbruk av et allerede
tre ganger fungerende, identisk PATCH-mønster (scoringsmetode/BBB-bonus/
flaggkart, rett over i samme fil). Vurdert som lav nok gjenværende
risiko til å ikke rettferdiggjøre en full 2FA-innlogging-scratch-runde i
en allerede svært lang økt -- bør likevel klikkes gjennom manuelt (eller
i en fremtidig scratch-runde) før dette lener seg tungt på i en skarp
turnering.
**Rullet ut:** venter på bekreftelse (migrasjon 083 + backend + frontend
-- egen bekreftelse for selve migrasjonen mot ekte `teecup_db`, se
CLAUDE.md sin sikkerhetsregel).
---
## Utviklingsplan (rekkefølge) ## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -12632,3 +12632,36 @@ Neste steg:
**Rullet ut 2026-08-18** -- bruker bekreftet. `docker compose build **Rullet ut 2026-08-18** -- bruker bekreftet. `docker compose build
teecup_api teecup_frontend && up -d` for begge, rene logger, 200 teecup_api teecup_frontend && up -d` for begge, rene logger, 200
OK. OK.
115. **Cut i individuelle turneringer -- 2026-08-18 (ADR-087).** Bruker:
"Sett i gang cut-funksjonen" (design bekreftet tidligere samme dag:
topp N og delt plass, etter et valgt rundenummer, egen "Cut"-seksjon
på leaderboardet, blokkert fra videre scoring). Ett gjenstående
teknisk valg avklart med bruker: cutten anvendes MANUELT (organisator
trykker "Anvend cut"), ikke automatisk ved fullspilt runde.
Migrasjon 083 (`tournament.cut_after_round`/`cut_size`/
`cut_applied_at`, `tournament_participant.cut`), ny `POST .../apply-
cut` (idempotent, regner alt på nytt fra bunnen hver gang), sperre i
`update_hole` mot score-registrering i runder etter cut-punktet for
kuttede deltakere (historikken t.o.m. cut-punktet forblir
redigerbar). Leaderboard: kuttede deltakere sorteres alltid sist
(stabil sortering, ikke basert på deres frosne til-par-tall alene),
`total_label="CUT"` gjenbruker den eksisterende WD/DQ-status-
mekanismen i `stroke-play-leaderboard.tsx`, pluss en ny synlig "CUT"-
skillelinje satt inn rett før første kuttede rad (nøyaktig det
V0-eksporten sin egen kommentar fra punkt 112 forutså plass til). Ny
"Cut"-kort i Oppsett-fanen (`individual-tournament-detail.tsx`),
håndkodet i samme stil som de tre andre allerede håndkodede
innstillingskortene rett over (scoringsmetode/BBB-bonus/flaggkart) --
se ADR-087 for full detalj, inkl. hvorfor V0 ikke ble brukt her.
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55, 6 nye
backend-tester i `tests/test_cut.py` -- 126/126 grønt (opp fra 120).
Skjermbilde-bekreftet cut-linje-skillelinjen. Se ADR-087 for full
verifiseringsdetalj, inkl. hva som IKKE ble browser-klikket-gjennom
denne runden (selve Oppsett-skjemaet -- lavt vurdert restrisiko,
gjenbruker et identisk, tre ganger allerede fungerende mønster).
**Rullet ut:** venter på bekreftelse (migrasjon 083 mot ekte
`teecup_db` krever egen bekreftelse, se CLAUDE.md).

View file

@ -940,7 +940,8 @@ async def update_hole(
async with org_connection(organization_id) as conn, translate_db_errors(): async with org_connection(organization_id) as conn, translate_db_errors():
round_row = await conn.fetchrow( round_row = await conn.fetchrow(
""" """
SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config, t.scoring_method SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config, tr.sequence,
t.scoring_method, t.cut_after_round
FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
WHERE tr.id = $1 WHERE tr.id = $1
""", """,
@ -953,8 +954,9 @@ async def update_hole(
rp = await conn.fetchrow( rp = await conn.fetchrow(
""" """
SELECT trp.id, trp.tournament_participant_id::text AS tournament_participant_id SELECT trp.id, trp.tournament_participant_id::text AS tournament_participant_id, tp.cut
FROM tournament_round_participant trp 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 WHERE trp.id = $1 AND trp.tournament_round_id = $2
""", """,
round_participant_id, round_participant_id,
@ -963,6 +965,19 @@ async def update_hole(
if rp is None: if rp is None:
raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.") raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
# Cut (migrasjon 083, 2026-08-18): en kuttet deltaker kan fortsatt
# se/rette score i runder T.O.M. cut_after_round (historikken deres
# skal ikke låses), men er blokkert fra å registrere score i runder
# ETTER cut-punktet -- det er selve poenget med en cut.
if (
rp["cut"]
and round_row["cut_after_round"] is not None
and round_row["sequence"] > round_row["cut_after_round"]
):
raise app_error(
403, "CUT", "Denne spilleren er kuttet og kan ikke registrere score i denne runden."
)
if not await user_is_own_tournament_participant( if not await user_is_own_tournament_participant(
conn, organization_id, rp["tournament_participant_id"], user.user_id conn, organization_id, rp["tournament_participant_id"], user.user_id
): ):
@ -1237,6 +1252,17 @@ class LeaderboardEntry(BaseModel):
# (scheduled_at er nullable -- ikke alle turneringer setter det). # (scheduled_at er nullable -- ikke alle turneringer setter det).
next_tee_time: str | None = None next_tee_time: str | None = None
next_start_hole: int | None = None next_start_hole: int | None = None
# Cut (migrasjon 083, 2026-08-18) -- KUN populert (og kun meningsfullt)
# for scoring_method i (stroke_gross, stroke_net, stableford), samme
# begrensning som resten av Augusta-resultattavlen. `rank` er den RÅ
# konkurranse-rangeringen (uten "T"-prefiks/uavgjort-tekst -- se
# `position` for visningsklar streng) -- brukt internt av apply_cut for
# å finne cut-grensen numerisk, men trygt å eksponere. `cut=True` når
# organisator har trykket "Anvend cut" og denne deltakeren falt utenfor
# cut_size+uavgjort-grensen (eller ikke hadde spilt noe gjennom
# cut_after_round i det hele tatt).
rank: int | None = None
cut: bool = False
# Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- beste resultat # Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- beste resultat
# per hull på tvers av ALLE turneringens runder (samme bane, håndhevet # per hull på tvers av ALLE turneringens runder (samme bane, håndhevet
# ved rundeopprettelse). Alltid None/tom liste for øvrige scoring_ # ved rundeopprettelse). Alltid None/tom liste for øvrige scoring_
@ -1245,7 +1271,9 @@ class LeaderboardEntry(BaseModel):
eclectic_holes: list[EclecticHoleCellOut] = [] eclectic_holes: list[EclecticHoleCellOut] = []
async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict, list[LeaderboardEntry]]: async def _compute_individual_standings(
conn, tournament_id: str, max_sequence: int | None = None
) -> tuple[dict, list[LeaderboardEntry]]:
"""Delt mellom `individual_leaderboard` under (denne ENE turneringens """Delt mellom `individual_leaderboard` under (denne ENE turneringens
egen visning) og Order of Merit sitt leaderboard-endepunkt egen visning) og Order of Merit sitt leaderboard-endepunkt
(`order_of_merit.py`, som summerer resultater over FLERE lenkede (`order_of_merit.py`, som summerer resultater over FLERE lenkede
@ -1253,6 +1281,12 @@ async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict,
posisjon-/til-par-beregningen (inkl. den til-par-normaliserte posisjon-/til-par-beregningen (inkl. den til-par-normaliserte
rangeringsnøkkelen, se kommentar i `_attach_stroke_play_columns`). rangeringsnøkkelen, se kommentar i `_attach_stroke_play_columns`).
`max_sequence` (2026-08-18, Cut): når satt, regnes stillingen KUN ut
fra runder t.o.m. den sekvensen -- brukt av `apply_cut` til å beregne
"stillingen som den var etter cut_after_round", uavhengig av hvor
langt turneringen faktisk har kommet i mellomtiden. `None` (standard)
= alle runder, uendret oppførsel for den vanlige leaderboard-visningen.
Returnerer `(tournament_row, entries)` -- kalleren i OOM-sammenheng Returnerer `(tournament_row, entries)` -- kalleren i OOM-sammenheng
trenger `tournament_row["scoring_method"]` for å vite hvilket felt trenger `tournament_row["scoring_method"]` for å vite hvilket felt
hver entry (`gross_total`/`net_total`/`stableford_total`/`position`) hver entry (`gross_total`/`net_total`/`stableford_total`/`position`)
@ -1269,6 +1303,7 @@ async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict,
""" """
SELECT tp.id::text AS tournament_participant_id, tp.player_id::text AS player_id, SELECT tp.id::text AS tournament_participant_id, tp.player_id::text AS player_id,
p.display_name AS player_name, p.display_name AS player_name,
tp.cut,
count(trs.id) FILTER (WHERE trs.holes_played > 0) AS rounds_played, count(trs.id) FILTER (WHERE trs.holes_played > 0) AS rounds_played,
SUM(trs.gross_total)::int AS gross_total, SUM(trs.gross_total)::int AS gross_total,
SUM(trs.net_total)::int AS net_total, SUM(trs.net_total)::int AS net_total,
@ -1278,12 +1313,17 @@ async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict,
tc.id::text AS class_id, tc.name AS class_name tc.id::text AS class_id, tc.name AS class_name
FROM tournament_participant tp FROM tournament_participant tp
JOIN player p ON p.id = tp.player_id JOIN player p ON p.id = tp.player_id
LEFT JOIN tournament_round_score trs ON trs.tournament_participant_id = tp.id LEFT JOIN tournament_round_score trs
ON trs.tournament_participant_id = tp.id
AND ($2::smallint IS NULL OR trs.tournament_round_id IN (
SELECT id FROM tournament_round WHERE tournament_id = $1 AND sequence <= $2
))
LEFT JOIN tournament_class tc ON tc.id = tp.class_id LEFT JOIN tournament_class tc ON tc.id = tp.class_id
WHERE tp.tournament_id = $1 WHERE tp.tournament_id = $1
GROUP BY tp.id, p.display_name, tc.id, tc.name GROUP BY tp.id, p.display_name, tc.id, tc.name
""", """,
tournament_id, tournament_id,
max_sequence,
) )
entries = [LeaderboardEntry(**dict(r)) for r in rows] entries = [LeaderboardEntry(**dict(r)) for r in rows]
@ -1299,7 +1339,26 @@ async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict,
# deltaker med KUN 18 hull spilt (lavere rå sum) rangerte foran # deltaker med KUN 18 hull spilt (lavere rå sum) rangerte foran
# deltakere med -5 til par over 27-36 hull, kun fordi rått # deltakere med -5 til par over 27-36 hull, kun fordi rått
# slagtall er mindre jo færre hull man har spilt. # slagtall er mindre jo færre hull man har spilt.
await _attach_stroke_play_columns(conn, tournament_id, method, entries) await _attach_stroke_play_columns(conn, tournament_id, method, entries, max_sequence=max_sequence)
# Cut (2026-08-18): kuttede spillere vises samlet BAKERST, som en
# egen seksjon -- ALDRI innimellom fortsatt-med-spillere basert på
# frossent til-par-tall alene (en kuttet spiller sin score endrer
# seg jo aldri mer, mens resten av feltet fortsetter å spille --
# uten dette ville en kuttet spiller med et godt frosset tall kunne
# ende opp foran en som fortsatt spiller og har hatt en dårlig
# runde). `total_label` overstyres til "CUT" -- gjenbruker
# STATUS-mekanismen som WD/DQ/DNS/NR allerede har i
# stroke-play-leaderboard.tsx (dempet rad, egen badge), pluss en
# visuell skillelinje i komponenten mellom siste ikke-kuttede og
# første kuttede rad. `_attach_stroke_play_columns` har ALLEREDE
# sortert `entries` riktig internt (beste til-par først) -- en
# STABIL sortering på kun `cut` her bevarer den rekkefølgen innad i
# begge gruppene, ingen ny rangeringsnøkkel trengs.
entries.sort(key=lambda e: e.cut)
for e in entries:
if e.cut:
e.total_label = "CUT"
e.is_leader = False
elif method == "copenhagen": elif method == "copenhagen":
# Flest poeng totalt vinner (kilden, spilletyper-og-spilleformer-2023.pdf s.4). # 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))) entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0)))
@ -1334,6 +1393,80 @@ async def individual_leaderboard(
return entries return entries
class CutResult(BaseModel):
cut_after_round: int
cut_size: int
survivors: int
cut_count: int
cut_participant_ids: list[str]
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/apply-cut",
response_model=CutResult,
)
async def apply_cut(
tournament_id: str,
organization_id: str = Depends(get_authorized_org),
) -> CutResult:
"""Anvender cutten MANUELT (migrasjon 083, 2026-08-18) -- bruker
bekreftet eksplisitt: IKKE automatisk ved fullspilt runde, organisator
trykker selv en "Anvend cut"-knapp når de er klare (unngår at cutten
slår inn mens noen fortsatt er midt i runden). Topp `cut_size` OG
delt plass slipper gjennom -- alle med samme til-par-tall som
grenseplassen regnes som "innenfor". Deltakere som ikke har spilt ETT
eneste hull gjennom `cut_after_round` kuttes alltid, uansett `cut_size`
(ingen resultat å beskytte).
IDEMPOTENT -- regner cut-linjen nytt fra bunnen av HELE feltet hver
gang (aldri inkrementelt), et nytt trykk etter en score-rettelse i
en tidligere runde gir riktig oppdatert resultat, ikke et lag oppå
forrige kjøring. Kan derfor trykkes flere ganger."""
async with org_connection(organization_id) as conn, translate_db_errors():
tournament = await conn.fetchrow(
"SELECT format_type, scoring_method, cut_after_round, cut_size FROM tournament WHERE id = $1",
tournament_id,
)
if tournament is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
if tournament["format_type"] != "individual" or tournament["scoring_method"] not in (
"stroke_gross", "stroke_net", "stableford"
):
raise app_error(
400, "VALIDATION_FAILED", "Cut er kun støttet for brutto-, netto- eller stableford-turneringer."
)
cut_after_round = tournament["cut_after_round"]
cut_size = tournament["cut_size"]
if cut_after_round is None or cut_size is None:
raise app_error(
400, "VALIDATION_FAILED", "Sett rundenummer og antall som går videre til cut før du anvender den."
)
_, entries = await _compute_individual_standings(conn, tournament_id, max_sequence=cut_after_round)
ranked = [e for e in entries if e.rank is not None]
ranked.sort(key=lambda e: e.rank)
if ranked and cut_size < len(ranked):
cutoff_rank = ranked[cut_size - 1].rank
else:
cutoff_rank = ranked[-1].rank if ranked else 0
cut_ids = [e.tournament_participant_id for e in entries if e.rank is None or e.rank > cutoff_rank]
await conn.execute(
"UPDATE tournament_participant SET cut = (id = ANY($2::uuid[])) WHERE tournament_id = $1",
tournament_id,
cut_ids,
)
await conn.execute("UPDATE tournament SET cut_applied_at = now() WHERE id = $1", tournament_id)
return CutResult(
cut_after_round=cut_after_round,
cut_size=cut_size,
survivors=len(entries) - len(cut_ids),
cut_count=len(cut_ids),
cut_participant_ids=cut_ids,
)
_HOLE_COUNT_BY_CONFIG = {"full_18": 18, "front_9": 9, "back_9": 9} _HOLE_COUNT_BY_CONFIG = {"full_18": 18, "front_9": 9, "back_9": 9}
@ -1352,7 +1485,7 @@ def _to_par_tone(v: int) -> str:
async def _attach_stroke_play_columns( async def _attach_stroke_play_columns(
conn, tournament_id: str, method: str, entries: list[LeaderboardEntry] conn, tournament_id: str, method: str, entries: list[LeaderboardEntry], max_sequence: int | None = None
) -> None: ) -> None:
"""Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx): """Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx):
POS/TODAY/THRU/TOTAL/R1-Rn. Muterer `entries` (allerede riktig sortert av POS/TODAY/THRU/TOTAL/R1-Rn. Muterer `entries` (allerede riktig sortert av
@ -1360,6 +1493,8 @@ async def _attach_stroke_play_columns(
gross_total/net_total/stableford_total som ALLEREDE er summert riktig, gross_total/net_total/stableford_total som ALLEREDE er summert riktig,
legger kun til det som mangler: par-for-spilte-hull (for til-par-tall), legger kun til det som mangler: par-for-spilte-hull (for til-par-tall),
per-runde nedbrytning, og posisjon-med-uavgjort-håndtering. per-runde nedbrytning, og posisjon-med-uavgjort-håndtering.
`max_sequence` (2026-08-18, Cut) -- se `_compute_individual_standings`.
""" """
rounds_meta = await conn.fetch( rounds_meta = await conn.fetch(
""" """
@ -1399,9 +1534,11 @@ async def _attach_stroke_play_columns(
AND trh.gross_strokes IS NOT NULL AND trh.gross_strokes IS NOT NULL
) pp ON true ) pp ON true
WHERE tr.tournament_id = $1 AND trs.holes_played > 0 WHERE tr.tournament_id = $1 AND trs.holes_played > 0
AND ($2::smallint IS NULL OR tr.sequence <= $2)
ORDER BY tr.sequence ORDER BY tr.sequence
""", """,
tournament_id, tournament_id,
max_sequence,
) )
by_participant: dict[str, dict[int, object]] = {} by_participant: dict[str, dict[int, object]] = {}
@ -1527,6 +1664,7 @@ async def _attach_stroke_play_columns(
for k in range(i, j): for k in range(i, j):
entries[k].position = None if not_started_group else (f"T{rank}" if tied else str(rank)) entries[k].position = None if not_started_group else (f"T{rank}" if tied else str(rank))
entries[k].is_leader = rank == 1 and not not_started_group entries[k].is_leader = rank == 1 and not not_started_group
entries[k].rank = None if not_started_group else rank
i = j i = j

View file

@ -61,7 +61,8 @@ _TOURNAMENT_COLUMNS = """
id::text, name, status::text, start_date, end_date, id::text, name, status::text, start_date, end_date,
visibility, description, registration_deadline, registration_capacity, visibility, description, registration_deadline, registration_capacity,
registration_overflow_policy, registration_requires_approval, hero_image_key, registration_overflow_policy, registration_requires_approval, hero_image_key,
join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible,
cut_after_round, cut_size, cut_applied_at
""" """
# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A). # Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A).
@ -92,6 +93,14 @@ class Tournament(BaseModel):
bbb_sweep_bonus_enabled: bool bbb_sweep_bonus_enabled: bool
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067). # Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
flag_map_visible: bool 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
def _tournament_from_row(row) -> Tournament: def _tournament_from_row(row) -> Tournament:
@ -124,7 +133,7 @@ async def list_tournaments(
t.registration_capacity, t.registration_overflow_policy, t.registration_capacity, t.registration_overflow_policy,
t.registration_requires_approval, t.hero_image_key, t.registration_requires_approval, t.hero_image_key,
t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled, t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled,
t.flag_map_visible t.flag_map_visible, t.cut_after_round, t.cut_size, t.cut_applied_at
FROM tournament t FROM tournament t
LEFT JOIN ( LEFT JOIN (
SELECT tournament_id, SELECT tournament_id,
@ -201,6 +210,12 @@ class TournamentUpdate(BaseModel):
# get_authorized_org under). Standard AV, samme "skjult til avslørt"- # get_authorized_org under). Standard AV, samme "skjult til avslørt"-
# filosofi som visibility. # filosofi som visibility.
flag_map_visible: bool | None = None 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)
# hero_image_key er BEVISST IKKE med her -- den settes kun via # hero_image_key er BEVISST IKKE med her -- den settes kun via
# upload_hero_image under, aldri som en fritekst-streng klienten sender, # upload_hero_image under, aldri som en fritekst-streng klienten sender,
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting. # siden den nå alltid kommer fra en ekte, server-konvertert opplasting.

View file

@ -109,6 +109,12 @@ type ApiTournamentInfo = {
bbb_sweep_bonus_enabled: boolean bbb_sweep_bonus_enabled: boolean
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067). // Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
flag_map_visible: boolean flag_map_visible: boolean
// Cut (migrasjon 083, 2026-08-18) -- kun meningsfullt for brutto/netto/
// stableford. cut_applied_at er null inntil organisator faktisk trykker
// "Anvend cut" -- cut_after_round/cut_size alene er bare konfigurasjon.
cut_after_round: number | null
cut_size: number | null
cut_applied_at: string | null
} }
type ApiParticipant = { type ApiParticipant = {
@ -508,6 +514,56 @@ export function IndividualTournamentDetail({
} }
} }
// Cut (migrasjon 083, 2026-08-18) -- ren KONFIGURASJON, fritt endre/
// nullstille helt til "Anvend cut" faktisk trykkes (applyCut under).
async function updateCutConfig(afterRound: number | null, size: number | null) {
if (!tournament) return
const previous = tournament
setTournament({ ...tournament, cut_after_round: afterRound, cut_size: size })
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ cut_after_round: afterRound, cut_size: size }),
})
if (!res.ok) throw new Error()
} catch {
setTournament(previous)
setError("Klarte ikke å lagre cut-innstillingene. Prøv igjen.")
}
}
// MANUELL anvendelse (bruker bekreftet eksplisitt 2026-08-18: IKKE
// automatisk ved fullspilt runde) -- idempotent på serveren, men likevel
// en reell, konsekvensfull handling for ekte spillere (blokkerer dem fra
// videre scoring), derfor samme confirm()-mønster som andre irreversible
// handlinger i appen (slett turnering osv.).
async function applyCut() {
if (!tournament) return
if (
!confirm(
"Anvende cut nå? Spillere utenfor grensen blokkeres fra å registrere score i senere runder. Kan kjøres på nytt (regner alt på nytt), men bør ikke trykkes før alle er ferdige med runden.",
)
) {
return
}
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/apply-cut`, {
method: "POST",
credentials: "include",
})
if (!res.ok) throw new Error()
const result: { survivors: number; cut_count: number } = await res.json()
setTournament({ ...tournament, cut_applied_at: new Date().toISOString() })
await refreshParticipants()
setError(null)
alert(`Cut anvendt: ${result.survivors} går videre, ${result.cut_count} er kuttet.`)
} catch {
setError("Klarte ikke å anvende cutten. Prøv igjen.")
}
}
async function addParticipant(playerId: string) { async function addParticipant(playerId: string) {
setError(null) setError(null)
const res = await fetch(`${base}/participants`, { const res = await fetch(`${base}/participants`, {
@ -817,6 +873,8 @@ export function IndividualTournamentDetail({
onUpdateScoringMethod={updateScoringMethod} onUpdateScoringMethod={updateScoringMethod}
onUpdateBbbSweepBonus={updateBbbSweepBonus} onUpdateBbbSweepBonus={updateBbbSweepBonus}
onUpdateFlagMapVisible={updateFlagMapVisible} onUpdateFlagMapVisible={updateFlagMapVisible}
onUpdateCutConfig={updateCutConfig}
onApplyCut={applyCut}
onAddParticipant={addParticipant} onAddParticipant={addParticipant}
onAddNewPlayer={addNewPlayerAndParticipant} onAddNewPlayer={addNewPlayerAndParticipant}
onImportComplete={refreshParticipants} onImportComplete={refreshParticipants}
@ -890,6 +948,8 @@ function SetupTab({
onUpdateScoringMethod, onUpdateScoringMethod,
onUpdateBbbSweepBonus, onUpdateBbbSweepBonus,
onUpdateFlagMapVisible, onUpdateFlagMapVisible,
onUpdateCutConfig,
onApplyCut,
onAddParticipant, onAddParticipant,
onAddNewPlayer, onAddNewPlayer,
onImportComplete, onImportComplete,
@ -914,6 +974,8 @@ function SetupTab({
onUpdateScoringMethod: (method: string) => void onUpdateScoringMethod: (method: string) => void
onUpdateBbbSweepBonus: (enabled: boolean) => void onUpdateBbbSweepBonus: (enabled: boolean) => void
onUpdateFlagMapVisible: (visible: boolean) => void onUpdateFlagMapVisible: (visible: boolean) => void
onUpdateCutConfig: (afterRound: number | null, size: number | null) => Promise<void>
onApplyCut: () => Promise<void>
onAddParticipant: (playerId: string) => Promise<void> onAddParticipant: (playerId: string) => Promise<void>
onAddNewPlayer: (input: NewPlayerInput) => Promise<void> onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
onImportComplete: () => Promise<void> onImportComplete: () => Promise<void>
@ -1022,6 +1084,11 @@ function SetupTab({
onError={onError} onError={onError}
/> />
{tournament.scoring_method != null &&
["stroke_gross", "stroke_net", "stableford"].includes(tournament.scoring_method) && (
<CutCard tournament={tournament} rounds={rounds} onUpdateConfig={onUpdateCutConfig} onApplyCut={onApplyCut} />
)}
<ClassesCard <ClassesCard
classes={classes} classes={classes}
courses={courses} courses={courses}
@ -1049,6 +1116,107 @@ function SetupTab({
) )
} }
// Cut (migrasjon 083, 2026-08-18) -- topp N og delt plass, etter et
// organisator-valgt rundenummer, MANUELT anvendt (bruker bekreftet
// eksplisitt: ikke automatisk ved fullspilt runde). Samme "vanlig
// innstillingskort"-mønster som Scoringsmetode-seksjonen over -- ingen
// V0-runde nødvendig, dette er en liten tilføyelse til et allerede
// håndkodet oppsett-skjema, ikke en ny selvstendig UI-flate.
function CutCard({
tournament,
rounds,
onUpdateConfig,
onApplyCut,
}: {
tournament: ApiTournamentInfo
rounds: ApiRound[]
onUpdateConfig: (afterRound: number | null, size: number | null) => Promise<void>
onApplyCut: () => Promise<void>
}) {
const [afterRound, setAfterRound] = useState(tournament.cut_after_round?.toString() ?? "")
const [size, setSize] = useState(tournament.cut_size?.toString() ?? "")
const [saving, setSaving] = useState(false)
const [applying, setApplying] = useState(false)
const sortedRounds = [...rounds].sort((a, b) => a.sequence - b.sequence)
const dirty = afterRound !== (tournament.cut_after_round?.toString() ?? "") || size !== (tournament.cut_size?.toString() ?? "")
const configComplete = afterRound.trim() !== "" && size.trim() !== ""
async function handleSave() {
setSaving(true)
await onUpdateConfig(afterRound.trim() ? Number(afterRound) : null, size.trim() ? Number(size) : null)
setSaving(false)
}
async function handleApply() {
setApplying(true)
await onApplyCut()
setApplying(false)
}
return (
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<h2 className="text-base font-bold text-foreground">Cut</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Topp N og delt plass slipper videre etter valgt runde. Kuttede spillere vises i egen
seksjon leaderboardet og blokkeres fra å registrere score i senere runder.
{sortedRounds.length > 0 && (
<> Turneringen har {sortedRounds.length} {sortedRounds.length === 1 ? "runde" : "runder"} satt opp.</>
)}
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Etter runde nr.</span>
<input
type="number"
min={1}
value={afterRound}
onChange={(e) => setAfterRound(e.target.value)}
placeholder="f.eks. 2"
className="h-11 rounded-xl border border-border bg-background px-4 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</label>
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Antall som går videre</span>
<input
type="number"
min={1}
value={size}
onChange={(e) => setSize(e.target.value)}
placeholder="f.eks. 20"
className="h-11 rounded-xl border border-border bg-background px-4 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</label>
<button
type="button"
onClick={() => void handleSave()}
disabled={!dirty || saving}
className="inline-flex h-11 shrink-0 items-center justify-center gap-2 rounded-xl border border-border bg-background px-5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? "Lagrer..." : "Lagre innstilling"}
</button>
</div>
<div className="flex flex-col items-start gap-2 border-t border-border pt-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-medium text-muted-foreground">
{tournament.cut_applied_at
? `Cut anvendt ${new Date(tournament.cut_applied_at).toLocaleString("no-NO", { dateStyle: "medium", timeStyle: "short" })}. Kan anvendes på nytt hvis noe rettes.`
: "Cut er ikke anvendt ennå -- ingen spillere er blokkert."}
</p>
<button
type="button"
onClick={() => void handleApply()}
disabled={!configComplete || applying}
className="inline-flex h-11 shrink-0 items-center justify-center gap-2 rounded-xl bg-primary px-5 text-sm font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{applying ? "Anvender..." : tournament.cut_applied_at ? "Anvend cut på nytt" : "Anvend cut"}
</button>
</div>
</section>
)
}
// Konkurranseklasser (2026-08-03) -- se tournament-detail.tsx sin // Konkurranseklasser (2026-08-03) -- se tournament-detail.tsx sin
// ClassesCard for lagturnering-varianten (samme mønster, men uten // ClassesCard for lagturnering-varianten (samme mønster, men uten
// leaderboard-koblingen -- her deler klasse OGSÅ opp resultatlisten). // leaderboard-koblingen -- her deler klasse OGSÅ opp resultatlisten).

View file

@ -1,6 +1,6 @@
"use client" "use client"
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { Clock, Flag, Pause, Play, Trophy } from "lucide-react" import { Clock, Flag, Pause, Play, Trophy } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -61,6 +61,8 @@ export const MOCK_ROWS: LeaderboardRow[] = [
{ position: "", playerName: "Kaia Strand", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: "WD", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 76)] }, { position: "", playerName: "Kaia Strand", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: "WD", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 76)] },
{ position: "-", playerName: "Julie Berget", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: null, totalIsUnderPar: false, isLeader: false, nextTeeTime: "2026-08-19T11:10:00", nextStartHole: 1, rounds: [] }, { position: "-", playerName: "Julie Berget", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: null, totalIsUnderPar: false, isLeader: false, nextTeeTime: "2026-08-19T11:10:00", nextStartHole: 1, rounds: [] },
{ position: "-", playerName: "Selma Vang", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: null, totalIsUnderPar: false, isLeader: false, nextTeeTime: null, nextStartHole: 10, rounds: [] }, { position: "-", playerName: "Selma Vang", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: null, totalIsUnderPar: false, isLeader: false, nextTeeTime: null, nextStartHole: 10, rounds: [] },
{ position: "16", playerName: "Thea Foss", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: "CUT", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 81)] },
{ position: "17", playerName: "Vilde Skog", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: "CUT", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 84)] },
] ]
// --- Tone helpers ---------------------------------------------------------- // --- Tone helpers ----------------------------------------------------------
@ -343,11 +345,20 @@ export function StrokePlayLeaderboard({
</tr> </tr>
)} )}
{/* A future "cut line" divider row can be inserted between any two {/* Cut-linje (2026-08-18): "CUT" er en egen, eksplisitt status
FieldRows here without restructuring. */} (skilt fra WD/DQ/DNS/NR, som fortsatt bare vises inline uten
{field.map((row, i) => ( divider) -- backend sorterer ALLTID kuttede spillere etter
<FieldRow key={`field-${row.playerName}`} row={row} roundNumbers={roundNumbers} striped={i % 2 === 1} /> alle fortsatt-med, "første CUT-rad" er en entydig,
))} stabil grense å sette streken foran. */}
{(() => {
const firstCutIndex = field.findIndex((row) => row.totalLabel === "CUT")
return field.map((row, i) => (
<Fragment key={`field-${row.playerName}`}>
{i === firstCutIndex && <CutLineRow totalCols={totalCols} />}
<FieldRow row={row} roundNumbers={roundNumbers} striped={i % 2 === 1} />
</Fragment>
))
})()}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -680,6 +691,29 @@ function RoundTd({
) )
} }
// --- Cut-linje ---------------------------------------------------------
// Egen seksjon-skillelinje mellom fortsatt-med-spillere og kuttede
// spillere (2026-08-18) -- bruker bekreftet eksplisitt at kuttede
// spillere skal "vises i egen 'Cut'-seksjon", ikke bare en dempet rad
// blant resten. Samme cellePadding/border-språk som resten av tabellen,
// ingen ny farge oppfunnet.
function CutLineRow({ totalCols }: { totalCols: number }) {
return (
<tr>
<td colSpan={totalCols} className="border-y-2 border-clubhouse-border bg-clubhouse-field px-4 py-2">
<div className="flex items-center gap-3">
<span aria-hidden="true" className="h-px flex-1 bg-clubhouse-border" />
<span className="text-xs font-black uppercase tracking-[0.2em] text-clubhouse-muted">
Cut -- spillerne under gikk ikke videre
</span>
<span aria-hidden="true" className="h-px flex-1 bg-clubhouse-border" />
</div>
</td>
</tr>
)
}
// --- Not-applicable dash cell (status rows) -------------------------------- // --- Not-applicable dash cell (status rows) --------------------------------
function DashTd({ bg, border }: { bg: string; border: string }) { function DashTd({ bg, border }: { bg: string; border: string }) {

227
tests/test_cut.py Normal file
View file

@ -0,0 +1,227 @@
"""
Cut (migrasjon 083, 2026-08-18): topp N og delt plass, etter et
organisator-valgt rundenummer, MANUELT anvendt (bruker bekreftet
eksplisitt -- ikke automatisk ved fullspilt runde). Kaller de ekte
router-funksjonene direkte (samme mønster som test_order_of_merit.py).
"""
import pytest
from fastapi import HTTPException
from app.auth import CurrentUser
from app.routers.individual_tournaments import (
HoleUpdate as TournamentHoleUpdate,
apply_cut,
individual_leaderboard,
update_hole as tournament_update_hole,
)
from app.routers.tournaments import TournamentUpdate, update_tournament
from tests.conftest import (
add_membership,
create_org,
create_org_hole,
create_course,
create_player,
create_tee,
create_tournament,
create_tournament_participant,
create_tournament_round,
create_tournament_round_participant,
create_user,
)
import app.db as app_db
async def _score_holes(tournament_id, round_id, rp_id, org_id, user_id, scores: list[int]) -> 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="Cut-turnering")
course_id = await create_course(org_id, name="Cut 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)
players = {}
for name in ["P1", "P2", "P3", "P4", "P5"]:
player_id = await create_player(org_id, display_name=name)
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
players[name] = tp_id
# Runde 1: P1 -4, P2/P3 uavgjort -2, P4 +3, P5 +8 (par 72 over 18 hull).
scores_r1 = {
"P1": [3, 3, 3, 3] + [4] * 14, # 68 -> -4
"P2": [3, 3] + [4] * 16, # 70 -> -2
"P3": [3, 3] + [4] * 16, # 70 -> -2 (uavgjort med P2)
"P4": [5, 5, 5] + [4] * 15, # 75 -> +3
"P5": [5] * 8 + [4] * 10, # 80 -> +8
}
rp1 = {}
for name, tp_id in players.items():
rp1[name] = await create_tournament_round_participant(org_id, round1_id, tp_id, tee_id)
await _score_holes(tournament_id, round1_id, rp1[name], org_id, owner_id, scores_r1[name])
# P6 opprettes som deltaker, men spiller ALDRI runde 1 -- skal kuttes
# uansett cut_size (ingen resultat gjennom cut-punktet).
p6_player_id = await create_player(org_id, display_name="P6")
players["P6"] = await create_tournament_participant(org_id, tournament_id, p6_player_id)
rp2 = {name: await create_tournament_round_participant(org_id, round2_id, tp_id, tee_id)
for name, tp_id in players.items() if name != "P6"}
return tournament_id, round1_id, round2_id, players, rp2
async def _set_cut_config(org_id, tournament_id, cut_after_round, cut_size):
await update_tournament(
tournament_id,
TournamentUpdate(cut_after_round=cut_after_round, cut_size=cut_size),
organization_id=org_id,
)
async def test_apply_cut_top_n_and_ties_lets_extra_player_through(pool):
"""cut_size=2, men P2/P3 er uavgjort på grenseplassen -- begge skal
slippe gjennom ("delt plass"), tre (ikke to) overlever i praksis."""
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, round1_id, round2_id, players, rp2 = await _setup(org_id, owner_id)
await _set_cut_config(org_id, tournament_id, cut_after_round=1, cut_size=2)
result = await apply_cut(tournament_id, organization_id=org_id)
assert result.cut_after_round == 1
assert result.cut_size == 2
assert result.survivors == 3 # P1, P2, P3 (uavgjort holdt begge inne)
assert result.cut_count == 3 # P4, P5, P6
async with app_db.org_connection(org_id) as conn:
cut_flags = {
name: await conn.fetchval("SELECT cut FROM tournament_participant WHERE id = $1", tp_id)
for name, tp_id in players.items()
}
assert cut_flags["P1"] is False
assert cut_flags["P2"] is False
assert cut_flags["P3"] is False
assert cut_flags["P4"] is True
assert cut_flags["P5"] is True
assert cut_flags["P6"] is True # spilte aldri runde 1 -- kuttes uansett cut_size
async def test_apply_cut_is_idempotent(pool):
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, round1_id, round2_id, players, rp2 = await _setup(org_id, owner_id)
await _set_cut_config(org_id, tournament_id, cut_after_round=1, cut_size=2)
first = await apply_cut(tournament_id, organization_id=org_id)
second = await apply_cut(tournament_id, organization_id=org_id)
assert first.cut_participant_ids == second.cut_participant_ids or (
set(first.cut_participant_ids) == set(second.cut_participant_ids)
)
assert second.survivors == first.survivors
assert second.cut_count == first.cut_count
async def test_apply_cut_requires_config_set_first(pool):
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, *_ = await _setup(org_id, owner_id)
with pytest.raises(HTTPException) as exc_info:
await apply_cut(tournament_id, organization_id=org_id)
assert exc_info.value.status_code == 400
async def test_cut_player_blocked_from_scoring_in_later_round(pool):
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, round1_id, round2_id, players, rp2 = await _setup(org_id, owner_id)
await _set_cut_config(org_id, tournament_id, cut_after_round=1, cut_size=2)
await apply_cut(tournament_id, organization_id=org_id)
user = CurrentUser(user_id=owner_id)
# P4 er kuttet -- runde 2 (etter cut_after_round=1) skal avvises.
with pytest.raises(HTTPException) as exc_info:
await tournament_update_hole(
tournament_id, round2_id, rp2["P4"], 1,
TournamentHoleUpdate(gross_strokes=4, expected_version=None),
organization_id=org_id, user=user,
)
assert exc_info.value.status_code == 403
# P1 overlevde cutten -- runde 2 skal fortsatt fungere som normalt.
result = await tournament_update_hole(
tournament_id, round2_id, rp2["P1"], 1,
TournamentHoleUpdate(gross_strokes=4, expected_version=None),
organization_id=org_id, user=user,
)
assert result.gross_strokes == 4
async def test_cut_player_can_still_edit_score_in_round_at_or_before_cut_point(pool):
"""Cutten låser IKKE historikken -- en rettelse i runde 1 (t.o.m.
cut_after_round) skal fortsatt være mulig for en kuttet spiller."""
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, round1_id, round2_id, players, rp2 = await _setup(org_id, owner_id)
await _set_cut_config(org_id, tournament_id, cut_after_round=1, cut_size=2)
await apply_cut(tournament_id, organization_id=org_id)
async with app_db.org_connection(org_id) as conn:
rp1_p4 = await conn.fetchval(
"SELECT id FROM tournament_round_participant WHERE tournament_round_id = $1 AND tournament_participant_id = $2",
round1_id, players["P4"],
)
user = CurrentUser(user_id=owner_id)
result = await tournament_update_hole(
tournament_id, round1_id, str(rp1_p4), 1,
TournamentHoleUpdate(gross_strokes=5, expected_version=1),
organization_id=org_id, user=user,
)
assert result.gross_strokes == 5
async def test_leaderboard_marks_cut_players_with_status_and_sorts_them_last(pool):
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, round1_id, round2_id, players, rp2 = await _setup(org_id, owner_id)
await _set_cut_config(org_id, tournament_id, cut_after_round=1, cut_size=2)
await apply_cut(tournament_id, organization_id=org_id)
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
by_id = {e.tournament_participant_id: e for e in entries}
assert by_id[players["P4"]].total_label == "CUT"
assert by_id[players["P4"]].cut is True
assert by_id[players["P4"]].is_leader is False
assert by_id[players["P1"]].total_label != "CUT"
assert by_id[players["P1"]].cut is False
# Alle ikke-kuttede rader kommer FØR alle kuttede rader, uansett
# frossent til-par-tall.
cut_flags_in_order = [by_id[e.tournament_participant_id].cut for e in entries]
first_cut_index = cut_flags_in_order.index(True)
assert all(not c for c in cut_flags_in_order[:first_cut_index])
assert all(c for c in cut_flags_in_order[first_cut_index:])