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)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
||||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
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
|
**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).
|
||||||
|
|
|
||||||
|
|
@ -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 på
|
trenger `tournament_row["scoring_method"]` for å vite hvilket felt på
|
||||||
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 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}
|
_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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 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
|
// 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).
|
||||||
|
|
|
||||||
|
|
@ -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, 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>
|
</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
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