Flight/gruppe-scoping i ScoreTab (ADR-099) -- steg 1 av parity-planen
ScoreTab viste hele turneringsfeltet i stedet for brukerens egen utslagsgruppe. Defaulter nå til egen flight med "vis hele feltet"- veksling og fallback når gruppe mangler. is_self beregnes server-side (aldri rå user_id). Lukker et reelt RLS-hull på tournament_round_group funnet underveis (migrasjon 089). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
dd974678fa
commit
4587503d03
7 changed files with 314 additions and 7 deletions
27
089_tournament_round_group_rls.sql
Normal file
27
089_tournament_round_group_rls.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — migrasjon 089
|
||||||
|
-- RLS på tournament_round_group (lukker et hull fra migrasjon 084)
|
||||||
|
-- =====================================================================
|
||||||
|
-- Oppdaget under kartlegging til parity-arbeidet (score-føring i
|
||||||
|
-- turnering vs. frittstående runde, 2026-08-21): tournament_round_group
|
||||||
|
-- (opprettet i 084_tournament_round_groups.sql, utvidet i
|
||||||
|
-- 088_shotgun_start.sql) fikk ALDRI standard org_isolation-RLS-policyen
|
||||||
|
-- som ellers gjelder alle domenetabeller (ADR-003) -- i strid med
|
||||||
|
-- arkitektur-invarianten i CLAUDE.md. Ingen begrunnet unntak (i
|
||||||
|
-- motsetning til f.eks. round_shot/round_hole/round_message, som
|
||||||
|
-- bevisst står utenfor RLS per ADR-033 Beslutning A). I praksis kun
|
||||||
|
-- beskyttet av WHERE-ledd i applikasjonskoden frem til nå.
|
||||||
|
--
|
||||||
|
-- Ingen dataendring, kun policy -- alle eksisterende spørringer går
|
||||||
|
-- allerede via org_connection() (setter app.current_org), så dette er
|
||||||
|
-- additivt, ikke en atferdsendring for korrekt scopet kode.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
ALTER TABLE tournament_round_group ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE tournament_round_group FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY org_isolation ON tournament_round_group
|
||||||
|
USING (organization_id = current_setting('app.current_org', true)::uuid)
|
||||||
|
WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid);
|
||||||
|
|
@ -10005,6 +10005,69 @@ Ingen migrasjon.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-099: Flight/gruppe-scoping i ScoreTab (2026-08-21)
|
||||||
|
|
||||||
|
**Kontekst:** bruker ba om full opplevelses-paritet mellom score-føring i
|
||||||
|
en org-turnering og en frittstående individuell runde ("Jeg skal i
|
||||||
|
utgangspunktet ha en identisk opplevelse når jeg fører score for meg (og
|
||||||
|
eventuelt de andre i flighten min)..."). Grundig undersøkelse fant det
|
||||||
|
mest strukturelle gapet: `ScoreTab` viste HELE turneringsfeltet
|
||||||
|
(alfabetisk, alle deltakere) i stedet for kun brukerens egen flight/
|
||||||
|
gruppe -- `tournament_round_group` (bygget for utslags-/tee-sheet-paring,
|
||||||
|
ADR-096) ble KUN lest av administrator-parings-skjermen, aldri av selve
|
||||||
|
score-føringsskjermen. Første punkt i en avtalt 8-punkts parity-
|
||||||
|
rekkefølge; auto-advance mellom spillere i flighten er et EGET, senere
|
||||||
|
steg -- denne runden endrer kun HVEM som vises/velges som standard.
|
||||||
|
|
||||||
|
**Beslutning A -- visningsendring, IKKE en ny autorisasjonsmodell.**
|
||||||
|
`update_hole` er allerede self-only (eller org-admin) via
|
||||||
|
`user_is_own_tournament_participant` (`team_authz.py`, innstrammet
|
||||||
|
2026-07-30). Denne runden endrer kun `ScoreTab`s participant-VELGER
|
||||||
|
(pill-listen) og standardvalg av `participantId` -- en org-admin kan
|
||||||
|
fortsatt bla til og rette en hvilken som helst deltaker via "Vis hele
|
||||||
|
feltet". Lesetilgang på andres hull (`list_round_participant_holes`) og
|
||||||
|
BBB-scoring (bevisst hele-feltet, egen docstring-begrunnelse) er UENDRET.
|
||||||
|
|
||||||
|
**Beslutning B -- fallback til hele feltet når gruppe mangler, ikke en
|
||||||
|
blokkerende tilstand.** Ikke alle runder har kjørt "Grupper og
|
||||||
|
startliste" ennå. `ScoreTab` faller da tilbake til dagens oppførsel (hele
|
||||||
|
feltet) med en synlig inline-merknad om hvorfor, i stedet for å skjule
|
||||||
|
scoring-muligheten. Verifisert eksplisitt i scratch (se Status).
|
||||||
|
|
||||||
|
**Beslutning C -- `is_self` beregnes server-side, ikke rå `user_id` til
|
||||||
|
klienten.** `list_round_participants`/`add_round_participant`/
|
||||||
|
`update_round_participant_tee` (`individual_tournaments.py`) utvidet med
|
||||||
|
`(p.user_id::text = $2)`-sammenligning (samme `is_mine`-mønster som
|
||||||
|
`rounds.py`), IKKE et rått `user_id`-felt -- unngår å lekke andre
|
||||||
|
spilleres kontokobling til enhver org-medlem-klient. `player.user_id` er
|
||||||
|
nullable (spillere uten konto) -- NULL-verdien måtte eksplisitt
|
||||||
|
`COALESCE`-es til `false` (fanget av en dedikert ny test,
|
||||||
|
`test_round_participant_flight_scoping.py`, ellers 500 pga.
|
||||||
|
Pydantic-validering av et NULL-boolsk felt).
|
||||||
|
|
||||||
|
**Beslutning D -- lukket et reelt RLS-hull funnet underveis.**
|
||||||
|
`tournament_round_group` (migrasjon 084/088) manglet HELT standard
|
||||||
|
org_isolation-RLS (bekreftet: ingen `ENABLE ROW LEVEL SECURITY`/
|
||||||
|
`CREATE POLICY` i noen av de to migrasjonene, uten den typen begrunnede
|
||||||
|
unntak `round_shot`/`round_hole` har per ADR-033). Lukket med ny
|
||||||
|
migrasjon `089_tournament_round_group_rls.sql`, samme mønster som
|
||||||
|
`001_initial_schema.sql`. I praksis var isolasjonen tidligere kun
|
||||||
|
app-lagets WHERE-ledd -- additiv, ikke en atferdsendring.
|
||||||
|
|
||||||
|
**Status:** 164/164 pytest (inkl. ny dedikert test), `tsc --noEmit` rent.
|
||||||
|
RLS-policyen verifisert direkte mot en engangs scratch-database (SQL som
|
||||||
|
`teecup_app`-rollen med feil `app.current_org` satt returnerte 0 rader,
|
||||||
|
riktig org 1 rad). Full-stack scratch-verifisering (egen scratch-db, egen
|
||||||
|
API-container, egen MinIO, ekte innlogging via `/auth/login-password`,
|
||||||
|
ingen fetch-mocking) bekreftet: gruppert runde defaulter til egen flight
|
||||||
|
(3 av 6 vist), "Vis hele feltet"/"Vis kun min flight" veksler korrekt
|
||||||
|
begge veier, ugruppert runde faller korrekt tilbake til hele feltet med
|
||||||
|
riktig merknad, mørk modus og smal mobilskjerm uendret lesbare. Alle
|
||||||
|
scratch-ressurser (container/db/rolle) ryddet opp etter verifisering.
|
||||||
|
IKKE bygget/rullet ut mot ekte containere ennå.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Utviklingsplan (rekkefølge)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
||||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||||
|
|
|
||||||
68
CHANGELOG.md
68
CHANGELOG.md
|
|
@ -14233,3 +14233,71 @@ Neste steg:
|
||||||
`/public/tournaments/{id}` returnerer `format_type: "individual"`,
|
`/public/tournaments/{id}` returnerer `format_type: "individual"`,
|
||||||
`/t/{id}/live` svarer 200. Scratch-ressursene (containere, dev-
|
`/t/{id}/live` svarer 200. Scratch-ressursene (containere, dev-
|
||||||
server) ryddet opp og bekreftet stoppet etterpå.
|
server) ryddet opp og bekreftet stoppet etterpå.
|
||||||
|
|
||||||
|
154. **Rett Thru-kolonnens vertikale justering i offentlig leaderboard,
|
||||||
|
2026-08-21 -- ekte bruk-funnet bug etter #153 sin utrulling.** Bruker
|
||||||
|
rapporterte fra faktisk mobilbruk (teecup.golf, live): THRU-etiketten
|
||||||
|
sto lavere enn I DAG/TOTAL. Årsak: Thru-verdien manglet samme
|
||||||
|
`min-h-10` som `ScoreShape` (brukt av I dag/Total) gir de andre
|
||||||
|
kolonnene -- hele Thru-kolonnen ble dermed lavere, senterte etiketten
|
||||||
|
ut av linje med søsknene sine. Rettet i
|
||||||
|
`public-individual-live.tsx`. Rullet ut sammen med punkt 155.
|
||||||
|
|
||||||
|
155. **Skjul HCP på offentlig leaderboard for rene bruttoslagspill-
|
||||||
|
turneringer, 2026-08-21.** Bruker: "når det er brutto som er
|
||||||
|
hovedkonkurransen trenger vi ikke se hcp i leaderboarden" -- HCP er
|
||||||
|
kun meningsfullt der den faktisk brukes til slagfordeling (netto/
|
||||||
|
stableford/Københavner/BBB), ikke ren brutto/eclectic-gross.
|
||||||
|
`PublicTournamentInfo` += `scoring_method` (manglet før på den
|
||||||
|
offentlige modellen), ny `shouldShowHcp(scoringMethod)`-hjelper i
|
||||||
|
`public-individual-live.tsx` (`GROSS_ONLY_METHODS = {"stroke_gross",
|
||||||
|
"eclectic_gross"}`). Bruker stilte oppfølgingsspørsmål om dette var en
|
||||||
|
systemendring (ja) -- eskalerte til den bredere parity-diskusjonen som
|
||||||
|
punkt 156 svarer på. Rullet ut sammen med punkt 154 (`docker compose
|
||||||
|
build teecup_api teecup_frontend && up -d`).
|
||||||
|
|
||||||
|
156. **Flight/gruppe-scoping i ScoreTab (ADR-099), 2026-08-21 -- steg 1
|
||||||
|
av en avtalt 8-punkts parity-plan.** Bruker: "Jeg skal i utgangspunktet
|
||||||
|
ha en identisk opplevelse når jeg fører score for meg (og eventuelt de
|
||||||
|
andre i flighten min) i en org-turnering som det jeg har når jeg fører
|
||||||
|
en individuell runde." Grundig Explore-undersøkelse kartla hele gapet
|
||||||
|
mellom org-turneringers scoring og frittstående runders scoring
|
||||||
|
(rapportert til bruker, som ba om rekkefølgen). Denne runden bygger
|
||||||
|
punkt 1: `ScoreTab` viste tidligere HELE turneringsfeltet i stedet for
|
||||||
|
brukerens egen flight -- `tournament_round_group` ble kun lest av
|
||||||
|
admin-parings-skjermen. Se ADR-099 for de fire beslutningene (visning
|
||||||
|
ikke autorisasjon; fallback til hele feltet uten gruppe; `is_self`
|
||||||
|
server-side, aldri rå `user_id`; et reelt RLS-hull på
|
||||||
|
`tournament_round_group` funnet og lukket underveis, migrasjon 089).
|
||||||
|
|
||||||
|
**Backend:** `RoundParticipantOut` += `tournament_round_group_id`,
|
||||||
|
`is_self` (tre spørringer i `individual_tournaments.py` utvidet:
|
||||||
|
`add_round_participant`, `list_round_participants`,
|
||||||
|
`update_round_participant_tee`). Ny migrasjon
|
||||||
|
`089_tournament_round_group_rls.sql`.
|
||||||
|
|
||||||
|
**Frontend:** `ScoreTab` (`individual-tournament-detail.tsx`)
|
||||||
|
defaulter pill-listen og `participantId`-valget til egen gruppe når
|
||||||
|
en finnes, med "Vis hele feltet"/"Vis kun min flight"-veksling og en
|
||||||
|
inline-merknad når runden mangler gruppeinndeling. Ingen ny V0-runde
|
||||||
|
-- filter+lenke lagt til en allerede eksisterende, V0-bygget skjerm.
|
||||||
|
|
||||||
|
**Verifisert:** 164/164 pytest (inkl. ny
|
||||||
|
`test_round_participant_flight_scoping.py` -- fanget en reell NULL-
|
||||||
|
håndteringsbug: `p.user_id::text = $2` ga SQL NULL, ikke `false`, for
|
||||||
|
spillere uten konto, rettet med `COALESCE`), `tsc --noEmit` rent.
|
||||||
|
RLS-policyen verifisert direkte (engangs scratch-db: `teecup_app`-
|
||||||
|
rollen med feil `app.current_org` satt fikk 0 rader, riktig org 1
|
||||||
|
rad). Full-stack scratch-verifisering (egen scratch-db, egen
|
||||||
|
API-container, egen isolert MinIO, ekte innlogging via
|
||||||
|
`/auth/login-password`, INGEN fetch-mocking -- et tidligere forsøk med
|
||||||
|
`window.fetch`-mocking i chrome-devtools-MCP-miljøet brøt selve
|
||||||
|
sidenavigasjonen på en måte som ikke var reproduserbar med ekte
|
||||||
|
nettverkstrafikk, byttet derfor til en ekte scratch-backend i stedet
|
||||||
|
for å grave videre i et miljøspesifikt verktøyproblem) bekreftet:
|
||||||
|
gruppert runde defaulter riktig til 3 av 6 (egen flight), veksling
|
||||||
|
fungerer begge veier, ugruppert runde faller korrekt tilbake med
|
||||||
|
riktig merknad, mørk modus og 390px mobilskjerm uendret lesbare.
|
||||||
|
Alle scratch-ressurser (API-/MinIO-container, db, rolle) ryddet opp
|
||||||
|
etter verifisering. IKKE bygget/rullet ut mot ekte containere ennå --
|
||||||
|
venter på bekreftelse.
|
||||||
|
|
|
||||||
|
|
@ -520,6 +520,12 @@ class RoundParticipantOut(BaseModel):
|
||||||
# level), speilet ut her slik at frontend vet om HoleGrid-cellen skal
|
# level), speilet ut her slik at frontend vet om HoleGrid-cellen skal
|
||||||
# åpne det utvidede HoleStatsSheet-et for denne deltakeren.
|
# åpne det utvidede HoleStatsSheet-et for denne deltakeren.
|
||||||
stat_level: str
|
stat_level: str
|
||||||
|
# Flight-scoping i ScoreTab (ADR-099) -- gruppe-id fra migrasjon 084
|
||||||
|
# (NULL = ikke gruppert ennå) og om raden er den innloggede brukeren
|
||||||
|
# selv (aldri rå user_id til klienten -- samme is_mine-mønster som
|
||||||
|
# rounds.py).
|
||||||
|
tournament_round_group_id: str | None
|
||||||
|
is_self: bool
|
||||||
|
|
||||||
|
|
||||||
async def _compute_round_participant_handicap(conn, round_participant_id: str) -> None:
|
async def _compute_round_participant_handicap(conn, round_participant_id: str) -> None:
|
||||||
|
|
@ -637,6 +643,7 @@ async def add_round_participant(
|
||||||
round_id: str,
|
round_id: str,
|
||||||
body: RoundParticipantCreate,
|
body: RoundParticipantCreate,
|
||||||
organization_id: str = Depends(get_authorized_org),
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> RoundParticipantOut:
|
) -> RoundParticipantOut:
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
await _validate_round_tee_choice(conn, tournament_id, round_id, body.tournament_participant_id, body.tee_id)
|
await _validate_round_tee_choice(conn, tournament_id, round_id, body.tournament_participant_id, body.tee_id)
|
||||||
|
|
@ -664,7 +671,9 @@ async def add_round_participant(
|
||||||
"""
|
"""
|
||||||
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
|
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
|
||||||
p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
|
p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
|
||||||
trp.course_handicap, trp.playing_handicap, tp.stat_level
|
trp.course_handicap, trp.playing_handicap, tp.stat_level,
|
||||||
|
trp.tournament_round_group_id::text AS tournament_round_group_id,
|
||||||
|
COALESCE(p.user_id::text = $2, false) AS is_self
|
||||||
FROM tournament_round_participant trp
|
FROM tournament_round_participant trp
|
||||||
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
||||||
JOIN player p ON p.id = tp.player_id
|
JOIN player p ON p.id = tp.player_id
|
||||||
|
|
@ -672,6 +681,7 @@ async def add_round_participant(
|
||||||
WHERE trp.id = $1
|
WHERE trp.id = $1
|
||||||
""",
|
""",
|
||||||
round_participant_id,
|
round_participant_id,
|
||||||
|
user.user_id,
|
||||||
)
|
)
|
||||||
return RoundParticipantOut(**dict(out))
|
return RoundParticipantOut(**dict(out))
|
||||||
|
|
||||||
|
|
@ -684,13 +694,16 @@ async def list_round_participants(
|
||||||
tournament_id: str,
|
tournament_id: str,
|
||||||
round_id: str,
|
round_id: str,
|
||||||
organization_id: str = Depends(get_authorized_org),
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> list[RoundParticipantOut]:
|
) -> list[RoundParticipantOut]:
|
||||||
async with org_connection(organization_id) as conn:
|
async with org_connection(organization_id) as conn:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
|
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
|
||||||
p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
|
p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
|
||||||
trp.course_handicap, trp.playing_handicap, tp.stat_level
|
trp.course_handicap, trp.playing_handicap, tp.stat_level,
|
||||||
|
trp.tournament_round_group_id::text AS tournament_round_group_id,
|
||||||
|
COALESCE(p.user_id::text = $2, false) AS is_self
|
||||||
FROM tournament_round_participant trp
|
FROM tournament_round_participant trp
|
||||||
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
||||||
JOIN player p ON p.id = tp.player_id
|
JOIN player p ON p.id = tp.player_id
|
||||||
|
|
@ -699,6 +712,7 @@ async def list_round_participants(
|
||||||
ORDER BY p.display_name
|
ORDER BY p.display_name
|
||||||
""",
|
""",
|
||||||
round_id,
|
round_id,
|
||||||
|
user.user_id,
|
||||||
)
|
)
|
||||||
return [RoundParticipantOut(**dict(r)) for r in rows]
|
return [RoundParticipantOut(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
@ -733,6 +747,7 @@ async def update_round_participant_tee(
|
||||||
round_participant_id: str,
|
round_participant_id: str,
|
||||||
body: RoundParticipantTeeUpdate,
|
body: RoundParticipantTeeUpdate,
|
||||||
organization_id: str = Depends(get_authorized_org),
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> RoundParticipantOut:
|
) -> RoundParticipantOut:
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
existing = await conn.fetchrow(
|
existing = await conn.fetchrow(
|
||||||
|
|
@ -759,7 +774,9 @@ async def update_round_participant_tee(
|
||||||
"""
|
"""
|
||||||
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
|
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
|
||||||
p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
|
p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
|
||||||
trp.course_handicap, trp.playing_handicap, tp.stat_level
|
trp.course_handicap, trp.playing_handicap, tp.stat_level,
|
||||||
|
trp.tournament_round_group_id::text AS tournament_round_group_id,
|
||||||
|
COALESCE(p.user_id::text = $2, false) AS is_self
|
||||||
FROM tournament_round_participant trp
|
FROM tournament_round_participant trp
|
||||||
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
||||||
JOIN player p ON p.id = tp.player_id
|
JOIN player p ON p.id = tp.player_id
|
||||||
|
|
@ -767,6 +784,7 @@ async def update_round_participant_tee(
|
||||||
WHERE trp.id = $1
|
WHERE trp.id = $1
|
||||||
""",
|
""",
|
||||||
round_participant_id,
|
round_participant_id,
|
||||||
|
user.user_id,
|
||||||
)
|
)
|
||||||
return RoundParticipantOut(**dict(out))
|
return RoundParticipantOut(**dict(out))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,9 @@ type ApiRoundParticipant = {
|
||||||
// "strokes_and_putts" / "full", styrer om HoleGrid sin celle åpner det
|
// "strokes_and_putts" / "full", styrer om HoleGrid sin celle åpner det
|
||||||
// enkle inline-tallfeltet (uendret oppførsel) eller HoleStatsSheet.
|
// enkle inline-tallfeltet (uendret oppførsel) eller HoleStatsSheet.
|
||||||
stat_level: "strokes_only" | "strokes_and_putts" | "full"
|
stat_level: "strokes_only" | "strokes_and_putts" | "full"
|
||||||
|
// Flight-scoping i ScoreTab (ADR-099).
|
||||||
|
tournament_round_group_id: string | null
|
||||||
|
is_self: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiHole = {
|
type ApiHole = {
|
||||||
|
|
@ -2965,6 +2968,18 @@ function ScoreTab({
|
||||||
// fetch) vet de skal hente på nytt.
|
// fetch) vet de skal hente på nytt.
|
||||||
const [refreshTick, setRefreshTick] = useState(0)
|
const [refreshTick, setRefreshTick] = useState(0)
|
||||||
|
|
||||||
|
// Flight-scoping (ADR-099) -- standard er å vise KUN egen
|
||||||
|
// utslagsgruppe i velgeren under, med en lenke for å se hele feltet.
|
||||||
|
// roundParticipants selv holdes ALLTID ufiltrert (BBB/Flag-panelene
|
||||||
|
// under bruker den direkte, uavhengig av dette).
|
||||||
|
const [showFullField, setShowFullField] = useState(false)
|
||||||
|
const myParticipant = roundParticipants.find((rp) => rp.is_self) ?? null
|
||||||
|
const myGroupId = myParticipant?.tournament_round_group_id ?? null
|
||||||
|
const visibleParticipants =
|
||||||
|
showFullField || !myGroupId
|
||||||
|
? roundParticipants
|
||||||
|
: roundParticipants.filter((rp) => rp.tournament_round_group_id === myGroupId)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!roundId) return
|
if (!roundId) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -2972,7 +2987,11 @@ function ScoreTab({
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const list = data ?? []
|
const list = data ?? []
|
||||||
setRoundParticipants(list)
|
setRoundParticipants(list)
|
||||||
setParticipantId(list[0]?.id ?? "")
|
setShowFullField(false)
|
||||||
|
const self = list.find((rp) => rp.is_self)
|
||||||
|
const selfGroupId = self?.tournament_round_group_id ?? null
|
||||||
|
const defaultList = selfGroupId ? list.filter((rp) => rp.tournament_round_group_id === selfGroupId) : list
|
||||||
|
setParticipantId(self?.id ?? defaultList[0]?.id ?? "")
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
|
@ -3068,7 +3087,7 @@ function ScoreTab({
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{roundParticipants.map((rp) => (
|
{visibleParticipants.map((rp) => (
|
||||||
<button
|
<button
|
||||||
key={rp.id}
|
key={rp.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -3086,6 +3105,20 @@ function ScoreTab({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{myGroupId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowFullField((v) => !v)}
|
||||||
|
className="self-start text-sm font-bold text-info underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
{showFullField ? "Vis kun min flight" : `Vis hele feltet (${roundParticipants.length})`}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Ingen gruppeinndeling er satt for denne runden ennå -- viser hele feltet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{round && holes && (
|
{round && holes && (
|
||||||
<HoleGrid
|
<HoleGrid
|
||||||
base={base}
|
base={base}
|
||||||
|
|
|
||||||
93
tests/test_round_participant_flight_scoping.py
Normal file
93
tests/test_round_participant_flight_scoping.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""
|
||||||
|
Flight/gruppe-scoping i ScoreTab (ADR-099, 2026-08-21). list_round_participants
|
||||||
|
speiler nå ut tournament_round_group_id + is_self per rad, slik at frontend kan
|
||||||
|
la score-føringsskjermen default til brukerens egen utslagsgruppe i stedet for
|
||||||
|
hele feltet. is_self beregnes server-side (ikke rå user_id, samme is_mine-
|
||||||
|
mønster som rounds.py) for ikke å lekke andre spilleres kontokobling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.auth import CurrentUser
|
||||||
|
from app.routers.individual_tournaments import (
|
||||||
|
RoundGroupIn,
|
||||||
|
SaveRoundGroupsIn,
|
||||||
|
list_round_participants,
|
||||||
|
save_round_groups,
|
||||||
|
)
|
||||||
|
|
||||||
|
from tests.conftest import (
|
||||||
|
create_org,
|
||||||
|
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 _setup(org_id):
|
||||||
|
tournament_id = await create_tournament(org_id, name="Flight-scoping-turnering")
|
||||||
|
course_id = await create_course(org_id, name="Flight Links")
|
||||||
|
tee_id = await create_tee(org_id, course_id, name="Gul")
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
round_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
|
||||||
|
return tournament_id, round_id, tee_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_is_self_and_group_id_reflect_viewer_and_grouping(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
tournament_id, round_id, tee_id = await _setup(org_id)
|
||||||
|
|
||||||
|
me_user_id = await create_user()
|
||||||
|
other_user_id = await create_user()
|
||||||
|
|
||||||
|
me_player_id = await create_player(org_id, display_name="Meg Selv", user_id=me_user_id)
|
||||||
|
other_player_id = await create_player(org_id, display_name="En Annen", user_id=other_user_id)
|
||||||
|
|
||||||
|
me_tp_id = await create_tournament_participant(org_id, tournament_id, me_player_id)
|
||||||
|
other_tp_id = await create_tournament_participant(org_id, tournament_id, other_player_id)
|
||||||
|
|
||||||
|
me_rp_id = await create_tournament_round_participant(org_id, round_id, me_tp_id, tee_id)
|
||||||
|
other_rp_id = await create_tournament_round_participant(org_id, round_id, other_tp_id, tee_id)
|
||||||
|
|
||||||
|
# Ingen gruppe satt ennå -- begge rader skal ha tournament_round_group_id=None.
|
||||||
|
ungrouped = await list_round_participants(
|
||||||
|
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=me_user_id),
|
||||||
|
)
|
||||||
|
by_id = {rp.id: rp for rp in ungrouped}
|
||||||
|
assert by_id[me_rp_id].tournament_round_group_id is None
|
||||||
|
assert by_id[other_rp_id].tournament_round_group_id is None
|
||||||
|
assert by_id[me_rp_id].is_self is True
|
||||||
|
assert by_id[other_rp_id].is_self is False
|
||||||
|
|
||||||
|
# Grupper begge i samme gruppe -- gruppe-id skal nå matche på begge rader,
|
||||||
|
# og is_self skal fortsatt kun være sann for den innloggede brukeren.
|
||||||
|
await save_round_groups(
|
||||||
|
tournament_id, round_id,
|
||||||
|
SaveRoundGroupsIn(groups=[RoundGroupIn(sequence=1, round_participant_ids=[me_rp_id, other_rp_id])]),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
grouped = await list_round_participants(
|
||||||
|
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=me_user_id),
|
||||||
|
)
|
||||||
|
by_id = {rp.id: rp for rp in grouped}
|
||||||
|
assert by_id[me_rp_id].tournament_round_group_id is not None
|
||||||
|
assert by_id[me_rp_id].tournament_round_group_id == by_id[other_rp_id].tournament_round_group_id
|
||||||
|
assert by_id[me_rp_id].is_self is True
|
||||||
|
assert by_id[other_rp_id].is_self is False
|
||||||
|
|
||||||
|
# Sett fra den ANDRE brukerens perspektiv: is_self bytter side.
|
||||||
|
as_other = await list_round_participants(
|
||||||
|
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=other_user_id),
|
||||||
|
)
|
||||||
|
by_id = {rp.id: rp for rp in as_other}
|
||||||
|
assert by_id[me_rp_id].is_self is False
|
||||||
|
assert by_id[other_rp_id].is_self is True
|
||||||
|
|
@ -13,6 +13,7 @@ får alltid tournament_round_group_id=NULL).
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.auth import CurrentUser
|
||||||
from app.routers.individual_tournaments import (
|
from app.routers.individual_tournaments import (
|
||||||
RoundGroupIn,
|
RoundGroupIn,
|
||||||
RoundParticipantTeeUpdate,
|
RoundParticipantTeeUpdate,
|
||||||
|
|
@ -30,6 +31,7 @@ from tests.conftest import (
|
||||||
create_tournament_participant,
|
create_tournament_participant,
|
||||||
create_tournament_round,
|
create_tournament_round,
|
||||||
create_tournament_round_participant,
|
create_tournament_round_participant,
|
||||||
|
create_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
import app.db as app_db
|
import app.db as app_db
|
||||||
|
|
@ -69,8 +71,10 @@ async def test_changing_tee_preserves_group_membership(pool):
|
||||||
)
|
)
|
||||||
assert group_id_before is not None
|
assert group_id_before is not None
|
||||||
|
|
||||||
|
user_id = await create_user()
|
||||||
updated = await update_round_participant_tee(
|
updated = await update_round_participant_tee(
|
||||||
tournament_id, round_id, rp_id, RoundParticipantTeeUpdate(tee_id=tee_b), organization_id=org_id
|
tournament_id, round_id, rp_id, RoundParticipantTeeUpdate(tee_id=tee_b),
|
||||||
|
organization_id=org_id, user=CurrentUser(user_id=user_id),
|
||||||
)
|
)
|
||||||
assert updated.tee_id == tee_b
|
assert updated.tee_id == tee_b
|
||||||
assert updated.id == rp_id # samme rad, ikke slett+gjenopprett
|
assert updated.id == rp_id # samme rad, ikke slett+gjenopprett
|
||||||
|
|
@ -88,11 +92,12 @@ async def test_changing_tee_to_one_outside_course_is_rejected(pool):
|
||||||
other_course_id = await create_course(org_id, name="En annen bane")
|
other_course_id = await create_course(org_id, name="En annen bane")
|
||||||
foreign_tee_id = await create_tee(org_id, other_course_id, name="Blå")
|
foreign_tee_id = await create_tee(org_id, other_course_id, name="Blå")
|
||||||
|
|
||||||
|
user_id = await create_user()
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
await update_round_participant_tee(
|
await update_round_participant_tee(
|
||||||
tournament_id, round_id, rp_id,
|
tournament_id, round_id, rp_id,
|
||||||
RoundParticipantTeeUpdate(tee_id=foreign_tee_id),
|
RoundParticipantTeeUpdate(tee_id=foreign_tee_id),
|
||||||
organization_id=org_id,
|
organization_id=org_id, user=CurrentUser(user_id=user_id),
|
||||||
)
|
)
|
||||||
assert exc_info.value.status_code == 400
|
assert exc_info.value.status_code == 400
|
||||||
assert exc_info.value.detail["code"] == "OUT_OF_SCOPE"
|
assert exc_info.value.detail["code"] == "OUT_OF_SCOPE"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue