From 544c952cab7e1a4476e2be7c8499bd2093ac77b9 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Fri, 21 Aug 2026 08:04:53 +0200 Subject: [PATCH] Kaskader cut-resultatet til alle senere runder (ADR-095) Overlevere auto-legges til runder etter cut (tee kopiert fra cut-runden), kuttede spilleres rundedeltakelse fjernes -- med mindre de allerede har scorer der, da beskyttes dataene av eksisterende cut-sperre i stedet. Bruker bekreftet eksplisitt: kaskade til ALLE runder, ikke bare neste. Co-Authored-By: Claude Sonnet 5 --- ARCHITECTURE_DECISIONS.md | 73 ++++++++ app/routers/individual_tournaments.py | 106 +++++++++++ .../individual-tournament-detail.tsx | 19 +- tests/test_cut.py | 168 +++++++++++++++++- 4 files changed, 361 insertions(+), 5 deletions(-) diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 4fc0e4e..6e3b957 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -9652,6 +9652,79 @@ skjerm faller korrekt tilbake til sidescroll. --- +## ADR-095: Cut-overlevere kaskaderes automatisk til ALLE senere runder (2026-08-20) + +Bruker, i en oppfølging til cut-mekanismen (ADR-083): "I eksempel- +turneringen vår opererer vi med cut etter runde 1. På et vis burde de +som har klart cut'en autoselekteres på runde 2? Gir ikke det mening?" +Undersøkt (read-only) før bygging: `tournament_round_participant`-rader +opprettes KUN på forespørsel (aldri opprettet på forhånd for alle +runder), så "overlevere spiller videre" krever en eksplisitt +INSERT-mekanisme et sted -- fantes ikke fra før. + +To eksplisitte beslutninger bekreftet av bruker, etter at jeg først +foreslo en snevrere variant (kun neste runde, aldri fjern noen): +1. **Kaskade til ALLE runder etter cut, ikke bare den neste.** "Kaskade + er det eneste riktige. Har du klart cut'en spiller du alle + påfølgende runder." +2. **Fjern kuttede spilleres rundedeltakelse i senere runder, ikke bare + la dem stå (blokkert av eksisterende 403-sperre).** "Jo, fjern de. + Dette får vi heller reversere om det viser seg å være en dårlig + beslutning." -- bruker informert eksplisitt om at sletting av + `tournament_round_participant` KASKADERER til `tournament_round_hole` + (migrasjon 040, `ON DELETE CASCADE`) FØR beslutningen ble tatt. + +**Implementasjon** (`_sync_post_cut_round_participation`, kalt fra +`apply_cut` i `app/routers/individual_tournaments.py`, rett etter +`tournament_participant.cut`-oppdateringen, samme transaksjon): +- For hver runde med `sequence > cut_after_round`: overlevere som + MANGLER en rad der, får én INSERT-et automatisk, med samme `tee_id` + som de spilte cut-runden med (eneste faktiske data å basere det på -- + ingen "kopier fra forrige runde"-mekanisme fantes). Handicap regnes ut + med samme `_compute_round_participant_handicap`-funksjon som + `add_round_participant` allerede bruker. +- Kuttede spillere med en EKSISTERENDE rad i en slik runde: raden + SLETTES -- MEN kun dersom den ikke allerede har registrert score + (`EXISTS (SELECT 1 FROM tournament_round_hole WHERE + tournament_round_participant_id = ...)`). Har raden scorer, la den stå + urørt -- den eksisterende cut-sperren (403, basert på + `tournament_participant.cut`) blokkerer likevel videre + scoreregistrering derfra. +- IDEMPOTENT, som resten av `apply_cut` -- trygt å trykke "Anvend cut" + på nytt etter en score-rettelse. +- Manuelt organisator-arbeid overskrives IKKE med mindre det kolliderer + direkte: en spiller organisator allerede har lagt til manuelt i en + senere runde (uansett om de overlever cutten) berøres ikke av + INSERT-logikken (kjører kun når raden MANGLER). + +**Frontend:** `CutResult` fikk et nytt felt, `added_to_later_rounds` +(antall nye rader), vist i "Anvend cut"-bekreftelsen +(`individual-tournament-detail.tsx`). `SetupTab` nullstiller nå +`roundParticipantsByRound` til `null` etter "Anvend cut" (i stedet for +kun å refreshe én runde) siden FLERE runder kan ha fått rader +lagt til/fjernet samtidig -- utløser eksisterende lazy-load-effekt for +alle runder på nytt. + +**Testet** i `tests/test_cut.py` (scratch-database, `./scripts/ +run_backend_tests.sh`, alle 156 tester grønne): auto-tillegg når raden +mangler (riktig tee kopiert), fjerning av kuttede spilleres rad, +IKKE-fjerning når scorer allerede finnes (+ bekreftet at 403-sperren +fortsatt fungerer i det tilfellet), kaskade til runde 3 uten at runde 2 +finnes i mellom. Én eksisterende test +(`test_cut_player_blocked_from_scoring_in_later_round`) fikk sin +forventning oppdatert fra 403 til 404 -- riktig og forventet konsekvens +av at raden nå faktisk slettes, ikke lenger bare blokkeres. + +Frontend-delen (sortable-kolonner i samme tabell, se +`v0-prompt-players-table-sort.md`) er en SEPARAT, urelatert endring +levert samme dag -- V0-generert sorterings-mønster (stigende/synkende/ +opprinnelig, `aria-sort`, kun de syv ikke-runde-kolonnene) integrert i +`tournament-players-table.tsx`, verifisert i scratch mot den ekte +komponenten (ikke V0-mocken) med presise `evaluate_script`-sjekker av +faktisk radrekkefølge og `aria-sort`-tilstand for alle tre sykluser. + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py index f99afc4..9634325 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -1840,6 +1840,108 @@ class CutResult(BaseModel): survivors: int cut_count: int cut_participant_ids: list[str] + added_to_later_rounds: int + + +async def _sync_post_cut_round_participation( + conn, organization_id: str, tournament_id: str, cut_after_round: int, cut_ids: list[str] +) -> int: + """Kaskader cut-resultatet til ALLE runder etter cut_after_round + (2026-08-20, bruker bekreftet eksplisitt: "Har du klart cut'en + spiller du alle påfølgende runder" -- ikke bare den aller neste). + + Overlevere: legges automatisk til enhver senere runde de ikke + allerede er meldt på i, med samme utslagssted som de spilte + cut-runden med (eneste faktiske data å basere det på -- ingen + "kopier fra forrige runde"-mekanisme fantes fra før). + + Kuttede: fjernes fra enhver senere runde de er meldt på i -- MEN + KUN dersom de ikke allerede har registrert score der (sletting av + tournament_round_participant kaskader til tournament_round_hole, + se migrasjon 040 -- ville slettet ekte scorer). Dersom scorer + allerede finnes, la raden stå urørt; den eksisterende cut-sperren + blokkerer likevel videre scoreregistrering for dem. + + IDEMPOTENT, som resten av apply-cut -- trygt å kjøre på nytt etter + en score-rettelse. Returnerer antall rader lagt til (for + tilbakemelding til organisator).""" + later_rounds = await conn.fetch( + """ + SELECT id::text AS id + FROM tournament_round + WHERE tournament_id = $1 AND sequence > $2 + ORDER BY sequence + """, + tournament_id, + cut_after_round, + ) + if not later_rounds: + return 0 + + cut_round = await conn.fetchrow( + "SELECT id::text AS id FROM tournament_round WHERE tournament_id = $1 AND sequence = $2", + tournament_id, + cut_after_round, + ) + survivor_tees: dict[str, str] = {} + if cut_round is not None: + rows = await conn.fetch( + "SELECT tournament_participant_id::text AS pid, tee_id::text AS tee_id " + "FROM tournament_round_participant WHERE tournament_round_id = $1", + cut_round["id"], + ) + survivor_tees = {r["pid"]: r["tee_id"] for r in rows} + + all_participants = await conn.fetch( + "SELECT id::text AS id FROM tournament_participant WHERE tournament_id = $1", + tournament_id, + ) + survivor_ids = [r["id"] for r in all_participants if r["id"] not in cut_ids] + + added = 0 + for round_row in later_rounds: + round_id = round_row["id"] + + existing = await conn.fetch( + "SELECT id::text AS id, tournament_participant_id::text AS pid " + "FROM tournament_round_participant WHERE tournament_round_id = $1", + round_id, + ) + existing_by_pid = {r["pid"]: r["id"] for r in existing} + + for pid in survivor_ids: + if pid in existing_by_pid: + continue + tee_id = survivor_tees.get(pid) + if tee_id is None: + continue # ingen kjent tee å bruke -- hopp over, ikke gjett + new_id = await conn.fetchval( + """ + INSERT INTO tournament_round_participant + (organization_id, tournament_round_id, tournament_participant_id, tee_id) + VALUES ($1, $2, $3, $4) + RETURNING id + """, + organization_id, + round_id, + pid, + tee_id, + ) + await _compute_round_participant_handicap(conn, str(new_id)) + added += 1 + + for pid, rp_id in existing_by_pid.items(): + if pid not in cut_ids: + continue + has_scores = await conn.fetchval( + "SELECT EXISTS(SELECT 1 FROM tournament_round_hole WHERE tournament_round_participant_id = $1)", + rp_id, + ) + if has_scores: + continue + await conn.execute("DELETE FROM tournament_round_participant WHERE id = $1", rp_id) + + return added @router.post( @@ -1897,6 +1999,9 @@ async def apply_cut( tournament_id, cut_ids, ) + added_to_later_rounds = await _sync_post_cut_round_participation( + conn, organization_id, tournament_id, cut_after_round, cut_ids + ) await conn.execute("UPDATE tournament SET cut_applied_at = now() WHERE id = $1", tournament_id) return CutResult( @@ -1905,6 +2010,7 @@ async def apply_cut( survivors=len(entries) - len(cut_ids), cut_count=len(cut_ids), cut_participant_ids=cut_ids, + added_to_later_rounds=added_to_later_rounds, ) diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index 98f70f3..84302b6 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -695,11 +695,15 @@ export function IndividualTournamentDetail({ credentials: "include", }) if (!res.ok) throw new Error() - const result: { survivors: number; cut_count: number } = await res.json() + const result: { survivors: number; cut_count: number; added_to_later_rounds: 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.`) + const extra = + result.added_to_later_rounds > 0 + ? ` ${result.added_to_later_rounds} rundedeltakelser lagt til automatisk i senere runder (kuttede spillere er samtidig fjernet der de ikke allerede har scorer).` + : "" + alert(`Cut anvendt: ${result.survivors} går videre, ${result.cut_count} er kuttet.${extra}`) } catch { setError("Klarte ikke å anvende cutten. Prøv igjen.") } @@ -1321,6 +1325,15 @@ function SetupTab({ setRoundParticipantsByRound((prev) => ({ ...(prev ?? {}), [roundId]: data ?? [] })) } + // "Anvend cut" (ADR-095) kaskader nå til rundedeltakelse i alle senere + // runder på backend -- tvinger derfor et fullt reload av + // roundParticipantsByRound (i stedet for kun ett enkelt refreshRoundParticipants- + // kall) siden FLERE runder kan ha fått rader lagt til/fjernet samtidig. + async function applyCutAndRefreshRounds() { + await onApplyCut() + setRoundParticipantsByRound(null) + } + async function toggleRoundParticipant( roundId: string, tournamentParticipantId: string, @@ -1728,7 +1741,7 @@ function SetupTab({ {tournament.scoring_method != null && ["stroke_gross", "stroke_net", "stableford"].includes(tournament.scoring_method) && ( <> - + )} diff --git a/tests/test_cut.py b/tests/test_cut.py index 4bb126b..89a4548 100644 --- a/tests/test_cut.py +++ b/tests/test_cut.py @@ -152,6 +152,13 @@ async def test_apply_cut_requires_config_set_first(pool): async def test_cut_player_blocked_from_scoring_in_later_round(pool): + """Siden 2026-08-20 fjerner apply_cut kuttede spilleres runde-2-rad + HELT (kaskade-vedtaket, se test_apply_cut_removes_cut_players_...) -- + forsøk på å score for dem gir derfor 404 (raden finnes ikke), ikke + lenger 403. Den gamle cut-sperren (403) lever videre som forsvar i + dybden for det sjeldne tilfellet raden IKKE ble fjernet fordi den + allerede hadde scorer -- se test_apply_cut_does_not_delete_round_ + participation_with_existing_scores for det tilfellet.""" org_id = await create_org() owner_id = await create_user() await add_membership(org_id, owner_id, role="owner") @@ -160,14 +167,15 @@ async def test_cut_player_blocked_from_scoring_in_later_round(pool): 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. + # P4 er kuttet -- runde-2-raden er fjernet av apply_cut, så forsøket + # treffer "finnes ikke", ikke "blokkert". 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 + assert exc_info.value.status_code == 404 # P1 overlevde cutten -- runde 2 skal fortsatt fungere som normalt. result = await tournament_update_hole( @@ -202,6 +210,162 @@ async def test_cut_player_can_still_edit_score_in_round_at_or_before_cut_point(p assert result.gross_strokes == 5 +async def test_apply_cut_auto_adds_survivor_to_round_after_cut_if_missing(pool): + """P1 overlevde, men har (i motsetning til _setup sitt vanlige rp2- + oppsett) INGEN runde 2-rad ennå -- apply_cut skal opprette den, med + samme tee som P1 spilte runde 1 med (2026-08-20, bruker bekreftet + kaskade-oppførsel).""" + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + 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 = 'stroke_gross' WHERE id = $1", + tournament_id, + ) + 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) + + p1 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P1")) + p2 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P2")) + rp1_p1 = await create_tournament_round_participant(org_id, round1_id, p1, tee_id) + rp1_p2 = await create_tournament_round_participant(org_id, round1_id, p2, tee_id) + await _score_holes(tournament_id, round1_id, rp1_p1, org_id, owner_id, [3, 3, 3, 3] + [4] * 14) # -4 + await _score_holes(tournament_id, round1_id, rp1_p2, org_id, owner_id, [5] * 8 + [4] * 10) # +8 + + # Ingen runde 2-rad for NOEN opprettet på forhånd -- P1 skal likevel + # dukke opp der etter cut, siden P1 overlever. + await _set_cut_config(org_id, tournament_id, cut_after_round=1, cut_size=1) + + result = await apply_cut(tournament_id, organization_id=org_id) + assert result.added_to_later_rounds == 1 + + async with app_db.org_connection(org_id) as conn: + row = await conn.fetchrow( + "SELECT tee_id::text AS tee_id, course_handicap FROM tournament_round_participant " + "WHERE tournament_round_id = $1 AND tournament_participant_id = $2", + round2_id, p1, + ) + p2_row = await conn.fetchval( + "SELECT id FROM tournament_round_participant WHERE tournament_round_id = $1 AND tournament_participant_id = $2", + round2_id, p2, + ) + assert row is not None + assert row["tee_id"] == tee_id + assert p2_row is None # P2 ble kuttet -- ingen runde 2-rad opprettet for dem + + +async def test_apply_cut_removes_cut_players_round_participation_in_later_rounds(pool): + """_setup() oppretter runde 2-rader for ALLE (unntatt P6) på forhånd + -- apply_cut skal fjerne raden for de som blir kuttet (P4, P5, P6), + men la P1/P2/P3 sine stå.""" + 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: + remaining = { + name: await conn.fetchval( + "SELECT id FROM tournament_round_participant WHERE tournament_round_id = $1 AND tournament_participant_id = $2", + round2_id, tp_id, + ) + for name, tp_id in players.items() + } + assert remaining["P1"] is not None + assert remaining["P2"] is not None + assert remaining["P3"] is not None + assert remaining["P4"] is None # kuttet, ingen scorer i runde 2 -- fjernet + assert remaining["P5"] is None # kuttet, ingen scorer i runde 2 -- fjernet + + +async def test_apply_cut_does_not_delete_round_participation_with_existing_scores(pool): + """Dersom en kuttet spiller likevel har scorer registrert i en senere + runde (kant-tilfelle -- burde normalt blokkeres av score-sperren, men + testes eksplisitt siden sletting av tournament_round_participant + kaskader til tournament_round_hole), skal raden IKKE fjernes.""" + 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) + + # Score inn ett hull for P4 i runde 2 FØR cutten anvendes (simulerer at + # data allerede finnes -- score-sperren gjelder kun EFTER cut er anvendt). + await _score_holes(tournament_id, round2_id, rp2["P4"], org_id, owner_id, [4]) + + 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: + still_there = await conn.fetchval( + "SELECT id FROM tournament_round_participant WHERE id = $1", rp2["P4"], + ) + score_still_there = await conn.fetchval( + "SELECT COUNT(*) FROM tournament_round_hole WHERE tournament_round_participant_id = $1", rp2["P4"], + ) + assert still_there is not None # IKKE slettet -- ville kaskadert bort scoren + assert score_still_there == 1 + + # Raden lever videre, men den eksisterende cut-sperren (403, basert på + # tournament_participant.cut) blokkerer likevel forsøk på Å FORTSETTE + # å registrere flere hull for denne kuttede spilleren i runden. + user = CurrentUser(user_id=owner_id) + with pytest.raises(HTTPException) as exc_info: + await tournament_update_hole( + tournament_id, round2_id, rp2["P4"], 2, + TournamentHoleUpdate(gross_strokes=4, expected_version=None), + organization_id=org_id, user=user, + ) + assert exc_info.value.status_code == 403 + + +async def test_apply_cut_cascades_to_all_rounds_after_cut_not_just_the_next_one(pool): + """Bruker bekreftet eksplisitt: har du klart cutten spiller du ALLE + påfølgende runder, ikke bare den aller neste. Turnering med 3 runder, + cut etter runde 1 -- overlever skal auto-legges til BÅDE runde 2 og 3.""" + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + tournament_id = await create_tournament(org_id, name="Cut-turnering, 3 runder") + 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 = 'stroke_gross' WHERE id = $1", + tournament_id, + ) + round1_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1) + round3_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=3) + + p1 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P1")) + p2 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P2")) + rp1_p1 = await create_tournament_round_participant(org_id, round1_id, p1, tee_id) + rp1_p2 = await create_tournament_round_participant(org_id, round1_id, p2, tee_id) + await _score_holes(tournament_id, round1_id, rp1_p1, org_id, owner_id, [3, 3, 3, 3] + [4] * 14) # -4 + await _score_holes(tournament_id, round1_id, rp1_p2, org_id, owner_id, [5] * 8 + [4] * 10) # +8 + + await _set_cut_config(org_id, tournament_id, cut_after_round=1, cut_size=1) + result = await apply_cut(tournament_id, organization_id=org_id) + assert result.added_to_later_rounds == 1 # kun round3 finnes (ingen round2 opprettet i dette oppsettet) + + async with app_db.org_connection(org_id) as conn: + row = await conn.fetchval( + "SELECT id FROM tournament_round_participant WHERE tournament_round_id = $1 AND tournament_participant_id = $2", + round3_id, p1, + ) + assert row is not None # P1 (overlever) auto-lagt til runde 3, selv om runde 2 ikke fantes i mellom + + async def test_leaderboard_marks_cut_players_with_status_and_sorts_them_last(pool): org_id = await create_org() owner_id = await create_user()