Shotgun-start: backend (migrasjon 088, ADR-096)
Ny start_mode ('consecutive'/'shotgun') på tournament_round + session,
og valgfri start_hole på tournament_round_group + match. Shotgun: alle
grupper/matcher går ut samtidig, sortert etter starthull i stedet for
klokkeslett. Reverserer/utvider migrasjon 084s bevisste "ikke shotgun"
fra 2026-08-18 -- nytt behov oppdaget under utskrift-planlegging.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
0792205c64
commit
f1575c9b42
6 changed files with 318 additions and 24 deletions
48
088_shotgun_start.sql
Normal file
48
088_shotgun_start.sql
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — migrasjon 088
|
||||||
|
-- Shotgun-start: alternativ til den tradisjonelle, staggerte starten
|
||||||
|
-- =====================================================================
|
||||||
|
-- Reverserer/utvider EKSPLISITT migrasjon 084 sitt bevisste "IKKE
|
||||||
|
-- shotgun"-valg fra 2026-08-18 -- se ADR-096 for full begrunnelse for
|
||||||
|
-- hvorfor dette nå bygges. Samme arkitektoniske hull fantes BEGGE
|
||||||
|
-- steder rundeplanlegging finnes i appen: individuelle turneringer
|
||||||
|
-- (tournament_round/tournament_round_group) og lag/Cup-format
|
||||||
|
-- (session/match) -- ett delt starthull for HELE runden/økten, kun
|
||||||
|
-- ulikt KLOKKESLETT per gruppe/match, aldri ulikt HULL. Rettet
|
||||||
|
-- identisk begge steder.
|
||||||
|
--
|
||||||
|
-- start_mode = 'consecutive' (standard, uendret oppførsel -- INGEN
|
||||||
|
-- regresjon): alle grupper/matcher deler samme starthull
|
||||||
|
-- (tournament_round.start_hole / session.start_hole, uendret), staggert
|
||||||
|
-- KUN på klokkeslett (eksisterende tee_time_override-mønster, uendret).
|
||||||
|
--
|
||||||
|
-- start_mode = 'shotgun': ALLE grupper/matcher i runden/økten går ut
|
||||||
|
-- SAMTIDIG (intervallet blir meningsløst og ignoreres av app-laget i
|
||||||
|
-- denne modusen) -- fra HVER SIN starthull i stedet. Nytt, valgfritt
|
||||||
|
-- per-gruppe/per-match starthull-felt dekker dette -- NULL når runden/
|
||||||
|
-- økten uansett er 'consecutive' (bruker da forelderens delte
|
||||||
|
-- starthull som før), settes eksplisitt av organisator per gruppe/
|
||||||
|
-- match kun i shotgun-modus.
|
||||||
|
--
|
||||||
|
-- Samme text+CHECK-mønster som migrasjon 087 (ikke en ny
|
||||||
|
-- CREATE TYPE ... AS ENUM, konsistent med nyere migrasjoner i denne
|
||||||
|
-- filserien).
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
ALTER TABLE tournament_round
|
||||||
|
ADD COLUMN start_mode text NOT NULL DEFAULT 'consecutive'
|
||||||
|
CHECK (start_mode IN ('consecutive', 'shotgun'));
|
||||||
|
|
||||||
|
ALTER TABLE tournament_round_group
|
||||||
|
ADD COLUMN start_hole smallint
|
||||||
|
CHECK (start_hole IS NULL OR start_hole BETWEEN 1 AND 18);
|
||||||
|
|
||||||
|
ALTER TABLE session
|
||||||
|
ADD COLUMN start_mode text NOT NULL DEFAULT 'consecutive'
|
||||||
|
CHECK (start_mode IN ('consecutive', 'shotgun'));
|
||||||
|
|
||||||
|
ALTER TABLE match
|
||||||
|
ADD COLUMN start_hole smallint
|
||||||
|
CHECK (start_hole IS NULL OR start_hole BETWEEN 1 AND 18);
|
||||||
|
|
@ -100,6 +100,12 @@ class TournamentRoundCreate(BaseModel):
|
||||||
scheduled_at: datetime | None = None
|
scheduled_at: datetime | None = None
|
||||||
tee_interval_minutes: int | None = Field(default=None, gt=0)
|
tee_interval_minutes: int | None = Field(default=None, gt=0)
|
||||||
start_hole: int = Field(default=1, ge=1, le=18)
|
start_hole: int = Field(default=1, ge=1, le=18)
|
||||||
|
# Shotgun-start (migrasjon 088, ADR-096) -- 'consecutive' (standard)
|
||||||
|
# er dagens oppførsel uendret: alle grupper deler DETTE feltet,
|
||||||
|
# staggert kun på klokkeslett. 'shotgun': alle grupper går ut
|
||||||
|
# SAMTIDIG, fra hvert sitt starthull (tournament_round_group.
|
||||||
|
# start_hole) i stedet -- se _compute_group_tee_time.
|
||||||
|
start_mode: str = Field(default="consecutive", pattern="^(consecutive|shotgun)$")
|
||||||
|
|
||||||
|
|
||||||
class TournamentRound(BaseModel):
|
class TournamentRound(BaseModel):
|
||||||
|
|
@ -113,12 +119,13 @@ class TournamentRound(BaseModel):
|
||||||
scheduled_at: str | None
|
scheduled_at: str | None
|
||||||
tee_interval_minutes: int | None
|
tee_interval_minutes: int | None
|
||||||
start_hole: int
|
start_hole: int
|
||||||
|
start_mode: str
|
||||||
|
|
||||||
|
|
||||||
_ROUND_COLUMNS = """
|
_ROUND_COLUMNS = """
|
||||||
tr.id::text, tr.tournament_id::text, tr.sequence, tr.name,
|
tr.id::text, tr.tournament_id::text, tr.sequence, tr.name,
|
||||||
tr.hole_config::text, tr.course_id::text, c.name AS course_name,
|
tr.hole_config::text, tr.course_id::text, c.name AS course_name,
|
||||||
tr.scheduled_at::text, tr.tee_interval_minutes, tr.start_hole
|
tr.scheduled_at::text, tr.tee_interval_minutes, tr.start_hole, tr.start_mode
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -162,8 +169,8 @@ async def create_round(
|
||||||
WITH inserted AS (
|
WITH inserted AS (
|
||||||
INSERT INTO tournament_round
|
INSERT INTO tournament_round
|
||||||
(organization_id, tournament_id, sequence, name, hole_config,
|
(organization_id, tournament_id, sequence, name, hole_config,
|
||||||
course_id, scheduled_at, tee_interval_minutes, start_hole)
|
course_id, scheduled_at, tee_interval_minutes, start_hole, start_mode)
|
||||||
VALUES ($1, $2, $3, $4, $5::hole_scope, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5::hole_scope, $6, $7, $8, $9, $10)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
)
|
)
|
||||||
SELECT {_ROUND_COLUMNS} FROM inserted tr JOIN course c ON c.id = tr.course_id
|
SELECT {_ROUND_COLUMNS} FROM inserted tr JOIN course c ON c.id = tr.course_id
|
||||||
|
|
@ -177,6 +184,7 @@ async def create_round(
|
||||||
body.scheduled_at,
|
body.scheduled_at,
|
||||||
body.tee_interval_minutes,
|
body.tee_interval_minutes,
|
||||||
body.start_hole,
|
body.start_hole,
|
||||||
|
body.start_mode,
|
||||||
)
|
)
|
||||||
return TournamentRound(**dict(row))
|
return TournamentRound(**dict(row))
|
||||||
|
|
||||||
|
|
@ -199,6 +207,7 @@ class TournamentRoundUpdate(BaseModel):
|
||||||
scheduled_at: datetime | None = None # se TournamentRoundCreate for hvorfor datetime, ikke str
|
scheduled_at: datetime | None = None # se TournamentRoundCreate for hvorfor datetime, ikke str
|
||||||
tee_interval_minutes: int | None = Field(default=None, gt=0)
|
tee_interval_minutes: int | None = Field(default=None, gt=0)
|
||||||
start_hole: int | None = Field(default=None, ge=1, le=18)
|
start_hole: int | None = Field(default=None, ge=1, le=18)
|
||||||
|
start_mode: str | None = Field(default=None, pattern="^(consecutive|shotgun)$")
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
|
|
@ -777,12 +786,21 @@ def _compute_group_tee_time(
|
||||||
tee_interval_minutes: int | None,
|
tee_interval_minutes: int | None,
|
||||||
sequence: int,
|
sequence: int,
|
||||||
override: datetime | None,
|
override: datetime | None,
|
||||||
|
start_mode: str = "consecutive",
|
||||||
) -> datetime | None:
|
) -> datetime | None:
|
||||||
"""Samme formel/filosofi som matches.py sin `_compute_tee_time` for
|
"""Samme formel/filosofi som matches.py sin `_compute_tee_time` for
|
||||||
lagturneringers økter -- utledet, ikke lagret, med en valgfri
|
lagturneringers økter -- utledet, ikke lagret, med en valgfri
|
||||||
override som vinner (f.eks. én gruppe forsinket)."""
|
override som vinner (f.eks. én gruppe forsinket).
|
||||||
|
|
||||||
|
Shotgun (migrasjon 088, ADR-096): ALLE grupper går ut SAMTIDIG --
|
||||||
|
intervallet er meningsløst i denne modusen (ulikt starthull, ikke
|
||||||
|
ulikt klokkeslett), så `sequence` ignoreres helt. Kun `scheduled_at`
|
||||||
|
(felles for hele runden) eller en eksplisitt per-gruppe override
|
||||||
|
gjelder."""
|
||||||
if override is not None:
|
if override is not None:
|
||||||
return override
|
return override
|
||||||
|
if start_mode == "shotgun":
|
||||||
|
return scheduled_at
|
||||||
if scheduled_at is None or tee_interval_minutes is None:
|
if scheduled_at is None or tee_interval_minutes is None:
|
||||||
return None
|
return None
|
||||||
return scheduled_at + timedelta(minutes=(sequence - 1) * tee_interval_minutes)
|
return scheduled_at + timedelta(minutes=(sequence - 1) * tee_interval_minutes)
|
||||||
|
|
@ -799,6 +817,10 @@ class RoundGroupOut(BaseModel):
|
||||||
sequence: int
|
sequence: int
|
||||||
tee_time: str | None # utledet (eller override), ISO-tekst
|
tee_time: str | None # utledet (eller override), ISO-tekst
|
||||||
tee_time_override: str | None
|
tee_time_override: str | None
|
||||||
|
# Kun meningsfullt når runden er 'shotgun' (migrasjon 088, ADR-096) --
|
||||||
|
# `None` for en 'consecutive'-runde, som fortsatt bruker rundens EGET
|
||||||
|
# delte start_hole (uendret siden migrasjon 040).
|
||||||
|
start_hole: int | None
|
||||||
participants: list[RoundGroupParticipant]
|
participants: list[RoundGroupParticipant]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -811,7 +833,8 @@ class RoundGroupsOut(BaseModel):
|
||||||
|
|
||||||
async def _round_scheduling(conn, tournament_id: str, round_id: str) -> asyncpg.Record:
|
async def _round_scheduling(conn, tournament_id: str, round_id: str) -> asyncpg.Record:
|
||||||
round_row = await conn.fetchrow(
|
round_row = await conn.fetchrow(
|
||||||
"SELECT scheduled_at, tee_interval_minutes FROM tournament_round WHERE id = $1 AND tournament_id = $2",
|
"SELECT scheduled_at, tee_interval_minutes, start_mode FROM tournament_round "
|
||||||
|
"WHERE id = $1 AND tournament_id = $2",
|
||||||
round_id,
|
round_id,
|
||||||
tournament_id,
|
tournament_id,
|
||||||
)
|
)
|
||||||
|
|
@ -822,7 +845,7 @@ async def _round_scheduling(conn, tournament_id: str, round_id: str) -> asyncpg.
|
||||||
|
|
||||||
async def _fetch_round_groups(conn, round_id: str, round_row) -> RoundGroupsOut:
|
async def _fetch_round_groups(conn, round_id: str, round_row) -> RoundGroupsOut:
|
||||||
group_rows = await conn.fetch(
|
group_rows = await conn.fetch(
|
||||||
"SELECT id::text, sequence, tee_time_override "
|
"SELECT id::text, sequence, tee_time_override, start_hole "
|
||||||
"FROM tournament_round_group WHERE tournament_round_id = $1 ORDER BY sequence",
|
"FROM tournament_round_group WHERE tournament_round_id = $1 ORDER BY sequence",
|
||||||
round_id,
|
round_id,
|
||||||
)
|
)
|
||||||
|
|
@ -850,7 +873,11 @@ async def _fetch_round_groups(conn, round_id: str, round_row) -> RoundGroupsOut:
|
||||||
groups = []
|
groups = []
|
||||||
for g in group_rows:
|
for g in group_rows:
|
||||||
tee_time = _compute_group_tee_time(
|
tee_time = _compute_group_tee_time(
|
||||||
round_row["scheduled_at"], round_row["tee_interval_minutes"], g["sequence"], g["tee_time_override"]
|
round_row["scheduled_at"],
|
||||||
|
round_row["tee_interval_minutes"],
|
||||||
|
g["sequence"],
|
||||||
|
g["tee_time_override"],
|
||||||
|
round_row["start_mode"],
|
||||||
)
|
)
|
||||||
groups.append(
|
groups.append(
|
||||||
RoundGroupOut(
|
RoundGroupOut(
|
||||||
|
|
@ -858,9 +885,18 @@ async def _fetch_round_groups(conn, round_id: str, round_row) -> RoundGroupsOut:
|
||||||
sequence=g["sequence"],
|
sequence=g["sequence"],
|
||||||
tee_time=tee_time.isoformat() if tee_time is not None else None,
|
tee_time=tee_time.isoformat() if tee_time is not None else None,
|
||||||
tee_time_override=g["tee_time_override"].isoformat() if g["tee_time_override"] else None,
|
tee_time_override=g["tee_time_override"].isoformat() if g["tee_time_override"] else None,
|
||||||
|
start_hole=g["start_hole"],
|
||||||
participants=by_group.get(g["id"], []),
|
participants=by_group.get(g["id"], []),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# Visningsrekkefølge (bruker, 2026-08-21): løpende start sorteres etter
|
||||||
|
# klokkeslett (allerede = sequence-rekkefølgen for 'consecutive', siden
|
||||||
|
# sequence 1 alltid er tidligst -- ingen endring der), shotgun sorteres
|
||||||
|
# etter starthull i stedet. Ikke tildelt starthull ennå (None) sorteres
|
||||||
|
# sist, ikke først -- en uferdig oppsatt gruppe skal ikke fortrenge
|
||||||
|
# ferdig plasserte grupper i visningen.
|
||||||
|
if round_row["start_mode"] == "shotgun":
|
||||||
|
groups.sort(key=lambda gr: (gr.start_hole is None, gr.start_hole))
|
||||||
return RoundGroupsOut(groups=groups, ungrouped=ungrouped)
|
return RoundGroupsOut(groups=groups, ungrouped=ungrouped)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -920,7 +956,7 @@ async def suggest_round_groups(
|
||||||
sequence = i // body.group_size + 1
|
sequence = i // body.group_size + 1
|
||||||
chunk = entries[i : i + body.group_size]
|
chunk = entries[i : i + body.group_size]
|
||||||
tee_time = _compute_group_tee_time(
|
tee_time = _compute_group_tee_time(
|
||||||
round_row["scheduled_at"], round_row["tee_interval_minutes"], sequence, None
|
round_row["scheduled_at"], round_row["tee_interval_minutes"], sequence, None, round_row["start_mode"]
|
||||||
)
|
)
|
||||||
groups.append(
|
groups.append(
|
||||||
RoundGroupOut(
|
RoundGroupOut(
|
||||||
|
|
@ -928,6 +964,12 @@ async def suggest_round_groups(
|
||||||
sequence=sequence,
|
sequence=sequence,
|
||||||
tee_time=tee_time.isoformat() if tee_time is not None else None,
|
tee_time=tee_time.isoformat() if tee_time is not None else None,
|
||||||
tee_time_override=None,
|
tee_time_override=None,
|
||||||
|
# Forslaget dekker KUN sammensetning (hvem spiller med
|
||||||
|
# hvem) -- starthull for en shotgun-runde er fortsatt et
|
||||||
|
# eget, manuelt valg organisator gjør etterpå, samme
|
||||||
|
# "forslag å justere fra, ikke helautomatisk"-prinsipp
|
||||||
|
# som selve gruppesammensetningen.
|
||||||
|
start_hole=None,
|
||||||
participants=chunk,
|
participants=chunk,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -937,6 +979,7 @@ async def suggest_round_groups(
|
||||||
class RoundGroupIn(BaseModel):
|
class RoundGroupIn(BaseModel):
|
||||||
sequence: int = Field(ge=1)
|
sequence: int = Field(ge=1)
|
||||||
tee_time_override: datetime | None = None
|
tee_time_override: datetime | None = None
|
||||||
|
start_hole: int | None = Field(default=None, ge=1, le=18)
|
||||||
round_participant_ids: list[str]
|
round_participant_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -978,14 +1021,15 @@ async def save_round_groups(
|
||||||
group_row = await conn.fetchrow(
|
group_row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
INSERT INTO tournament_round_group
|
INSERT INTO tournament_round_group
|
||||||
(organization_id, tournament_round_id, sequence, tee_time_override)
|
(organization_id, tournament_round_id, sequence, tee_time_override, start_hole)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
organization_id,
|
organization_id,
|
||||||
round_id,
|
round_id,
|
||||||
g.sequence,
|
g.sequence,
|
||||||
g.tee_time_override,
|
g.tee_time_override,
|
||||||
|
g.start_hole,
|
||||||
)
|
)
|
||||||
if g.round_participant_ids:
|
if g.round_participant_ids:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,15 @@ def _compute_tee_time(
|
||||||
tee_interval_minutes: int | None,
|
tee_interval_minutes: int | None,
|
||||||
sequence: int,
|
sequence: int,
|
||||||
override: datetime | None,
|
override: datetime | None,
|
||||||
|
start_mode: str = "consecutive",
|
||||||
) -> datetime | None:
|
) -> datetime | None:
|
||||||
|
"""Shotgun (migrasjon 088, ADR-096): ALLE matcher i økten går ut
|
||||||
|
SAMTIDIG -- intervallet/sequence er meningsløst (ulikt starthull,
|
||||||
|
ikke ulikt klokkeslett), se match.start_hole i stedet."""
|
||||||
if override is not None:
|
if override is not None:
|
||||||
return override
|
return override
|
||||||
|
if start_mode == "shotgun":
|
||||||
|
return scheduled_at
|
||||||
if scheduled_at is None or tee_interval_minutes is None:
|
if scheduled_at is None or tee_interval_minutes is None:
|
||||||
return None
|
return None
|
||||||
return scheduled_at + timedelta(minutes=(sequence - 1) * tee_interval_minutes)
|
return scheduled_at + timedelta(minutes=(sequence - 1) * tee_interval_minutes)
|
||||||
|
|
@ -64,6 +70,10 @@ class MatchCreate(BaseModel):
|
||||||
sequence: int
|
sequence: int
|
||||||
team_a_id: str
|
team_a_id: str
|
||||||
team_b_id: str
|
team_b_id: str
|
||||||
|
# Kun meningsfullt (og satt av organisator) når økten er 'shotgun' --
|
||||||
|
# se _compute_tee_time. `None` for en 'consecutive'-økt, som fortsatt
|
||||||
|
# bruker øktens EGET delte start_hole (uendret).
|
||||||
|
start_hole: int | None = Field(default=None, ge=1, le=18)
|
||||||
|
|
||||||
|
|
||||||
class MatchOut(BaseModel):
|
class MatchOut(BaseModel):
|
||||||
|
|
@ -76,6 +86,7 @@ class MatchOut(BaseModel):
|
||||||
points_side_b: float | None
|
points_side_b: float | None
|
||||||
leading_side: str | None
|
leading_side: str | None
|
||||||
tee_time: datetime | None
|
tee_time: datetime | None
|
||||||
|
start_hole: int | None
|
||||||
participants: list[MatchParticipantOut]
|
participants: list[MatchParticipantOut]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -92,7 +103,7 @@ async def create_match(
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
session = await conn.fetchrow(
|
session = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes
|
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes, start_mode
|
||||||
FROM session WHERE id = $1
|
FROM session WHERE id = $1
|
||||||
""",
|
""",
|
||||||
session_id,
|
session_id,
|
||||||
|
|
@ -113,20 +124,25 @@ async def create_match(
|
||||||
|
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
INSERT INTO match (organization_id, session_id, sequence, team_a_id, team_b_id)
|
INSERT INTO match (organization_id, session_id, sequence, team_a_id, team_b_id, start_hole)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id::text, sequence, team_a_id::text, team_b_id::text,
|
RETURNING id::text, sequence, team_a_id::text, team_b_id::text,
|
||||||
status_text, points_side_a::float AS points_side_a,
|
status_text, points_side_a::float AS points_side_a,
|
||||||
points_side_b::float AS points_side_b, leading_side, tee_time_override
|
points_side_b::float AS points_side_b, leading_side, tee_time_override, start_hole
|
||||||
""",
|
""",
|
||||||
organization_id,
|
organization_id,
|
||||||
session_id,
|
session_id,
|
||||||
body.sequence,
|
body.sequence,
|
||||||
body.team_a_id,
|
body.team_a_id,
|
||||||
body.team_b_id,
|
body.team_b_id,
|
||||||
|
body.start_hole,
|
||||||
)
|
)
|
||||||
tee_time = _compute_tee_time(
|
tee_time = _compute_tee_time(
|
||||||
session["scheduled_at"], session["tee_interval_minutes"], row["sequence"], row["tee_time_override"]
|
session["scheduled_at"],
|
||||||
|
session["tee_interval_minutes"],
|
||||||
|
row["sequence"],
|
||||||
|
row["tee_time_override"],
|
||||||
|
session["start_mode"],
|
||||||
)
|
)
|
||||||
return MatchOut(**dict(row), tee_time=tee_time, participants=[])
|
return MatchOut(**dict(row), tee_time=tee_time, participants=[])
|
||||||
|
|
||||||
|
|
@ -141,7 +157,7 @@ async def fetch_matches(
|
||||||
avslørte matcher, se blind_draw.py)."""
|
avslørte matcher, se blind_draw.py)."""
|
||||||
session = await conn.fetchrow(
|
session = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes
|
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes, start_mode
|
||||||
FROM session WHERE id = $1
|
FROM session WHERE id = $1
|
||||||
""",
|
""",
|
||||||
session_id,
|
session_id,
|
||||||
|
|
@ -153,7 +169,7 @@ async def fetch_matches(
|
||||||
"""
|
"""
|
||||||
SELECT id::text, sequence, team_a_id::text, team_b_id::text,
|
SELECT id::text, sequence, team_a_id::text, team_b_id::text,
|
||||||
status_text, points_side_a::float AS points_side_a,
|
status_text, points_side_a::float AS points_side_a,
|
||||||
points_side_b::float AS points_side_b, leading_side, tee_time_override
|
points_side_b::float AS points_side_b, leading_side, tee_time_override, start_hole
|
||||||
FROM match
|
FROM match
|
||||||
WHERE session_id = $1
|
WHERE session_id = $1
|
||||||
ORDER BY sequence
|
ORDER BY sequence
|
||||||
|
|
@ -203,7 +219,7 @@ async def fetch_matches(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
out = [
|
||||||
MatchOut(
|
MatchOut(
|
||||||
id=m["id"],
|
id=m["id"],
|
||||||
sequence=m["sequence"],
|
sequence=m["sequence"],
|
||||||
|
|
@ -214,12 +230,23 @@ async def fetch_matches(
|
||||||
points_side_b=m["points_side_b"],
|
points_side_b=m["points_side_b"],
|
||||||
leading_side=m["leading_side"],
|
leading_side=m["leading_side"],
|
||||||
tee_time=_compute_tee_time(
|
tee_time=_compute_tee_time(
|
||||||
session["scheduled_at"], session["tee_interval_minutes"], m["sequence"], m["tee_time_override"]
|
session["scheduled_at"],
|
||||||
|
session["tee_interval_minutes"],
|
||||||
|
m["sequence"],
|
||||||
|
m["tee_time_override"],
|
||||||
|
session["start_mode"],
|
||||||
),
|
),
|
||||||
|
start_hole=m["start_hole"],
|
||||||
participants=by_match[m["id"]],
|
participants=by_match[m["id"]],
|
||||||
)
|
)
|
||||||
for m in matches
|
for m in matches
|
||||||
]
|
]
|
||||||
|
# Visningsrekkefølge (bruker, 2026-08-21, ADR-096): løpende sorteres
|
||||||
|
# etter klokkeslett (allerede = sequence-rekkefølgen for 'consecutive'),
|
||||||
|
# shotgun sorteres etter starthull -- ikke tildelt (None) sist.
|
||||||
|
if session["start_mode"] == "shotgun":
|
||||||
|
out.sort(key=lambda mo: (mo.start_hole is None, mo.start_hole))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
|
||||||
|
|
@ -899,6 +899,12 @@ class SessionCreate(BaseModel):
|
||||||
scheduled_at: datetime | None = None
|
scheduled_at: datetime | None = None
|
||||||
tee_interval_minutes: int | None = None
|
tee_interval_minutes: int | None = None
|
||||||
start_hole: int = 1
|
start_hole: int = 1
|
||||||
|
# Shotgun-start (migrasjon 088, ADR-096) -- samme mønster som
|
||||||
|
# tournament_round for individuelle turneringer. 'consecutive'
|
||||||
|
# (standard) = uendret oppførsel. 'shotgun' = alle matcher i økten
|
||||||
|
# går ut SAMTIDIG, fra hvert sitt starthull (match.start_hole) i
|
||||||
|
# stedet for staggert klokkeslett.
|
||||||
|
start_mode: str = Field(default="consecutive", pattern="^(consecutive|shotgun)$")
|
||||||
# KUN meningsfullt (og påkrevd) når format='shamble' -- antall av
|
# KUN meningsfullt (og påkrevd) når format='shamble' -- antall av
|
||||||
# sidens individuelle resultater som teller per hull ("beste N av M").
|
# sidens individuelle resultater som teller per hull ("beste N av M").
|
||||||
shamble_best_n: int | None = Field(default=None, ge=1)
|
shamble_best_n: int | None = Field(default=None, ge=1)
|
||||||
|
|
@ -917,6 +923,7 @@ class SessionOut(BaseModel):
|
||||||
scheduled_at: datetime | None
|
scheduled_at: datetime | None
|
||||||
tee_interval_minutes: int | None
|
tee_interval_minutes: int | None
|
||||||
start_hole: int
|
start_hole: int
|
||||||
|
start_mode: str
|
||||||
shamble_best_n: int | None
|
shamble_best_n: int | None
|
||||||
locked_team_ids: list[str]
|
locked_team_ids: list[str]
|
||||||
revealed: bool
|
revealed: bool
|
||||||
|
|
@ -936,6 +943,7 @@ def _session_out(row, locked: set[str]) -> SessionOut:
|
||||||
scheduled_at=row["scheduled_at"],
|
scheduled_at=row["scheduled_at"],
|
||||||
tee_interval_minutes=row["tee_interval_minutes"],
|
tee_interval_minutes=row["tee_interval_minutes"],
|
||||||
start_hole=row["start_hole"],
|
start_hole=row["start_hole"],
|
||||||
|
start_mode=row["start_mode"],
|
||||||
shamble_best_n=row["shamble_best_n"],
|
shamble_best_n=row["shamble_best_n"],
|
||||||
locked_team_ids=sorted(locked),
|
locked_team_ids=sorted(locked),
|
||||||
revealed=len(locked) >= 2,
|
revealed=len(locked) >= 2,
|
||||||
|
|
@ -951,7 +959,7 @@ async def _fetch_sessions(conn, tournament_id: str) -> list[SessionOut]:
|
||||||
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
course_id::text, points_per_match::float AS points_per_match,
|
course_id::text, points_per_match::float AS points_per_match,
|
||||||
allowance_override::text AS allowance_override, scoring_mode,
|
allowance_override::text AS allowance_override, scoring_mode,
|
||||||
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
|
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
|
||||||
FROM session
|
FROM session
|
||||||
WHERE tournament_id = $1
|
WHERE tournament_id = $1
|
||||||
ORDER BY sequence
|
ORDER BY sequence
|
||||||
|
|
@ -1008,12 +1016,12 @@ async def create_session(
|
||||||
INSERT INTO session
|
INSERT INTO session
|
||||||
(organization_id, tournament_id, sequence, name, format,
|
(organization_id, tournament_id, sequence, name, format,
|
||||||
hole_config, course_id, points_per_match, allowance_override, scoring_mode,
|
hole_config, course_id, points_per_match, allowance_override, scoring_mode,
|
||||||
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n)
|
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13, $14)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13, $14, $15)
|
||||||
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
|
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
course_id::text, points_per_match::float AS points_per_match,
|
course_id::text, points_per_match::float AS points_per_match,
|
||||||
allowance_override::text AS allowance_override, scoring_mode,
|
allowance_override::text AS allowance_override, scoring_mode,
|
||||||
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
|
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
|
||||||
""",
|
""",
|
||||||
organization_id,
|
organization_id,
|
||||||
tournament_id,
|
tournament_id,
|
||||||
|
|
@ -1028,6 +1036,7 @@ async def create_session(
|
||||||
body.scheduled_at,
|
body.scheduled_at,
|
||||||
body.tee_interval_minutes,
|
body.tee_interval_minutes,
|
||||||
body.start_hole,
|
body.start_hole,
|
||||||
|
body.start_mode,
|
||||||
body.shamble_best_n,
|
body.shamble_best_n,
|
||||||
)
|
)
|
||||||
return _session_out(row, set())
|
return _session_out(row, set())
|
||||||
|
|
@ -1045,6 +1054,7 @@ class SessionUpdate(BaseModel):
|
||||||
scheduled_at: datetime | None = None
|
scheduled_at: datetime | None = None
|
||||||
tee_interval_minutes: int | None = None
|
tee_interval_minutes: int | None = None
|
||||||
start_hole: int | None = Field(default=None, ge=1, le=18)
|
start_hole: int | None = Field(default=None, ge=1, le=18)
|
||||||
|
start_mode: str | None = Field(default=None, pattern="^(consecutive|shotgun)$")
|
||||||
points_per_match: float | None = None
|
points_per_match: float | None = None
|
||||||
allowance_override: dict | None = None
|
allowance_override: dict | None = None
|
||||||
course_id: str | None = None
|
course_id: str | None = None
|
||||||
|
|
@ -1188,7 +1198,7 @@ async def update_session(
|
||||||
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
course_id::text, points_per_match::float AS points_per_match,
|
course_id::text, points_per_match::float AS points_per_match,
|
||||||
allowance_override::text AS allowance_override, scoring_mode,
|
allowance_override::text AS allowance_override, scoring_mode,
|
||||||
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
|
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
|
||||||
FROM session WHERE id = $1
|
FROM session WHERE id = $1
|
||||||
""",
|
""",
|
||||||
session_id,
|
session_id,
|
||||||
|
|
|
||||||
|
|
@ -162,3 +162,56 @@ async def test_group_tee_time_override_wins_over_computed(pool):
|
||||||
)
|
)
|
||||||
assert result.groups[0].tee_time is not None
|
assert result.groups[0].tee_time is not None
|
||||||
assert "09:15" in result.groups[0].tee_time # override, IKKE den utledede 10:00
|
assert "09:15" in result.groups[0].tee_time # override, IKKE den utledede 10:00
|
||||||
|
|
||||||
|
|
||||||
|
async def test_shotgun_mode_gives_all_groups_the_same_tee_time(pool):
|
||||||
|
"""Migrasjon 088, ADR-096 -- intervallet er meningsløst i shotgun,
|
||||||
|
alle grupper går ut SAMTIDIG uansett sequence."""
|
||||||
|
org_id = await create_org()
|
||||||
|
tournament_id, round_id, rp_ids = await _setup(org_id, n_players=4)
|
||||||
|
await update_round(
|
||||||
|
tournament_id, round_id,
|
||||||
|
TournamentRoundUpdate(
|
||||||
|
scheduled_at="2026-08-19T08:00:00", tee_interval_minutes=10, start_mode="shotgun",
|
||||||
|
),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await save_round_groups(
|
||||||
|
tournament_id, round_id,
|
||||||
|
SaveRoundGroupsIn(groups=[
|
||||||
|
RoundGroupIn(sequence=1, start_hole=5, round_participant_ids=[rp_ids[0], rp_ids[1]]),
|
||||||
|
RoundGroupIn(sequence=2, start_hole=1, round_participant_ids=[rp_ids[2], rp_ids[3]]),
|
||||||
|
]),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
# Begge grupper går ut kl 08:00, uavhengig av sequence/intervall.
|
||||||
|
for g in result.groups:
|
||||||
|
assert g.tee_time is not None and "08:00" in g.tee_time
|
||||||
|
|
||||||
|
# Vises sortert etter STARTHULL (1 før 5), ikke etter sequence (1 før 2).
|
||||||
|
assert [g.start_hole for g in result.groups] == [1, 5]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_consecutive_mode_still_sorts_by_sequence_not_start_hole(pool):
|
||||||
|
"""Regresjonsvern -- start_hole (uansett verdi, normalt None) skal
|
||||||
|
IKKE påvirke rekkefølgen når runden fortsatt er 'consecutive'."""
|
||||||
|
org_id = await create_org()
|
||||||
|
tournament_id, round_id, rp_ids = await _setup(org_id, n_players=4)
|
||||||
|
await update_round(
|
||||||
|
tournament_id, round_id,
|
||||||
|
TournamentRoundUpdate(scheduled_at="2026-08-19T08:00:00", tee_interval_minutes=10),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await save_round_groups(
|
||||||
|
tournament_id, round_id,
|
||||||
|
SaveRoundGroupsIn(groups=[
|
||||||
|
RoundGroupIn(sequence=1, round_participant_ids=[rp_ids[0], rp_ids[1]]),
|
||||||
|
RoundGroupIn(sequence=2, round_participant_ids=[rp_ids[2], rp_ids[3]]),
|
||||||
|
]),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
assert [g.sequence for g in result.groups] == [1, 2]
|
||||||
|
assert "08:00" in result.groups[0].tee_time
|
||||||
|
assert "08:10" in result.groups[1].tee_time
|
||||||
|
|
|
||||||
112
tests/test_shotgun_start_cup.py
Normal file
112
tests/test_shotgun_start_cup.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""
|
||||||
|
Shotgun-start for Cup-/lagformatet (migrasjon 088, ADR-096) -- samme
|
||||||
|
mønster som individuelle turneringers utslagsgrupper
|
||||||
|
(test_round_groups.py), men for session/match. Bruker: "Det må komme
|
||||||
|
frem om det er Shotgun eller løpende start... Sorteringen av
|
||||||
|
startlisten gjøres etter utslagsform."
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.routers.matches import MatchCreate, create_match, list_matches
|
||||||
|
from app.routers.tournaments import SessionUpdate, update_session
|
||||||
|
from app.auth import CurrentUser
|
||||||
|
|
||||||
|
import app.db as app_db
|
||||||
|
|
||||||
|
from tests.conftest import (
|
||||||
|
add_membership,
|
||||||
|
create_course,
|
||||||
|
create_org,
|
||||||
|
create_session,
|
||||||
|
create_team,
|
||||||
|
create_tournament,
|
||||||
|
create_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup(org_id):
|
||||||
|
owner_id = await create_user()
|
||||||
|
await add_membership(org_id, owner_id, role="owner")
|
||||||
|
tournament_id = await create_tournament(org_id, name="Cup-turnering")
|
||||||
|
course_id = await create_course(org_id, name="Cup Links")
|
||||||
|
session_id = await create_session(org_id, tournament_id, course_id, format="foursome")
|
||||||
|
team_a = await create_team(org_id, tournament_id, name="Team Nord")
|
||||||
|
team_b = await create_team(org_id, tournament_id, name="Team Sør")
|
||||||
|
return owner_id, tournament_id, session_id, team_a, team_b
|
||||||
|
|
||||||
|
|
||||||
|
async def test_shotgun_session_gives_all_matches_the_same_tee_time(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
owner_id, tournament_id, session_id, team_a, team_b = await _setup(org_id)
|
||||||
|
|
||||||
|
await update_session(
|
||||||
|
session_id,
|
||||||
|
SessionUpdate(scheduled_at="2026-08-19T08:00:00", tee_interval_minutes=10, start_mode="shotgun"),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
m1 = (await create_match(
|
||||||
|
session_id, MatchCreate(sequence=1, team_a_id=team_a, team_b_id=team_b, start_hole=5),
|
||||||
|
organization_id=org_id,
|
||||||
|
)).id
|
||||||
|
m2 = (await create_match(
|
||||||
|
session_id, MatchCreate(sequence=2, team_a_id=team_a, team_b_id=team_b, start_hole=1),
|
||||||
|
organization_id=org_id,
|
||||||
|
)).id
|
||||||
|
|
||||||
|
user = CurrentUser(user_id=owner_id)
|
||||||
|
matches = await list_matches(session_id, organization_id=org_id, user=user)
|
||||||
|
# Begge matcher går ut kl 08:00, uansett sequence/intervall.
|
||||||
|
for m in matches:
|
||||||
|
assert m.tee_time is not None and m.tee_time.isoformat().startswith("2026-08-19T08:00:00")
|
||||||
|
# Vist sortert etter STARTHULL (1 før 5), ikke sequence (m1 før m2).
|
||||||
|
assert [m.start_hole for m in matches] == [1, 5]
|
||||||
|
assert [m.id for m in matches] == [m2, m1]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_consecutive_session_still_sorts_by_sequence(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
owner_id, tournament_id, session_id, team_a, team_b = await _setup(org_id)
|
||||||
|
|
||||||
|
await update_session(
|
||||||
|
session_id,
|
||||||
|
SessionUpdate(scheduled_at="2026-08-19T08:00:00", tee_interval_minutes=10),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
m1 = (await create_match(
|
||||||
|
session_id, MatchCreate(sequence=1, team_a_id=team_a, team_b_id=team_b),
|
||||||
|
organization_id=org_id,
|
||||||
|
)).id
|
||||||
|
m2 = (await create_match(
|
||||||
|
session_id, MatchCreate(sequence=2, team_a_id=team_a, team_b_id=team_b),
|
||||||
|
organization_id=org_id,
|
||||||
|
)).id
|
||||||
|
|
||||||
|
user = CurrentUser(user_id=owner_id)
|
||||||
|
matches = await list_matches(session_id, organization_id=org_id, user=user)
|
||||||
|
assert [m.id for m in matches] == [m1, m2]
|
||||||
|
assert matches[0].tee_time.isoformat().startswith("2026-08-19T08:00:00")
|
||||||
|
assert matches[1].tee_time.isoformat().startswith("2026-08-19T08:10:00")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_match_tee_time_override_wins_even_in_shotgun_mode(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
owner_id, tournament_id, session_id, team_a, team_b = await _setup(org_id)
|
||||||
|
|
||||||
|
await update_session(
|
||||||
|
session_id,
|
||||||
|
SessionUpdate(scheduled_at="2026-08-19T08:00:00", start_mode="shotgun"),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
match_id = (await create_match(
|
||||||
|
session_id, MatchCreate(sequence=1, team_a_id=team_a, team_b_id=team_b, start_hole=9),
|
||||||
|
organization_id=org_id,
|
||||||
|
)).id
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE match SET tee_time_override = '2026-08-19T09:30:00' WHERE id = $1", match_id
|
||||||
|
)
|
||||||
|
|
||||||
|
user = CurrentUser(user_id=owner_id)
|
||||||
|
matches = await list_matches(session_id, organization_id=org_id, user=user)
|
||||||
|
assert matches[0].tee_time.isoformat().startswith("2026-08-19T09:30:00")
|
||||||
Loading…
Reference in a new issue