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:
parent
9f81258ef7
commit
19c527f834
8 changed files with 771 additions and 14 deletions
37
083_tournament_cut.sql
Normal file
37
083_tournament_cut.sql
Normal 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;
|
||||
|
|
@ -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)
|
||||
|
||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||
|
|
|
|||
33
CHANGELOG.md
33
CHANGELOG.md
|
|
@ -12632,3 +12632,36 @@ Neste steg:
|
|||
**Rullet ut 2026-08-18** -- bruker bekreftet. `docker compose build
|
||||
teecup_api teecup_frontend && up -d` for begge, rene logger, 200
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -940,7 +940,8 @@ async def update_hole(
|
|||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
round_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config, t.scoring_method
|
||||
SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config, tr.sequence,
|
||||
t.scoring_method, t.cut_after_round
|
||||
FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
|
||||
WHERE tr.id = $1
|
||||
""",
|
||||
|
|
@ -953,8 +954,9 @@ async def update_hole(
|
|||
|
||||
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
|
||||
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
||||
WHERE trp.id = $1 AND trp.tournament_round_id = $2
|
||||
""",
|
||||
round_participant_id,
|
||||
|
|
@ -963,6 +965,19 @@ async def update_hole(
|
|||
if rp is None:
|
||||
raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
|
||||
|
||||
# Cut (migrasjon 083, 2026-08-18): en kuttet deltaker kan fortsatt
|
||||
# se/rette score i runder T.O.M. cut_after_round (historikken deres
|
||||
# skal ikke låses), men er blokkert fra å registrere score i runder
|
||||
# ETTER cut-punktet -- det er selve poenget med en cut.
|
||||
if (
|
||||
rp["cut"]
|
||||
and round_row["cut_after_round"] is not None
|
||||
and round_row["sequence"] > round_row["cut_after_round"]
|
||||
):
|
||||
raise app_error(
|
||||
403, "CUT", "Denne spilleren er kuttet og kan ikke registrere score i denne runden."
|
||||
)
|
||||
|
||||
if not await user_is_own_tournament_participant(
|
||||
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).
|
||||
next_tee_time: str | None = None
|
||||
next_start_hole: int | None = None
|
||||
# Cut (migrasjon 083, 2026-08-18) -- KUN populert (og kun meningsfullt)
|
||||
# for scoring_method i (stroke_gross, stroke_net, stableford), samme
|
||||
# begrensning som resten av Augusta-resultattavlen. `rank` er den RÅ
|
||||
# konkurranse-rangeringen (uten "T"-prefiks/uavgjort-tekst -- se
|
||||
# `position` for visningsklar streng) -- brukt internt av apply_cut for
|
||||
# å finne cut-grensen numerisk, men trygt å eksponere. `cut=True` når
|
||||
# organisator har trykket "Anvend cut" og denne deltakeren falt utenfor
|
||||
# cut_size+uavgjort-grensen (eller ikke hadde spilt noe gjennom
|
||||
# cut_after_round i det hele tatt).
|
||||
rank: int | None = None
|
||||
cut: bool = False
|
||||
# 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_
|
||||
|
|
@ -1245,7 +1271,9 @@ class LeaderboardEntry(BaseModel):
|
|||
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
|
||||
egen visning) og Order of Merit sitt leaderboard-endepunkt
|
||||
(`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
|
||||
rangeringsnøkkelen, se kommentar i `_attach_stroke_play_columns`).
|
||||
|
||||
`max_sequence` (2026-08-18, Cut): når satt, regnes stillingen KUN ut
|
||||
fra runder t.o.m. den sekvensen -- brukt av `apply_cut` til å beregne
|
||||
"stillingen som den var etter cut_after_round", uavhengig av hvor
|
||||
langt turneringen faktisk har kommet i mellomtiden. `None` (standard)
|
||||
= alle runder, uendret oppførsel for den vanlige leaderboard-visningen.
|
||||
|
||||
Returnerer `(tournament_row, entries)` -- kalleren i OOM-sammenheng
|
||||
trenger `tournament_row["scoring_method"]` for å vite hvilket felt på
|
||||
hver entry (`gross_total`/`net_total`/`stableford_total`/`position`)
|
||||
|
|
@ -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,
|
||||
p.display_name AS player_name,
|
||||
tp.cut,
|
||||
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,
|
||||
|
|
@ -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
|
||||
FROM tournament_participant tp
|
||||
JOIN player p ON p.id = tp.player_id
|
||||
LEFT JOIN tournament_round_score trs ON trs.tournament_participant_id = tp.id
|
||||
LEFT JOIN tournament_round_score trs
|
||||
ON trs.tournament_participant_id = tp.id
|
||||
AND ($2::smallint IS NULL OR trs.tournament_round_id IN (
|
||||
SELECT id FROM tournament_round WHERE tournament_id = $1 AND sequence <= $2
|
||||
))
|
||||
LEFT JOIN tournament_class tc ON tc.id = tp.class_id
|
||||
WHERE tp.tournament_id = $1
|
||||
GROUP BY tp.id, p.display_name, tc.id, tc.name
|
||||
""",
|
||||
tournament_id,
|
||||
max_sequence,
|
||||
)
|
||||
entries = [LeaderboardEntry(**dict(r)) for r in rows]
|
||||
|
||||
|
|
@ -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
|
||||
# deltakere med -5 til par over 27-36 hull, kun fordi rått
|
||||
# slagtall er mindre jo færre hull man har spilt.
|
||||
await _attach_stroke_play_columns(conn, tournament_id, method, entries)
|
||||
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":
|
||||
# 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)))
|
||||
|
|
@ -1334,6 +1393,80 @@ async def individual_leaderboard(
|
|||
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 på nytt fra bunnen av HELE feltet hver
|
||||
gang (aldri inkrementelt), så et nytt trykk etter en score-rettelse i
|
||||
en tidligere runde gir riktig oppdatert resultat, ikke et lag oppå
|
||||
forrige kjøring. Kan derfor trykkes flere ganger."""
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
tournament = await conn.fetchrow(
|
||||
"SELECT format_type, scoring_method, cut_after_round, cut_size FROM tournament WHERE id = $1",
|
||||
tournament_id,
|
||||
)
|
||||
if tournament is None:
|
||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||
if tournament["format_type"] != "individual" or tournament["scoring_method"] not in (
|
||||
"stroke_gross", "stroke_net", "stableford"
|
||||
):
|
||||
raise app_error(
|
||||
400, "VALIDATION_FAILED", "Cut er kun støttet for brutto-, netto- eller stableford-turneringer."
|
||||
)
|
||||
cut_after_round = tournament["cut_after_round"]
|
||||
cut_size = tournament["cut_size"]
|
||||
if cut_after_round is None or cut_size is None:
|
||||
raise app_error(
|
||||
400, "VALIDATION_FAILED", "Sett rundenummer og antall som går videre til cut før du anvender den."
|
||||
)
|
||||
|
||||
_, entries = await _compute_individual_standings(conn, tournament_id, max_sequence=cut_after_round)
|
||||
ranked = [e for e in entries if e.rank is not None]
|
||||
ranked.sort(key=lambda e: e.rank)
|
||||
if ranked and cut_size < len(ranked):
|
||||
cutoff_rank = ranked[cut_size - 1].rank
|
||||
else:
|
||||
cutoff_rank = ranked[-1].rank if ranked else 0
|
||||
cut_ids = [e.tournament_participant_id for e in entries if e.rank is None or e.rank > cutoff_rank]
|
||||
|
||||
await conn.execute(
|
||||
"UPDATE tournament_participant SET cut = (id = ANY($2::uuid[])) WHERE tournament_id = $1",
|
||||
tournament_id,
|
||||
cut_ids,
|
||||
)
|
||||
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}
|
||||
|
||||
|
||||
|
|
@ -1352,7 +1485,7 @@ def _to_par_tone(v: int) -> str:
|
|||
|
||||
|
||||
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:
|
||||
"""Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx):
|
||||
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,
|
||||
legger kun til det som mangler: par-for-spilte-hull (for til-par-tall),
|
||||
per-runde nedbrytning, og posisjon-med-uavgjort-håndtering.
|
||||
|
||||
`max_sequence` (2026-08-18, Cut) -- se `_compute_individual_standings`.
|
||||
"""
|
||||
rounds_meta = await conn.fetch(
|
||||
"""
|
||||
|
|
@ -1399,9 +1534,11 @@ async def _attach_stroke_play_columns(
|
|||
AND trh.gross_strokes IS NOT NULL
|
||||
) pp ON true
|
||||
WHERE tr.tournament_id = $1 AND trs.holes_played > 0
|
||||
AND ($2::smallint IS NULL OR tr.sequence <= $2)
|
||||
ORDER BY tr.sequence
|
||||
""",
|
||||
tournament_id,
|
||||
max_sequence,
|
||||
)
|
||||
|
||||
by_participant: dict[str, dict[int, object]] = {}
|
||||
|
|
@ -1527,6 +1664,7 @@ async def _attach_stroke_play_columns(
|
|||
for k in range(i, j):
|
||||
entries[k].position = None if not_started_group else (f"T{rank}" if tied else str(rank))
|
||||
entries[k].is_leader = rank == 1 and not not_started_group
|
||||
entries[k].rank = None if not_started_group else rank
|
||||
i = j
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,8 @@ _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
|
||||
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).
|
||||
|
|
@ -92,6 +93,14 @@ class Tournament(BaseModel):
|
|||
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
|
||||
|
||||
|
||||
def _tournament_from_row(row) -> Tournament:
|
||||
|
|
@ -124,7 +133,7 @@ async def list_tournaments(
|
|||
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.flag_map_visible, t.cut_after_round, t.cut_size, t.cut_applied_at
|
||||
FROM tournament t
|
||||
LEFT JOIN (
|
||||
SELECT tournament_id,
|
||||
|
|
@ -201,6 +210,12 @@ class TournamentUpdate(BaseModel):
|
|||
# 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)
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -109,6 +109,12 @@ type ApiTournamentInfo = {
|
|||
bbb_sweep_bonus_enabled: boolean
|
||||
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||||
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 = {
|
||||
|
|
@ -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) {
|
||||
setError(null)
|
||||
const res = await fetch(`${base}/participants`, {
|
||||
|
|
@ -817,6 +873,8 @@ export function IndividualTournamentDetail({
|
|||
onUpdateScoringMethod={updateScoringMethod}
|
||||
onUpdateBbbSweepBonus={updateBbbSweepBonus}
|
||||
onUpdateFlagMapVisible={updateFlagMapVisible}
|
||||
onUpdateCutConfig={updateCutConfig}
|
||||
onApplyCut={applyCut}
|
||||
onAddParticipant={addParticipant}
|
||||
onAddNewPlayer={addNewPlayerAndParticipant}
|
||||
onImportComplete={refreshParticipants}
|
||||
|
|
@ -890,6 +948,8 @@ function SetupTab({
|
|||
onUpdateScoringMethod,
|
||||
onUpdateBbbSweepBonus,
|
||||
onUpdateFlagMapVisible,
|
||||
onUpdateCutConfig,
|
||||
onApplyCut,
|
||||
onAddParticipant,
|
||||
onAddNewPlayer,
|
||||
onImportComplete,
|
||||
|
|
@ -914,6 +974,8 @@ function SetupTab({
|
|||
onUpdateScoringMethod: (method: string) => void
|
||||
onUpdateBbbSweepBonus: (enabled: boolean) => void
|
||||
onUpdateFlagMapVisible: (visible: boolean) => void
|
||||
onUpdateCutConfig: (afterRound: number | null, size: number | null) => Promise<void>
|
||||
onApplyCut: () => Promise<void>
|
||||
onAddParticipant: (playerId: string) => Promise<void>
|
||||
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
|
||||
onImportComplete: () => Promise<void>
|
||||
|
|
@ -1022,6 +1084,11 @@ function SetupTab({
|
|||
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
|
||||
classes={classes}
|
||||
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 på 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
|
||||
// ClassesCard for lagturnering-varianten (samme mønster, men uten
|
||||
// leaderboard-koblingen -- her deler klasse OGSÅ opp resultatlisten).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"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 { 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: "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: "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 ----------------------------------------------------------
|
||||
|
|
@ -343,11 +345,20 @@ export function StrokePlayLeaderboard({
|
|||
</tr>
|
||||
)}
|
||||
|
||||
{/* A future "cut line" divider row can be inserted between any two
|
||||
FieldRows here without restructuring. */}
|
||||
{field.map((row, i) => (
|
||||
<FieldRow key={`field-${row.playerName}`} row={row} roundNumbers={roundNumbers} striped={i % 2 === 1} />
|
||||
))}
|
||||
{/* Cut-linje (2026-08-18): "CUT" er en egen, eksplisitt status
|
||||
(skilt fra WD/DQ/DNS/NR, som fortsatt bare vises inline uten
|
||||
divider) -- backend sorterer ALLTID kuttede spillere etter
|
||||
alle fortsatt-med, så "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>
|
||||
</table>
|
||||
</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) --------------------------------
|
||||
|
||||
function DashTd({ bg, border }: { bg: string; border: string }) {
|
||||
|
|
|
|||
227
tests/test_cut.py
Normal file
227
tests/test_cut.py
Normal 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"), så 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:])
|
||||
Loading…
Reference in a new issue