Slett en hel turnering, permanent (ADR-086)
Nytt DELETE /orgs/{id}/tournaments/{id}, kun org-eier/admin. Eksplisitt
riktig-rekkefølge-sletting (ikke naiv kaskade) siden to RESTRICT-FK-er
sitter midt inni kaskade-grafen for begge turneringsformater -- verifisert
med fire nye backend-tester mot ekte populerte data. Søppelbøtte-ikon i
begge turnering-headerne med sterk confirm()-tekst.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
82343e522b
commit
207750acba
6 changed files with 454 additions and 1 deletions
|
|
@ -8820,6 +8820,88 @@ revet ned.
|
|||
|
||||
---
|
||||
|
||||
## ADR-086: Slett en hel turnering, permanent (2026-08-18)
|
||||
|
||||
Bruker: "Jeg mangler mulighet til å slette HELE turneringen." Undersøkt
|
||||
FØR bygging: ingen `DELETE`-endepunkt for selve `tournament`-raden fantes
|
||||
noe sted -- alle eksisterende `DELETE`-endepunkter i `tournaments.py`/
|
||||
`individual_tournaments.py` treffer kun UNDER-ressurser (én økt, én runde,
|
||||
ett medlem osv.), og de fleste av DEM nekter i tillegg sletting så snart
|
||||
det finnes ekte barn (409 "ikke tom"). Genuint manglende funksjonalitet,
|
||||
ikke bare uklikkbar.
|
||||
|
||||
**Autorisasjon: strengere enn resten av turneringsoppsettet, bevisst.**
|
||||
Alt annet i `tournaments.py` krever kun org-medlemskap (`get_authorized_
|
||||
org`) -- dokumentert som en bevisst grense i filens egen header-kommentar
|
||||
("dette er organisator-arbeid"). En hel-turnering-sletting er
|
||||
kategorisk mer ødeleggende (fjerner ALL ekte score-/matchdata permanent,
|
||||
ikke tomt oppsett) -- ny, strengere sperre: `is_org_admin` (eier/admin),
|
||||
samme funksjon `messaging.py` allerede bruker for moderasjon. Håndheves
|
||||
server-side (403 for vanlige medlemmer); frontend skjuler i tillegg selve
|
||||
knappen for dem via `/auth/me` sin rolle -- ren UX-hjelp, ikke den reelle
|
||||
sperren.
|
||||
|
||||
**Eksplisitt, riktig-rekkefølge-sletting, IKKE naiv enkelt-setnings-
|
||||
kaskade.** Skjemaet har to RESTRICT-FK-er MIDT INNI kaskade-grafen, ikke
|
||||
bare i kantene: `match_participant.team_roster_id` (mens `team_roster`
|
||||
kaskaderer fra `team`, og `match_participant` OGSÅ kaskaderer fra
|
||||
`match`→`session` -- en "diamant"), og tilsvarende `tournament_round_
|
||||
participant.tournament_participant_id` for individuell-formatet. Siden
|
||||
RESTRICT (ulikt NO ACTION) sjekkes UMIDDELBART, ikke utsettbart, kunne en
|
||||
enkel `DELETE FROM tournament` feilt avhengig av Postgres sin interne
|
||||
kaskade-rekkefølge -- KUN når det faktisk finnes ekte kamper/runder (en
|
||||
tom turnering ville aldri avslørt dette). Løst med eksplisitt sletting i
|
||||
riktig rekkefølge (fjerner RESTRICT-blokkerende rader FØR det de peker på
|
||||
forsvinner) i stedet for å stole blindt på kaskaden -- `org_connection`
|
||||
sin egen transaksjon gjør alt atomisk uansett.
|
||||
|
||||
**Spillerne selv (`player`, org-scopet pool) røres ALDRI** -- kun
|
||||
turneringens egne koblinger til dem (roster/deltakelse) forsvinner.
|
||||
|
||||
**Ingen soft-delete/arkivering.** Sjekket: ingen `deleted_at`/`is_
|
||||
deleted`-mønster finnes noe sted i skjemaet. `tournament.status` sin
|
||||
`'archived'`-verdi er et rent visningsmerke uten noen reell backend-
|
||||
oppførsel (ingen skjuling/beskyttelse) -- IKKE en soft-delete-erstatning.
|
||||
Ekte, permanent `DELETE` er den etablerte normen i denne kodebasen
|
||||
(speiler `DELETE /rounds/{id}` i `rounds.py`, den nærmeste presedensen
|
||||
for "slett en hel ting med barn").
|
||||
|
||||
**UI:** søppelbøtte-ikon i hoved-header, ved siden av
|
||||
`TournamentStatusPicker`, i BÅDE `tournament-detail.tsx` (lag-format) og
|
||||
`individual-tournament-detail.tsx` (individuell) -- identisk mønster i
|
||||
begge, kun synlig for eier/admin. Bekreftelse via nettleserens `confirm()`
|
||||
med eksplisitt, sterk ordlyd ("Kan ikke angres") -- samme mønster som
|
||||
`round-detail.tsx` sin runde-sletting og `org-members.tsx` sin
|
||||
medlem-fjerning, ingen ny bekreftelses-konvensjon oppfunnet.
|
||||
|
||||
**Bevisst IKKE bygget:** ingen statusbasert sperre (f.eks. "kan ikke
|
||||
slette en `active`/`completed` turnering") -- status kan i dag settes
|
||||
fritt av hvem som helst med ett klikk, usynkronisert med faktisk
|
||||
datainnhold, så en statusbasert sperre ville vært en falsk trygghet.
|
||||
Ekte barnesjekk (finnes det score) ble vurdert, men forkastet til fordel
|
||||
for enkelhet -- eier/admin-sperren + den sterke `confirm()`-teksten ble
|
||||
ansett tilstrekkelig for en bevisst, sjelden handling.
|
||||
|
||||
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Fire nye
|
||||
backend-tester (`tests/test_delete_tournament.py`, 111→115 totalt) --
|
||||
begge formater med EKTE populerte data (lag-format: kamp+roster+
|
||||
match_participant+hole_score; individuelt: runde+deltaker+round_
|
||||
participant+round_hole), bekrefter RESTRICT-diamant-scenarioet faktisk
|
||||
fungerer, pluss 403 for vanlig medlem og 404 for ikke-eksisterende
|
||||
turnering. Full nettleser-scratch-verifisering (database+API+next dev)
|
||||
med et ekte scratch-scenario med reell 2FA-fullføring (TOTP-hemmelighet
|
||||
lest fra oppsettskjermen, kode beregnet med `pyotp`, siden org-eier/admin
|
||||
krever obligatorisk 2FA ved innlogging) -- bekreftet søppelbøtte-ikonet
|
||||
vises, `confirm()`-dialogen viser riktig tekst, og etter bekreftelse:
|
||||
navigasjon til dashbord OG faktisk 0 rader igjen i `tournament`/`team`/
|
||||
`team_roster`/`match`/`match_participant`/`hole_score`/`session`, mens
|
||||
spillerne selv (2 stk) fortsatt fantes uendret. Scratch-stacken revet
|
||||
ned.
|
||||
|
||||
**Rullet ut:** venter på bekreftelse.
|
||||
|
||||
---
|
||||
|
||||
## Utviklingsplan (rekkefølge)
|
||||
|
||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||
|
|
|
|||
36
CHANGELOG.md
36
CHANGELOG.md
|
|
@ -12369,3 +12369,39 @@ Neste steg:
|
|||
|
||||
**Rullet ut:** venter på bekreftelse (ren frontend, ingen
|
||||
migrasjon).
|
||||
|
||||
110. **Slett en hel turnering, permanent (ADR-086) — 2026-08-18.**
|
||||
Bruker: "Jeg mangler mulighet til å slette HELE turneringen."
|
||||
Bekreftet: genuint manglende -- ingen `DELETE`-endepunkt for selve
|
||||
`tournament`-raden fantes, kun for under-ressurser (som i tillegg
|
||||
nekter sletting så snart de har barn).
|
||||
|
||||
Nytt `DELETE /orgs/{id}/tournaments/{id}` (`app/routers/
|
||||
tournaments.py`) -- strengere sperre enn resten av turnerings-
|
||||
oppsettet (kun org-eier/admin, `is_org_admin`, ikke bare medlemskap
|
||||
som alt annet her), siden dette er kategorisk mer ødeleggende.
|
||||
**Kritisk skjemafunn:** to RESTRICT-FK-er sitter MIDT INNI
|
||||
kaskade-grafen (`match_participant.team_roster_id`,
|
||||
`tournament_round_participant.tournament_participant_id`) -- en
|
||||
naiv enkelt-setnings-kaskade kunne feilt avhengig av Postgres sin
|
||||
interne rekkefølge, KUN når det faktisk fantes ekte kamper/runder.
|
||||
Løst med eksplisitt sletting i riktig rekkefølge i stedet for å
|
||||
stole blindt på kaskaden. Spillerne selv (`player`) røres aldri --
|
||||
kun turneringens koblinger til dem.
|
||||
|
||||
UI: søppelbøtte-ikon i header ved siden av statusvelgeren, i begge
|
||||
turneringstyper (lag- og individuell-format), kun synlig for eier/
|
||||
admin. `confirm()`-bekreftelse med sterk ordlyd, samme mønster som
|
||||
runde-sletting/medlem-fjerning -- ingen ny bekreftelses-konvensjon.
|
||||
|
||||
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Fire nye
|
||||
backend-tester (111→115) -- begge formater med EKTE populerte data,
|
||||
bekrefter RESTRICT-diamant-scenarioet faktisk fungerer, pluss 403/
|
||||
404-grensetilfeller. Full nettleser-scratch-verifisering med ekte
|
||||
2FA-fullføring (TOTP-kode beregnet med `pyotp` fra oppsettskjermens
|
||||
hemmelighet) -- bekreftet knapp, `confirm()`-tekst, og at ALT
|
||||
(turnering/lag/roster/kamper/match_participant/hole_score/økt)
|
||||
faktisk ble borte mens spillerne selv besto uendret. Scratch-
|
||||
stacken revet ned.
|
||||
|
||||
**Rullet ut:** venter på bekreftelse.
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from ..email import send_session_result_email
|
|||
from ..errors import app_error, translate_db_errors
|
||||
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
|
||||
from .auth import _hash_secret
|
||||
from ..team_authz import is_org_member, user_is_team_captain, user_is_tournament_participant
|
||||
from ..team_authz import is_org_admin, is_org_member, user_is_team_captain, user_is_tournament_participant
|
||||
from .scoring import apply_concession, played_hole_numbers, recompute_and_cache_match_state
|
||||
from .. import storage
|
||||
|
||||
|
|
@ -246,6 +246,84 @@ async def update_tournament(
|
|||
return _tournament_from_row(row)
|
||||
|
||||
|
||||
@router.delete("/orgs/{organization_id}/tournaments/{tournament_id}", status_code=204)
|
||||
async def delete_tournament(
|
||||
tournament_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
"""Sletter HELE turneringen -- permanent, ingen angre (ADR-086,
|
||||
2026-08-18). Strengere sperre enn resten av denne fila (kun org-
|
||||
medlemskap for oppsett) -- kun org-eier/admin, siden dette er
|
||||
kategorisk mer ødeleggende enn noe annet i turneringsoppsettet
|
||||
(fjerner ALL score-/matchdata permanent, ikke bare tomt oppsett).
|
||||
|
||||
Eksplisitt, RIKTIG REKKEFØLGE-sletting i stedet for å stole blindt på
|
||||
én kaskaderende `DELETE FROM tournament` -- to steder i skjemaet har
|
||||
en RESTRICT-FK midt INNI kaskade-grafen (`match_participant.
|
||||
team_roster_id` og `tournament_round_participant.tournament_
|
||||
participant_id`, begge migrasjon 001/040), som kan avvise en
|
||||
enkelt-setnings-kaskade avhengig av Postgres sin interne
|
||||
kaskade-rekkefølge når det faktisk finnes ekte kamper/runder. Fjerner
|
||||
derfor RESTRICT-blokkerende rader FØR det de peker på slettes.
|
||||
`org_connection` sin egen transaksjon gjør alt atomisk uansett --
|
||||
feiler ett steg, ruller alt tilbake."""
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
if not await is_org_admin(conn, organization_id, user.user_id):
|
||||
raise app_error(
|
||||
403,
|
||||
"NOT_ORG_ADMIN",
|
||||
"Kun organisasjonseier/administrator kan slette en turnering.",
|
||||
)
|
||||
exists = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM tournament WHERE id = $1)", tournament_id
|
||||
)
|
||||
if not exists:
|
||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||
|
||||
# Lag-format: fjern match_participant FØR team_roster forsvinner
|
||||
# (RESTRICT-FK-en over) -- kaskaderer selv videre til hole_score.
|
||||
await conn.execute(
|
||||
"""
|
||||
DELETE FROM match_participant
|
||||
WHERE match_id IN (
|
||||
SELECT m.id FROM match m
|
||||
JOIN session s ON m.session_id = s.id
|
||||
WHERE s.tournament_id = $1
|
||||
)
|
||||
""",
|
||||
tournament_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"DELETE FROM match WHERE session_id IN (SELECT id FROM session WHERE tournament_id = $1)",
|
||||
tournament_id,
|
||||
)
|
||||
await conn.execute("DELETE FROM session WHERE tournament_id = $1", tournament_id)
|
||||
await conn.execute(
|
||||
"DELETE FROM team_roster WHERE team_id IN (SELECT id FROM team WHERE tournament_id = $1)",
|
||||
tournament_id,
|
||||
)
|
||||
await conn.execute("DELETE FROM team WHERE tournament_id = $1", tournament_id)
|
||||
|
||||
# Individuell-format: samme prinsipp -- tournament_round_participant
|
||||
# FØR tournament_participant/tournament_round forsvinner.
|
||||
await conn.execute(
|
||||
"""
|
||||
DELETE FROM tournament_round_participant
|
||||
WHERE tournament_round_id IN (SELECT id FROM tournament_round WHERE tournament_id = $1)
|
||||
OR tournament_participant_id IN (SELECT id FROM tournament_participant WHERE tournament_id = $1)
|
||||
""",
|
||||
tournament_id,
|
||||
)
|
||||
await conn.execute("DELETE FROM tournament_round WHERE tournament_id = $1", tournament_id)
|
||||
await conn.execute("DELETE FROM tournament_participant WHERE tournament_id = $1", tournament_id)
|
||||
|
||||
# Resten (klasser, sponsorer, påmelding, feed/chat-meldinger,
|
||||
# OoM-lenke) kaskaderer trygt fra selve tournament-raden -- ingen
|
||||
# RESTRICT-FK-er igjen på dette punktet.
|
||||
await conn.execute("DELETE FROM tournament WHERE id = $1", tournament_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/hero-image",
|
||||
response_model=Tournament,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type React from "react"
|
|||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import dynamic from "next/dynamic"
|
||||
import { useRouter } from "next/navigation"
|
||||
import type { FlagMapEntry } from "@/components/flag-map-overview"
|
||||
import {
|
||||
ArrowLeft,
|
||||
|
|
@ -342,6 +343,7 @@ export function IndividualTournamentDetail({
|
|||
tournamentId: string
|
||||
tournamentName: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [tournament, setTournament] = useState<ApiTournamentInfo | null>(null)
|
||||
const [participants, setParticipants] = useState<ApiParticipant[]>([])
|
||||
const [rounds, setRounds] = useState<ApiRound[]>([])
|
||||
|
|
@ -350,9 +352,51 @@ export function IndividualTournamentDetail({
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [tab, setTab] = useState<"setup" | "score" | "leaderboard" | "presentation">("setup")
|
||||
// Kun org-eier/admin får se/bruke "Slett turnering" (ADR-086) -- samme
|
||||
// sperre/begrunnelse som tournament-detail.tsx (lagformatet).
|
||||
const [myRole, setMyRole] = useState<"owner" | "admin" | "member" | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const base = `/orgs/${organizationId}/tournaments/${tournamentId}`
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch("/auth/me", { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((me: { organizations: { organization_id: string; role: "owner" | "admin" | "member" }[] } | null) => {
|
||||
if (cancelled || !me) return
|
||||
setMyRole(me.organizations.find((o) => o.organization_id === organizationId)?.role ?? null)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [organizationId])
|
||||
const isOrgAdmin = myRole === "owner" || myRole === "admin"
|
||||
|
||||
async function deleteTournament() {
|
||||
if (
|
||||
!confirm(
|
||||
`Slette "${tournamentName}" permanent? Dette fjerner ALT -- deltakere, alle runder og all scoring. Kan ikke angres.`,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
setDeleting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(base, { method: "DELETE", credentials: "include" })
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const body = await res.json().catch(() => null)
|
||||
throw new Error(body?.detail?.message ?? "Klarte ikke å slette turneringen.")
|
||||
}
|
||||
router.replace("/dashboard")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Klarte ikke å slette turneringen.")
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
|
|
@ -692,6 +736,17 @@ export function IndividualTournamentDetail({
|
|||
<div className="flex flex-wrap items-center gap-2 pl-13">
|
||||
<TournamentStatusPicker status={tournament.status} onChange={updateStatus} />
|
||||
<JoinCodeChip code={tournament.join_code} />
|
||||
{isOrgAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void deleteTournament()}
|
||||
disabled={deleting}
|
||||
aria-label="Slett turnering"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<Trash2 aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import type React from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowLeftRight,
|
||||
|
|
@ -94,6 +95,7 @@ export function TournamentDetail({
|
|||
tournamentId: string
|
||||
tournamentName: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [teams, setTeams] = useState<[Team | null, Team | null]>([null, null])
|
||||
const [pool, setPool] = useState<ApiPlayer[]>([])
|
||||
const [joinCode, setJoinCode] = useState<string | null>(null)
|
||||
|
|
@ -101,9 +103,56 @@ export function TournamentDetail({
|
|||
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Kun org-eier/admin får se/bruke "Slett turnering" (ADR-086) -- strengere
|
||||
// sperre enn resten av denne siden, som kun krever org-medlemskap. Håndheves
|
||||
// reelt server-side; dette skjuler bare knappen for noen den uansett ville
|
||||
// fått 403 på.
|
||||
const [myRole, setMyRole] = useState<"owner" | "admin" | "member" | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const [showImport, setShowImport] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch("/auth/me", { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((me: { organizations: { organization_id: string; role: "owner" | "admin" | "member" }[] } | null) => {
|
||||
if (cancelled || !me) return
|
||||
setMyRole(me.organizations.find((o) => o.organization_id === organizationId)?.role ?? null)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [organizationId])
|
||||
const isOrgAdmin = myRole === "owner" || myRole === "admin"
|
||||
|
||||
async function deleteTournament() {
|
||||
if (
|
||||
!confirm(
|
||||
`Slette "${tournamentName}" permanent? Dette fjerner ALT -- lag, spillere sin rostring, alle kamper og all scoring. Kan ikke angres.`,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
setDeleting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok && res.status !== 204) {
|
||||
const body = await res.json().catch(() => null)
|
||||
throw new Error(body?.detail?.message ?? "Klarte ikke å slette turneringen.")
|
||||
}
|
||||
router.replace("/dashboard")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Klarte ikke å slette turneringen.")
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
|
|
@ -443,6 +492,17 @@ export function TournamentDetail({
|
|||
</div>
|
||||
{status && <TournamentStatusPicker status={status} onChange={updateStatus} />}
|
||||
{joinCode && <JoinCodeChip code={joinCode} />}
|
||||
{isOrgAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void deleteTournament()}
|
||||
disabled={deleting}
|
||||
aria-label="Slett turnering"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<Trash2 aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav
|
||||
|
|
|
|||
142
tests/test_delete_tournament.py
Normal file
142
tests/test_delete_tournament.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"""
|
||||
Full sletting av en hel turnering (ADR-086) -- kritisk å teste med EKTE
|
||||
populert data i begge formater, ikke bare en tom turnering, siden skjemaet
|
||||
har to RESTRICT-FK-er MIDT INNI kaskade-grafen (match_participant.
|
||||
team_roster_id, tournament_round_participant.tournament_participant_id) som
|
||||
kan avvise en naiv enkelt-setnings-kaskade. Se delete_tournament() sin egen
|
||||
kommentar i app/routers/tournaments.py for full begrunnelse.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.auth import CurrentUser
|
||||
from app.routers.tournaments import delete_tournament
|
||||
|
||||
from tests.conftest import (
|
||||
add_membership,
|
||||
create_course,
|
||||
create_match,
|
||||
create_match_participant,
|
||||
create_org,
|
||||
create_org_hole,
|
||||
create_player,
|
||||
create_session,
|
||||
create_team,
|
||||
create_team_roster,
|
||||
create_tee,
|
||||
create_tournament,
|
||||
create_tournament_participant,
|
||||
create_tournament_round,
|
||||
create_tournament_round_participant,
|
||||
create_user,
|
||||
)
|
||||
from app import db as app_db
|
||||
|
||||
|
||||
async def test_delete_team_tournament_with_real_matches_and_scores(pool):
|
||||
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)
|
||||
course_id = await create_course(org_id)
|
||||
tee_id = await create_tee(org_id, course_id)
|
||||
session_id = await create_session(org_id, tournament_id, course_id)
|
||||
team_a = await create_team(org_id, tournament_id)
|
||||
team_b = await create_team(org_id, tournament_id)
|
||||
match_id = await create_match(org_id, session_id, team_a, team_b)
|
||||
player_a = await create_player(org_id)
|
||||
player_b = await create_player(org_id)
|
||||
roster_a = await create_team_roster(org_id, team_a, player_a)
|
||||
roster_b = await create_team_roster(org_id, team_b, player_b)
|
||||
mp_a = await create_match_participant(org_id, match_id, "a", roster_a, tee_id)
|
||||
mp_b = await create_match_participant(org_id, match_id, "b", roster_b, tee_id)
|
||||
|
||||
async with app_db.org_connection(org_id) as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO hole_score (organization_id, match_id, team_side, match_participant_id, "
|
||||
"hole_number, gross_strokes) VALUES ($1, $2, 'a', $3, 1, 4)",
|
||||
org_id, match_id, mp_a,
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO hole_score (organization_id, match_id, team_side, match_participant_id, "
|
||||
"hole_number, gross_strokes) VALUES ($1, $2, 'b', $3, 1, 5)",
|
||||
org_id, match_id, mp_b,
|
||||
)
|
||||
|
||||
user = CurrentUser(user_id=owner_id)
|
||||
await delete_tournament(tournament_id, organization_id=org_id, user=user)
|
||||
|
||||
async with app_db.org_connection(org_id) as conn:
|
||||
assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM tournament WHERE id = $1)", tournament_id) is False
|
||||
assert await conn.fetchval("SELECT count(*) FROM session WHERE tournament_id = $1", tournament_id) == 0
|
||||
assert await conn.fetchval("SELECT count(*) FROM match WHERE id = $1", match_id) == 0
|
||||
assert await conn.fetchval("SELECT count(*) FROM match_participant WHERE match_id = $1", match_id) == 0
|
||||
assert await conn.fetchval("SELECT count(*) FROM hole_score WHERE match_id = $1", match_id) == 0
|
||||
assert await conn.fetchval("SELECT count(*) FROM team_roster WHERE team_id = $1", team_a) == 0
|
||||
assert await conn.fetchval("SELECT count(*) FROM team WHERE tournament_id = $1", tournament_id) == 0
|
||||
# Spillerne selv (org-scopet spillerpool) skal IKKE slettes -- kun
|
||||
# turneringens egne koblinger til dem.
|
||||
assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM player WHERE id = $1)", player_a) is True
|
||||
|
||||
|
||||
async def test_delete_individual_tournament_with_real_rounds_and_scores(pool):
|
||||
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)
|
||||
course_id = await create_course(org_id)
|
||||
for n in range(1, 19):
|
||||
await create_org_hole(org_id, course_id, hole_number=n)
|
||||
tee_id = await create_tee(org_id, course_id)
|
||||
round_id = await create_tournament_round(org_id, tournament_id, course_id)
|
||||
player_id = await create_player(org_id)
|
||||
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
|
||||
rp_id = await create_tournament_round_participant(org_id, round_id, tp_id, tee_id)
|
||||
|
||||
async with app_db.org_connection(org_id) as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO tournament_round_hole (organization_id, tournament_round_participant_id, "
|
||||
"hole_number, gross_strokes) VALUES ($1, $2, 1, 4)",
|
||||
org_id, rp_id,
|
||||
)
|
||||
|
||||
user = CurrentUser(user_id=owner_id)
|
||||
await delete_tournament(tournament_id, organization_id=org_id, user=user)
|
||||
|
||||
async with app_db.org_connection(org_id) as conn:
|
||||
assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM tournament WHERE id = $1)", tournament_id) is False
|
||||
assert await conn.fetchval("SELECT count(*) FROM tournament_round WHERE tournament_id = $1", tournament_id) == 0
|
||||
assert await conn.fetchval("SELECT count(*) FROM tournament_participant WHERE tournament_id = $1", tournament_id) == 0
|
||||
assert await conn.fetchval(
|
||||
"SELECT count(*) FROM tournament_round_participant WHERE tournament_round_id = $1", round_id
|
||||
) == 0
|
||||
assert await conn.fetchval(
|
||||
"SELECT count(*) FROM tournament_round_hole WHERE tournament_round_participant_id = $1", rp_id
|
||||
) == 0
|
||||
|
||||
|
||||
async def test_delete_requires_org_admin_not_just_member(pool):
|
||||
org_id = await create_org()
|
||||
member_id = await create_user()
|
||||
await add_membership(org_id, member_id, role="member")
|
||||
tournament_id = await create_tournament(org_id)
|
||||
|
||||
user = CurrentUser(user_id=member_id)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await delete_tournament(tournament_id, organization_id=org_id, user=user)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
async with app_db.org_connection(org_id) as conn:
|
||||
assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM tournament WHERE id = $1)", tournament_id) is True
|
||||
|
||||
|
||||
async def test_delete_nonexistent_tournament_is_404(pool):
|
||||
org_id = await create_org()
|
||||
owner_id = await create_user()
|
||||
await add_membership(org_id, owner_id, role="owner")
|
||||
|
||||
user = CurrentUser(user_id=owner_id)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await delete_tournament("00000000-0000-0000-0000-000000000000", organization_id=org_id, user=user)
|
||||
assert exc_info.value.status_code == 404
|
||||
Loading…
Reference in a new issue