Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
"""
|
|
|
|
|
Individuelle/flerrunde-turneringer (ADR-037): grunnstruktur oppå
|
|
|
|
|
`040_individual_tournaments.sql`. Kun for `tournament.format_type =
|
|
|
|
|
'individual'` -- lagturneringer (`format_type = 'team'`, standard) er
|
|
|
|
|
uendret, bygget/vedlikeholdt i tournaments.py/matches.py/scoring.py.
|
|
|
|
|
|
|
|
|
|
Ett flatt felt av spillere (`tournament_participant`), over én eller flere
|
|
|
|
|
runder (`tournament_round`, samme rolle som `session` for lagturneringer).
|
|
|
|
|
Rå bruttoslag lagres per hull (`tournament_round_hole`, kilde-sannhet); et
|
|
|
|
|
ferdig utregnet resultat caches per deltaker per runde
|
|
|
|
|
(`tournament_round_score`) -- samme "regn på nytt ved hver innsending,
|
|
|
|
|
SQL summerer bare det cachede ved lesing"-mønster som `match.status_text`/
|
|
|
|
|
`points_side_a/b`. Sammenlagt over flere runder summeres VED LESING i
|
|
|
|
|
leaderboardet, ikke i en egen tredje cache-tabell (ADR-037 Beslutning C).
|
|
|
|
|
|
2026-07-30 09:36:52 +02:00
|
|
|
Autorisasjon (strammet inn 2026-07-30, se `user_is_own_tournament_
|
|
|
|
|
participant` i `app/team_authz.py`): selve SCORE-REGISTRERINGEN
|
|
|
|
|
(`update_hole`) krever nå at brukeren ER deltakeren (via `player.user_id`)
|
|
|
|
|
ELLER org-eier/admin -- samme mønster som `user_is_match_participant`
|
|
|
|
|
strammet inn lagturneringer i ADR-023. Runde-/deltaker-OPPSETT (opprett/
|
|
|
|
|
slett runde, legg til/fjern turnering-/rundedeltaker) forblir bevisst på
|
|
|
|
|
vanlig org-medlemsnivå (`get_authorized_org`) -- samme presedens som
|
|
|
|
|
`session`-opprettelse og `team_roster`-tilføyelse i tournaments.py, som
|
|
|
|
|
heller aldri har vært captain-/admin-gatet. Kun VALGET AV HVEM SOM SPILLER
|
|
|
|
|
og selve SCORINGEN er gatet strengere noe sted i appen, aldri det generelle
|
|
|
|
|
oppsettet.
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
|
All eight new turneringsformater have backend + engine complete and scratch-verified — Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, and High-low-high. Built and tested one format at a time (engine tests → migration → API wiring → scratch verification) exactly per the approved plan:
Verification totals: handicap_engine.py unit tests went from 63 → 98 (all passing), plus 400+ scratch-API checks across the eight formats' test scripts, covering both frittstående runder and the appropriate org-scoped system (team or individual) for each. test_isolation.sql stayed 12/12 throughout — migrations 041–050 are purely additive.
A few real things surfaced and were fixed during testing:
Nassau can be blocked by the existing "match already decided" lock (ADR-012) if the overall 18-hole match closes early — documented as a known v1 limitation, not fixed (would mean loosening an established safety guard).
Københavner's points depend on all 3 players at once, breaking the usual "recompute one participant" pattern — handled with a dedicated recompute path.
High-low-high doesn't fit the existing win/loss/halved match cache, so it gets its own dedicated read endpoint (like Nassau) — verified digit-for-digit against your own worked example ("1-1 etter hull 1", "2-1 til lag 2").
Two missing-column crashes (caught in scratch, never reached anything resembling "done").
2026-07-30 14:54:11 +02:00
|
|
|
Københavner (2026-07-30): scoring_method='copenhagen' -- flatt felt av
|
|
|
|
|
NØYAKTIG 3 spillere, 6 poeng delt per hull etter relativ NETTO-rangering
|
|
|
|
|
(handicap_engine.py sin compute_copenhagen_detail). Eneste scoring_method
|
|
|
|
|
her som krever paring PÅ TVERS av spillere per hull -- _recompute_
|
|
|
|
|
copenhagen_points regner derfor om for ALLE tre samtidig (kalt fra
|
|
|
|
|
update_hole), ikke ett-om-gangen slik gross/net/stableford ellers gjøres.
|
|
|
|
|
|
|
|
|
|
Bevisst UTENFOR omfang (se ADR-037): High-low-high/Robbins/Try all/
|
|
|
|
|
Flaggturnering (High-low-high/Robbins/Try all krever to SIDER, ikke et
|
|
|
|
|
flatt felt -- hører hjemme i org-lagturneringer i stedet, se matches.py/
|
|
|
|
|
scoring.py) og Order of Merit (sesong-sammenlagt på tvers av FLERE
|
|
|
|
|
turneringer).
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends
|
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
from handicap_engine import (
|
|
|
|
|
allocate_over_played_holes,
|
All eight new turneringsformater have backend + engine complete and scratch-verified — Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, and High-low-high. Built and tested one format at a time (engine tests → migration → API wiring → scratch verification) exactly per the approved plan:
Verification totals: handicap_engine.py unit tests went from 63 → 98 (all passing), plus 400+ scratch-API checks across the eight formats' test scripts, covering both frittstående runder and the appropriate org-scoped system (team or individual) for each. test_isolation.sql stayed 12/12 throughout — migrations 041–050 are purely additive.
A few real things surfaced and were fixed during testing:
Nassau can be blocked by the existing "match already decided" lock (ADR-012) if the overall 18-hole match closes early — documented as a known v1 limitation, not fixed (would mean loosening an established safety guard).
Københavner's points depend on all 3 players at once, breaking the usual "recompute one participant" pattern — handled with a dedicated recompute path.
High-low-high doesn't fit the existing win/loss/halved match cache, so it gets its own dedicated read endpoint (like Nassau) — verified digit-for-digit against your own worked example ("1-1 etter hull 1", "2-1 til lag 2").
Two missing-column crashes (caught in scratch, never reached anything resembling "done").
2026-07-30 14:54:11 +02:00
|
|
|
bbb_points_for_hole,
|
|
|
|
|
compute_bbb,
|
|
|
|
|
compute_copenhagen_detail,
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
course_handicap_raw,
|
All eight new turneringsformater have backend + engine complete and scratch-verified — Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, and High-low-high. Built and tested one format at a time (engine tests → migration → API wiring → scratch verification) exactly per the approved plan:
Verification totals: handicap_engine.py unit tests went from 63 → 98 (all passing), plus 400+ scratch-API checks across the eight formats' test scripts, covering both frittstående runder and the appropriate org-scoped system (team or individual) for each. test_isolation.sql stayed 12/12 throughout — migrations 041–050 are purely additive.
A few real things surfaced and were fixed during testing:
Nassau can be blocked by the existing "match already decided" lock (ADR-012) if the overall 18-hole match closes early — documented as a known v1 limitation, not fixed (would mean loosening an established safety guard).
Københavner's points depend on all 3 players at once, breaking the usual "recompute one participant" pattern — handled with a dedicated recompute path.
High-low-high doesn't fit the existing win/loss/halved match cache, so it gets its own dedicated read endpoint (like Nassau) — verified digit-for-digit against your own worked example ("1-1 etter hull 1", "2-1 til lag 2").
Two missing-column crashes (caught in scratch, never reached anything resembling "done").
2026-07-30 14:54:11 +02:00
|
|
|
flag_result,
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
round_half_up,
|
|
|
|
|
stableford_total,
|
|
|
|
|
stroke_play_gross_total,
|
|
|
|
|
stroke_play_net_total,
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-30 09:36:52 +02:00
|
|
|
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
from ..db import org_connection
|
|
|
|
|
from ..errors import app_error, translate_db_errors
|
2026-07-30 09:36:52 +02:00
|
|
|
from ..team_authz import user_is_own_tournament_participant
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
from .scoring import played_hole_numbers
|
|
|
|
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
All eight new turneringsformater have backend + engine complete and scratch-verified — Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, and High-low-high. Built and tested one format at a time (engine tests → migration → API wiring → scratch verification) exactly per the approved plan:
Verification totals: handicap_engine.py unit tests went from 63 → 98 (all passing), plus 400+ scratch-API checks across the eight formats' test scripts, covering both frittstående runder and the appropriate org-scoped system (team or individual) for each. test_isolation.sql stayed 12/12 throughout — migrations 041–050 are purely additive.
A few real things surfaced and were fixed during testing:
Nassau can be blocked by the existing "match already decided" lock (ADR-012) if the overall 18-hole match closes early — documented as a known v1 limitation, not fixed (would mean loosening an established safety guard).
Københavner's points depend on all 3 players at once, breaking the usual "recompute one participant" pattern — handled with a dedicated recompute path.
High-low-high doesn't fit the existing win/loss/halved match cache, so it gets its own dedicated read endpoint (like Nassau) — verified digit-for-digit against your own worked example ("1-1 etter hull 1", "2-1 til lag 2").
Two missing-column crashes (caught in scratch, never reached anything resembling "done").
2026-07-30 14:54:11 +02:00
|
|
|
_SCORING_METHODS_REQUIRING_HANDICAP = {"stroke_net", "stableford", "copenhagen", "flag"}
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# =====================================================================
|
|
|
|
|
# Runder (tournament_round)
|
|
|
|
|
# =====================================================================
|
|
|
|
|
|
|
|
|
|
class TournamentRoundCreate(BaseModel):
|
|
|
|
|
sequence: int = Field(ge=1)
|
|
|
|
|
name: str | None = None
|
|
|
|
|
hole_config: str = Field(default="full_18", pattern="^(full_18|front_9|back_9)$")
|
|
|
|
|
course_id: str
|
|
|
|
|
scheduled_at: str | None = None # ISO 8601, tolkes av asyncpg/Pydantic ved behov
|
|
|
|
|
tee_interval_minutes: int | None = Field(default=None, gt=0)
|
|
|
|
|
start_hole: int = Field(default=1, ge=1, le=18)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TournamentRound(BaseModel):
|
|
|
|
|
id: str
|
|
|
|
|
tournament_id: str
|
|
|
|
|
sequence: int
|
|
|
|
|
name: str | None
|
|
|
|
|
hole_config: str
|
|
|
|
|
course_id: str
|
|
|
|
|
course_name: str
|
|
|
|
|
scheduled_at: str | None
|
|
|
|
|
tee_interval_minutes: int | None
|
|
|
|
|
start_hole: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_ROUND_COLUMNS = """
|
|
|
|
|
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.scheduled_at::text, tr.tee_interval_minutes, tr.start_hole
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds",
|
|
|
|
|
response_model=TournamentRound,
|
|
|
|
|
status_code=201,
|
|
|
|
|
)
|
|
|
|
|
async def create_round(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
body: TournamentRoundCreate,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> TournamentRound:
|
|
|
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
|
|
|
tournament = await conn.fetchrow(
|
|
|
|
|
"SELECT format_type FROM tournament WHERE id = $1", tournament_id
|
|
|
|
|
)
|
|
|
|
|
if tournament is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
|
|
|
if tournament["format_type"] != "individual":
|
|
|
|
|
raise app_error(
|
|
|
|
|
400, "VALIDATION_FAILED", "Runder kan kun opprettes for individuelle turneringer."
|
|
|
|
|
)
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
f"""
|
|
|
|
|
WITH inserted AS (
|
|
|
|
|
INSERT INTO tournament_round
|
|
|
|
|
(organization_id, tournament_id, sequence, name, hole_config,
|
|
|
|
|
course_id, scheduled_at, tee_interval_minutes, start_hole)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5::hole_scope, $6, $7, $8, $9)
|
|
|
|
|
RETURNING *
|
|
|
|
|
)
|
|
|
|
|
SELECT {_ROUND_COLUMNS} FROM inserted tr JOIN course c ON c.id = tr.course_id
|
|
|
|
|
""",
|
|
|
|
|
organization_id,
|
|
|
|
|
tournament_id,
|
|
|
|
|
body.sequence,
|
|
|
|
|
body.name,
|
|
|
|
|
body.hole_config,
|
|
|
|
|
body.course_id,
|
|
|
|
|
body.scheduled_at,
|
|
|
|
|
body.tee_interval_minutes,
|
|
|
|
|
body.start_hole,
|
|
|
|
|
)
|
|
|
|
|
return TournamentRound(**dict(row))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds",
|
|
|
|
|
response_model=list[TournamentRound],
|
|
|
|
|
)
|
|
|
|
|
async def list_rounds(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> list[TournamentRound]:
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
|
|
|
|
|
if exists is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
|
|
|
rows = await conn.fetch(
|
|
|
|
|
f"""
|
|
|
|
|
SELECT {_ROUND_COLUMNS} FROM tournament_round tr
|
|
|
|
|
JOIN course c ON c.id = tr.course_id
|
|
|
|
|
WHERE tr.tournament_id = $1
|
|
|
|
|
ORDER BY tr.sequence
|
|
|
|
|
""",
|
|
|
|
|
tournament_id,
|
|
|
|
|
)
|
|
|
|
|
return [TournamentRound(**dict(r)) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}", status_code=204)
|
|
|
|
|
async def delete_round(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
round_id: str,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> None:
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
has_participants = await conn.fetchval(
|
|
|
|
|
"SELECT EXISTS(SELECT 1 FROM tournament_round_participant WHERE tournament_round_id = $1)",
|
|
|
|
|
round_id,
|
|
|
|
|
)
|
|
|
|
|
if has_participants:
|
|
|
|
|
raise app_error(
|
|
|
|
|
409,
|
|
|
|
|
"NOT_EMPTY",
|
|
|
|
|
"Runden har allerede deltakere -- fjern dem først, eller la runden stå.",
|
|
|
|
|
)
|
|
|
|
|
deleted = await conn.fetchval(
|
|
|
|
|
"DELETE FROM tournament_round WHERE id = $1 AND tournament_id = $2 RETURNING id",
|
|
|
|
|
round_id,
|
|
|
|
|
tournament_id,
|
|
|
|
|
)
|
|
|
|
|
if deleted is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =====================================================================
|
|
|
|
|
# Turnering-deltakere (tournament_participant -- flatt felt, intet lag)
|
|
|
|
|
# =====================================================================
|
|
|
|
|
|
|
|
|
|
class TournamentParticipantCreate(BaseModel):
|
|
|
|
|
player_id: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TournamentParticipantOut(BaseModel):
|
|
|
|
|
id: str
|
|
|
|
|
player_id: str
|
|
|
|
|
player_name: str
|
|
|
|
|
handicap_index_snapshot: float | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/participants",
|
|
|
|
|
response_model=TournamentParticipantOut,
|
|
|
|
|
status_code=201,
|
|
|
|
|
)
|
|
|
|
|
async def add_tournament_participant(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
body: TournamentParticipantCreate,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> TournamentParticipantOut:
|
|
|
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
|
|
|
player = await conn.fetchrow(
|
|
|
|
|
"SELECT display_name, handicap_index FROM player WHERE id = $1", body.player_id
|
|
|
|
|
)
|
|
|
|
|
if player is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Spilleren finnes ikke.")
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO tournament_participant (organization_id, tournament_id, player_id, handicap_index_snapshot)
|
|
|
|
|
VALUES ($1, $2, $3, $4)
|
|
|
|
|
RETURNING id::text
|
|
|
|
|
""",
|
|
|
|
|
organization_id,
|
|
|
|
|
tournament_id,
|
|
|
|
|
body.player_id,
|
|
|
|
|
player["handicap_index"],
|
|
|
|
|
)
|
|
|
|
|
return TournamentParticipantOut(
|
|
|
|
|
id=row["id"],
|
|
|
|
|
player_id=body.player_id,
|
|
|
|
|
player_name=player["display_name"],
|
|
|
|
|
handicap_index_snapshot=player["handicap_index"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/participants",
|
|
|
|
|
response_model=list[TournamentParticipantOut],
|
|
|
|
|
)
|
|
|
|
|
async def list_tournament_participants(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> list[TournamentParticipantOut]:
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
|
|
|
|
|
if exists is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
|
|
|
rows = await conn.fetch(
|
|
|
|
|
"""
|
|
|
|
|
SELECT tp.id::text AS id, tp.player_id::text AS player_id,
|
|
|
|
|
p.display_name AS player_name,
|
|
|
|
|
tp.handicap_index_snapshot::float AS handicap_index_snapshot
|
|
|
|
|
FROM tournament_participant tp
|
|
|
|
|
JOIN player p ON p.id = tp.player_id
|
|
|
|
|
WHERE tp.tournament_id = $1
|
|
|
|
|
ORDER BY p.display_name
|
|
|
|
|
""",
|
|
|
|
|
tournament_id,
|
|
|
|
|
)
|
|
|
|
|
return [TournamentParticipantOut(**dict(r)) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete(
|
|
|
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/participants/{participant_id}",
|
|
|
|
|
status_code=204,
|
|
|
|
|
)
|
|
|
|
|
async def remove_tournament_participant(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
participant_id: str,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> None:
|
|
|
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
|
|
|
deleted = await conn.fetchval(
|
|
|
|
|
"DELETE FROM tournament_participant WHERE id = $1 AND tournament_id = $2 RETURNING id",
|
|
|
|
|
participant_id,
|
|
|
|
|
tournament_id,
|
|
|
|
|
)
|
|
|
|
|
if deleted is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =====================================================================
|
|
|
|
|
# Deltaker PÅ ÉN RUNDE (tee + cachet handicap for akkurat den runden)
|
|
|
|
|
# =====================================================================
|
|
|
|
|
|
|
|
|
|
class RoundParticipantCreate(BaseModel):
|
|
|
|
|
tournament_participant_id: str
|
|
|
|
|
tee_id: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RoundParticipantOut(BaseModel):
|
|
|
|
|
id: str
|
|
|
|
|
tournament_participant_id: str
|
|
|
|
|
player_name: str
|
|
|
|
|
tee_id: str
|
|
|
|
|
tee_name: str
|
|
|
|
|
course_handicap: int | None
|
|
|
|
|
playing_handicap: int | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _compute_round_participant_handicap(conn, round_participant_id: str) -> None:
|
|
|
|
|
"""Regner course_handicap/playing_handicap for ÉN rundedeltaker.
|
|
|
|
|
|
|
|
|
|
v1 har ingen allowance-prosent for individuelle turneringer (ulikt
|
|
|
|
|
lagturneringenes AllowanceStrategy-familie, som er bygget for et
|
|
|
|
|
relativt to-siders oppgjør -- ikke relevant her) -- playing_handicap er
|
|
|
|
|
derfor alltid identisk med (avrundet) course_handicap. Setter INGENTING
|
|
|
|
|
hvis spilleren mangler handicap_index_snapshot eller en matchende
|
|
|
|
|
tee_rating -- kalleren har allerede validert dette FØR innsetting når
|
|
|
|
|
scoring_method krever det (se add_round_participant).
|
|
|
|
|
"""
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
SELECT tp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
|
|
|
|
rating.course_rating::float AS course_rating,
|
|
|
|
|
rating.slope_rating::float AS slope_rating,
|
|
|
|
|
rating.par AS par
|
|
|
|
|
FROM tournament_round_participant trp
|
|
|
|
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
|
|
|
|
JOIN player p ON p.id = tp.player_id
|
|
|
|
|
JOIN tee_rating rating ON rating.tee_id = trp.tee_id
|
|
|
|
|
AND rating.scope = 'full_18'
|
|
|
|
|
AND rating.gender = p.gender
|
|
|
|
|
WHERE trp.id = $1
|
|
|
|
|
""",
|
|
|
|
|
round_participant_id,
|
|
|
|
|
)
|
|
|
|
|
if row is None or row["handicap_index_snapshot"] is None:
|
|
|
|
|
return
|
|
|
|
|
ch = round_half_up(
|
|
|
|
|
course_handicap_raw(
|
|
|
|
|
row["handicap_index_snapshot"], row["slope_rating"], row["course_rating"], row["par"]
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
await conn.execute(
|
|
|
|
|
"UPDATE tournament_round_participant SET course_handicap = $1, playing_handicap = $1 WHERE id = $2",
|
|
|
|
|
ch,
|
|
|
|
|
round_participant_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants",
|
|
|
|
|
response_model=RoundParticipantOut,
|
|
|
|
|
status_code=201,
|
|
|
|
|
)
|
|
|
|
|
async def add_round_participant(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
round_id: str,
|
|
|
|
|
body: RoundParticipantCreate,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> RoundParticipantOut:
|
|
|
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
|
|
|
round_row = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
SELECT tr.course_id::text AS course_id, t.scoring_method
|
|
|
|
|
FROM tournament_round tr
|
|
|
|
|
JOIN tournament t ON t.id = tr.tournament_id
|
|
|
|
|
WHERE tr.id = $1 AND tr.tournament_id = $2
|
|
|
|
|
""",
|
|
|
|
|
round_id,
|
|
|
|
|
tournament_id,
|
|
|
|
|
)
|
|
|
|
|
if round_row is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
|
|
|
|
|
|
|
|
tee_course_id = await conn.fetchval("SELECT course_id::text FROM tee WHERE id = $1", body.tee_id)
|
|
|
|
|
if tee_course_id is None or tee_course_id != round_row["course_id"]:
|
|
|
|
|
raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke rundens bane.")
|
|
|
|
|
|
|
|
|
|
requires_handicap = round_row["scoring_method"] in _SCORING_METHODS_REQUIRING_HANDICAP
|
|
|
|
|
if requires_handicap:
|
|
|
|
|
player_row = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
SELECT tp.handicap_index_snapshot, p.gender
|
|
|
|
|
FROM tournament_participant tp
|
|
|
|
|
JOIN player p ON p.id = tp.player_id
|
|
|
|
|
WHERE tp.id = $1
|
|
|
|
|
""",
|
|
|
|
|
body.tournament_participant_id,
|
|
|
|
|
)
|
|
|
|
|
if player_row is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Turnering-deltakeren finnes ikke.")
|
|
|
|
|
if player_row["handicap_index_snapshot"] is None:
|
|
|
|
|
raise app_error(
|
|
|
|
|
400,
|
|
|
|
|
"VALIDATION_FAILED",
|
|
|
|
|
"Denne spilleren mangler handicap-indeks -- påkrevd for "
|
|
|
|
|
"nettoslagspill/Stableford.",
|
|
|
|
|
)
|
|
|
|
|
if player_row["gender"] is None:
|
|
|
|
|
raise app_error(
|
|
|
|
|
400,
|
|
|
|
|
"VALIDATION_FAILED",
|
|
|
|
|
"Denne spilleren mangler registrert kjønn -- trengs for å finne riktig tee-rating.",
|
|
|
|
|
)
|
|
|
|
|
has_rating = await conn.fetchval(
|
|
|
|
|
"SELECT EXISTS(SELECT 1 FROM tee_rating WHERE tee_id = $1 AND scope = 'full_18' AND gender = $2)",
|
|
|
|
|
body.tee_id,
|
|
|
|
|
player_row["gender"],
|
|
|
|
|
)
|
|
|
|
|
if not has_rating:
|
|
|
|
|
kjonn_tekst = "dame" if player_row["gender"] == "f" else "herre"
|
|
|
|
|
raise app_error(
|
|
|
|
|
400, "VALIDATION_FAILED", f"Dette utslaget har ingen {kjonn_tekst}-rating -- velg et annet."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
WITH inserted AS (
|
|
|
|
|
INSERT INTO tournament_round_participant
|
|
|
|
|
(organization_id, tournament_round_id, tournament_participant_id, tee_id)
|
|
|
|
|
VALUES ($1, $2, $3, $4)
|
|
|
|
|
RETURNING id
|
|
|
|
|
)
|
|
|
|
|
SELECT inserted.id::text AS id
|
|
|
|
|
FROM inserted
|
|
|
|
|
""",
|
|
|
|
|
organization_id,
|
|
|
|
|
round_id,
|
|
|
|
|
body.tournament_participant_id,
|
|
|
|
|
body.tee_id,
|
|
|
|
|
)
|
|
|
|
|
round_participant_id = row["id"]
|
|
|
|
|
await _compute_round_participant_handicap(conn, round_participant_id)
|
|
|
|
|
|
|
|
|
|
out = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
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,
|
|
|
|
|
trp.course_handicap, trp.playing_handicap
|
|
|
|
|
FROM tournament_round_participant trp
|
|
|
|
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
|
|
|
|
JOIN player p ON p.id = tp.player_id
|
|
|
|
|
JOIN tee ON tee.id = trp.tee_id
|
|
|
|
|
WHERE trp.id = $1
|
|
|
|
|
""",
|
|
|
|
|
round_participant_id,
|
|
|
|
|
)
|
|
|
|
|
return RoundParticipantOut(**dict(out))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants",
|
|
|
|
|
response_model=list[RoundParticipantOut],
|
|
|
|
|
)
|
|
|
|
|
async def list_round_participants(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
round_id: str,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> list[RoundParticipantOut]:
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
rows = await conn.fetch(
|
|
|
|
|
"""
|
|
|
|
|
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,
|
|
|
|
|
trp.course_handicap, trp.playing_handicap
|
|
|
|
|
FROM tournament_round_participant trp
|
|
|
|
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
|
|
|
|
JOIN player p ON p.id = tp.player_id
|
|
|
|
|
JOIN tee ON tee.id = trp.tee_id
|
|
|
|
|
WHERE trp.tournament_round_id = $1
|
|
|
|
|
ORDER BY p.display_name
|
|
|
|
|
""",
|
|
|
|
|
round_id,
|
|
|
|
|
)
|
|
|
|
|
return [RoundParticipantOut(**dict(r)) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete(
|
|
|
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants/{round_participant_id}",
|
|
|
|
|
status_code=204,
|
|
|
|
|
)
|
|
|
|
|
async def remove_round_participant(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
round_id: str,
|
|
|
|
|
round_participant_id: str,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> None:
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
deleted = await conn.fetchval(
|
|
|
|
|
"DELETE FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2 RETURNING id",
|
|
|
|
|
round_participant_id,
|
|
|
|
|
round_id,
|
|
|
|
|
)
|
|
|
|
|
if deleted is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =====================================================================
|
|
|
|
|
# Hull-for-hull-score (tournament_round_hole -- kilde-sannhet)
|
|
|
|
|
# =====================================================================
|
|
|
|
|
|
|
|
|
|
class HoleUpdate(BaseModel):
|
|
|
|
|
gross_strokes: int = Field(ge=1, le=20)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RoundHoleOut(BaseModel):
|
|
|
|
|
hole_number: int
|
|
|
|
|
par: int
|
|
|
|
|
stroke_index: int
|
|
|
|
|
gross_strokes: int | None
|
|
|
|
|
strokes_received: int | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _recompute_round_score(conn, round_id: str, round_participant_id: str, organization_id: str) -> None:
|
|
|
|
|
"""Regner ferdig totalsum (brutto/netto/Stableford) på nytt for ÉN
|
|
|
|
|
deltaker i ÉN runde, og cacher resultatet -- kalt etter HVER
|
|
|
|
|
hull-innsending (samme mønster som recompute_and_cache_match_state).
|
|
|
|
|
"""
|
|
|
|
|
ctx = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config,
|
|
|
|
|
trp.tournament_participant_id::text AS tournament_participant_id,
|
|
|
|
|
trp.playing_handicap
|
|
|
|
|
FROM tournament_round_participant trp
|
|
|
|
|
JOIN tournament_round tr ON tr.id = trp.tournament_round_id
|
|
|
|
|
WHERE trp.id = $1
|
|
|
|
|
""",
|
|
|
|
|
round_participant_id,
|
|
|
|
|
)
|
|
|
|
|
holes = await conn.fetch(
|
|
|
|
|
"""
|
|
|
|
|
SELECT h.hole_number, h.par, h.stroke_index, trh.gross_strokes
|
|
|
|
|
FROM hole h
|
|
|
|
|
LEFT JOIN tournament_round_hole trh
|
|
|
|
|
ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
|
|
|
|
|
WHERE h.course_id = $2
|
|
|
|
|
ORDER BY h.hole_number
|
|
|
|
|
""",
|
|
|
|
|
round_participant_id,
|
|
|
|
|
ctx["course_id"],
|
|
|
|
|
)
|
|
|
|
|
played = [h for h in holes if h["gross_strokes"] is not None]
|
|
|
|
|
holes_played = len(played)
|
|
|
|
|
gross_total = stroke_play_gross_total([h["gross_strokes"] for h in played]) if played else None
|
|
|
|
|
|
|
|
|
|
net_total = None
|
|
|
|
|
stableford_points = None
|
|
|
|
|
if played and ctx["playing_handicap"] is not None:
|
|
|
|
|
all_18_si = [h["stroke_index"] for h in holes]
|
|
|
|
|
played_numbers = [h["hole_number"] for h in played]
|
|
|
|
|
received = allocate_over_played_holes(ctx["playing_handicap"], all_18_si, played_numbers)
|
|
|
|
|
gross_list = [h["gross_strokes"] for h in played]
|
|
|
|
|
pars = [h["par"] for h in played]
|
|
|
|
|
net_total = stroke_play_net_total(gross_list, received)
|
|
|
|
|
stableford_points = stableford_total(pars, gross_list, received)
|
|
|
|
|
|
|
|
|
|
await conn.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO tournament_round_score
|
|
|
|
|
(organization_id, tournament_round_id, tournament_participant_id,
|
|
|
|
|
holes_played, gross_total, net_total, stableford_points, updated_at)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
|
|
|
|
ON CONFLICT (tournament_round_id, tournament_participant_id) DO UPDATE SET
|
|
|
|
|
holes_played = $4, gross_total = $5, net_total = $6,
|
|
|
|
|
stableford_points = $7, updated_at = now()
|
|
|
|
|
""",
|
|
|
|
|
organization_id,
|
|
|
|
|
round_id,
|
|
|
|
|
ctx["tournament_participant_id"],
|
|
|
|
|
holes_played,
|
|
|
|
|
gross_total,
|
|
|
|
|
net_total,
|
|
|
|
|
stableford_points,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
All eight new turneringsformater have backend + engine complete and scratch-verified — Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, and High-low-high. Built and tested one format at a time (engine tests → migration → API wiring → scratch verification) exactly per the approved plan:
Verification totals: handicap_engine.py unit tests went from 63 → 98 (all passing), plus 400+ scratch-API checks across the eight formats' test scripts, covering both frittstående runder and the appropriate org-scoped system (team or individual) for each. test_isolation.sql stayed 12/12 throughout — migrations 041–050 are purely additive.
A few real things surfaced and were fixed during testing:
Nassau can be blocked by the existing "match already decided" lock (ADR-012) if the overall 18-hole match closes early — documented as a known v1 limitation, not fixed (would mean loosening an established safety guard).
Københavner's points depend on all 3 players at once, breaking the usual "recompute one participant" pattern — handled with a dedicated recompute path.
High-low-high doesn't fit the existing win/loss/halved match cache, so it gets its own dedicated read endpoint (like Nassau) — verified digit-for-digit against your own worked example ("1-1 etter hull 1", "2-1 til lag 2").
Two missing-column crashes (caught in scratch, never reached anything resembling "done").
2026-07-30 14:54:11 +02:00
|
|
|
async def _recompute_copenhagen_points(conn, round_id: str, organization_id: str) -> None:
|
|
|
|
|
"""Københavner (2026-07-30): 6 poeng deles per hull mellom NØYAKTIG 3
|
|
|
|
|
deltakere basert på relativ NETTO-rangering -- kan derfor IKKE regnes
|
|
|
|
|
per deltaker isolert slik gross/net/stableford ellers gjøres (se
|
|
|
|
|
modulens docstring, "bevisst utenfor omfang" er nå innhentet for dette
|
|
|
|
|
ene formatet). Regner om for ALLE deltakerne i runden samtidig, kalt
|
|
|
|
|
etter HVER hull-innsending når scoring_method='copenhagen' -- ETTER at
|
|
|
|
|
_recompute_round_score allerede har kjørt for alle tre (sikrer at
|
|
|
|
|
tournament_round_score-raden finnes for alle, ikke bare den som nettopp
|
|
|
|
|
scoret)."""
|
|
|
|
|
participants = await conn.fetch(
|
|
|
|
|
"""
|
|
|
|
|
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
|
|
|
|
|
trp.playing_handicap
|
|
|
|
|
FROM tournament_round_participant trp
|
|
|
|
|
WHERE trp.tournament_round_id = $1
|
|
|
|
|
""",
|
|
|
|
|
round_id,
|
|
|
|
|
)
|
|
|
|
|
if len(participants) != 3:
|
|
|
|
|
return # Ikke komplett ennå -- copenhagen_points_for_hole krever nøyaktig 3
|
|
|
|
|
|
|
|
|
|
course_id = await conn.fetchval("SELECT course_id::text FROM tournament_round WHERE id = $1", round_id)
|
|
|
|
|
all_18 = await conn.fetch(
|
|
|
|
|
"SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", course_id
|
|
|
|
|
)
|
|
|
|
|
all_18_si = [h["stroke_index"] for h in all_18]
|
|
|
|
|
|
|
|
|
|
net_by_hole: dict[int, list[tuple[str, int]]] = {}
|
|
|
|
|
for p in participants:
|
|
|
|
|
if p["playing_handicap"] is None:
|
|
|
|
|
continue
|
|
|
|
|
holes = await conn.fetch(
|
|
|
|
|
"SELECT hole_number, gross_strokes FROM tournament_round_hole "
|
|
|
|
|
"WHERE tournament_round_participant_id = $1 AND gross_strokes IS NOT NULL "
|
|
|
|
|
"ORDER BY hole_number",
|
|
|
|
|
p["id"],
|
|
|
|
|
)
|
|
|
|
|
if not holes:
|
|
|
|
|
continue
|
|
|
|
|
played_numbers = [h["hole_number"] for h in holes]
|
|
|
|
|
allocation = allocate_over_played_holes(p["playing_handicap"], all_18_si, played_numbers)
|
|
|
|
|
for h, received in zip(holes, allocation):
|
|
|
|
|
net = h["gross_strokes"] - received
|
|
|
|
|
net_by_hole.setdefault(h["hole_number"], []).append((p["tournament_participant_id"], net))
|
|
|
|
|
|
|
|
|
|
scores_by_hole = [net_by_hole.get(h["hole_number"], []) for h in all_18]
|
|
|
|
|
totals, _log = compute_copenhagen_detail(scores_by_hole, higher_is_better=False)
|
|
|
|
|
|
|
|
|
|
for p in participants:
|
|
|
|
|
await conn.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO tournament_round_score
|
|
|
|
|
(organization_id, tournament_round_id, tournament_participant_id, copenhagen_points, updated_at)
|
|
|
|
|
VALUES ($1, $2, $3, $4, now())
|
|
|
|
|
ON CONFLICT (tournament_round_id, tournament_participant_id) DO UPDATE SET
|
|
|
|
|
copenhagen_points = $4, updated_at = now()
|
|
|
|
|
""",
|
|
|
|
|
organization_id,
|
|
|
|
|
round_id,
|
|
|
|
|
p["tournament_participant_id"],
|
|
|
|
|
totals.get(p["tournament_participant_id"]),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _recompute_bbb_points(conn, round_id: str, organization_id: str) -> None:
|
|
|
|
|
"""Bingo Bango Bongo (2026-07-30): poeng er en per-HULL-fakta på tvers
|
|
|
|
|
av ALLE deltakerne i runden, ikke per deltaker isolert -- regner om for
|
|
|
|
|
alle samtidig, kalt etter HVER bbb-hull-registrering. Krever INGEN
|
|
|
|
|
handicap (ulikt Københavner) -- rene observasjons-poeng."""
|
|
|
|
|
participants = await conn.fetch(
|
|
|
|
|
"SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id "
|
|
|
|
|
"FROM tournament_round_participant trp WHERE trp.tournament_round_id = $1",
|
|
|
|
|
round_id,
|
|
|
|
|
)
|
|
|
|
|
if not participants:
|
|
|
|
|
return
|
|
|
|
|
tournament = await conn.fetchrow(
|
|
|
|
|
"SELECT t.bbb_sweep_bonus_enabled FROM tournament_round tr "
|
|
|
|
|
"JOIN tournament t ON t.id = tr.tournament_id WHERE tr.id = $1",
|
|
|
|
|
round_id,
|
|
|
|
|
)
|
|
|
|
|
rows = await conn.fetch(
|
|
|
|
|
"""
|
|
|
|
|
SELECT hole_number, bingo_participant_id::text AS bingo_participant_id,
|
|
|
|
|
bango_participant_id::text AS bango_participant_id,
|
|
|
|
|
bongo_participant_id::text AS bongo_participant_id
|
|
|
|
|
FROM tournament_round_bbb_hole WHERE tournament_round_id = $1 ORDER BY hole_number
|
|
|
|
|
""",
|
|
|
|
|
round_id,
|
|
|
|
|
)
|
|
|
|
|
hole_log = [(r["bingo_participant_id"], r["bango_participant_id"], r["bongo_participant_id"]) for r in rows]
|
|
|
|
|
totals = compute_bbb(hole_log, tournament["bbb_sweep_bonus_enabled"])
|
|
|
|
|
|
|
|
|
|
for p in participants:
|
|
|
|
|
await conn.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO tournament_round_score
|
|
|
|
|
(organization_id, tournament_round_id, tournament_participant_id, bbb_points, updated_at)
|
|
|
|
|
VALUES ($1, $2, $3, $4, now())
|
|
|
|
|
ON CONFLICT (tournament_round_id, tournament_participant_id) DO UPDATE SET
|
|
|
|
|
bbb_points = $4, updated_at = now()
|
|
|
|
|
""",
|
|
|
|
|
organization_id,
|
|
|
|
|
round_id,
|
|
|
|
|
p["tournament_participant_id"],
|
|
|
|
|
totals.get(p["tournament_participant_id"]),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BBBHoleUpdate(BaseModel):
|
|
|
|
|
bingo_participant_id: str | None = None
|
|
|
|
|
bango_participant_id: str | None = None
|
|
|
|
|
bongo_participant_id: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BBBHoleOut(BaseModel):
|
|
|
|
|
hole_number: int
|
|
|
|
|
bingo_participant_id: str | None
|
|
|
|
|
bango_participant_id: str | None
|
|
|
|
|
bongo_participant_id: str | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch(
|
|
|
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/bbb/{hole_number}",
|
|
|
|
|
response_model=BBBHoleOut,
|
|
|
|
|
)
|
|
|
|
|
async def update_bbb_hole(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
round_id: str,
|
|
|
|
|
hole_number: int,
|
|
|
|
|
body: BBBHoleUpdate,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> BBBHoleOut:
|
|
|
|
|
"""Runde-/deltaker-oppsett-nivå autorisasjon (get_authorized_org, org-
|
|
|
|
|
medlem), samme presedens som resten av dette modulens oppsett -- ikke
|
|
|
|
|
strammet inn til "kun deltakeren selv" slik update_hole er, siden
|
|
|
|
|
bbb-registrering er en OBSERVASJON om alle deltakerne (hvem som var
|
|
|
|
|
først på green osv.), ikke én persons egen score."""
|
|
|
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
|
|
|
round_row = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
SELECT tr.hole_config::text AS hole_config, t.scoring_method, t.id::text AS tournament_id
|
|
|
|
|
FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
|
|
|
|
|
WHERE tr.id = $1
|
|
|
|
|
""",
|
|
|
|
|
round_id,
|
|
|
|
|
)
|
|
|
|
|
if round_row is None or round_row["tournament_id"] != tournament_id:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
|
|
|
if round_row["scoring_method"] != "bingo_bango_bongo":
|
|
|
|
|
raise app_error(400, "VALIDATION_FAILED", "Denne turneringen bruker ikke Bingo Bango Bongo.")
|
|
|
|
|
if hole_number not in played_hole_numbers(round_row["hole_config"]):
|
|
|
|
|
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor rundens hullomfang.")
|
|
|
|
|
|
|
|
|
|
for pid in (body.bingo_participant_id, body.bango_participant_id, body.bongo_participant_id):
|
|
|
|
|
if pid is None:
|
|
|
|
|
continue
|
|
|
|
|
belongs = await conn.fetchval(
|
|
|
|
|
"SELECT 1 FROM tournament_participant WHERE id = $1 AND tournament_id = $2",
|
|
|
|
|
pid,
|
|
|
|
|
tournament_id,
|
|
|
|
|
)
|
|
|
|
|
if not belongs:
|
|
|
|
|
raise app_error(400, "VALIDATION_FAILED", "Valgt spiller hører ikke til denne turneringen.")
|
|
|
|
|
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO tournament_round_bbb_hole
|
|
|
|
|
(organization_id, tournament_round_id, hole_number,
|
|
|
|
|
bingo_participant_id, bango_participant_id, bongo_participant_id)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
|
|
|
ON CONFLICT (tournament_round_id, hole_number) DO UPDATE SET
|
|
|
|
|
bingo_participant_id = $4, bango_participant_id = $5, bongo_participant_id = $6
|
|
|
|
|
RETURNING hole_number, bingo_participant_id::text AS bingo_participant_id,
|
|
|
|
|
bango_participant_id::text AS bango_participant_id,
|
|
|
|
|
bongo_participant_id::text AS bongo_participant_id
|
|
|
|
|
""",
|
|
|
|
|
organization_id,
|
|
|
|
|
round_id,
|
|
|
|
|
hole_number,
|
|
|
|
|
body.bingo_participant_id,
|
|
|
|
|
body.bango_participant_id,
|
|
|
|
|
body.bongo_participant_id,
|
|
|
|
|
)
|
|
|
|
|
await _recompute_bbb_points(conn, round_id, organization_id)
|
|
|
|
|
return BBBHoleOut(**dict(row))
|
|
|
|
|
|
|
|
|
|
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
@router.patch(
|
|
|
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
|
|
|
|
"/participants/{round_participant_id}/holes/{hole_number}",
|
|
|
|
|
response_model=RoundHoleOut,
|
|
|
|
|
)
|
|
|
|
|
async def update_hole(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
round_id: str,
|
|
|
|
|
round_participant_id: str,
|
|
|
|
|
hole_number: int,
|
|
|
|
|
body: HoleUpdate,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
2026-07-30 09:36:52 +02:00
|
|
|
user: CurrentUser = Depends(get_current_user),
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
) -> RoundHoleOut:
|
|
|
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
|
|
|
round_row = await conn.fetchrow(
|
All eight new turneringsformater have backend + engine complete and scratch-verified — Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, and High-low-high. Built and tested one format at a time (engine tests → migration → API wiring → scratch verification) exactly per the approved plan:
Verification totals: handicap_engine.py unit tests went from 63 → 98 (all passing), plus 400+ scratch-API checks across the eight formats' test scripts, covering both frittstående runder and the appropriate org-scoped system (team or individual) for each. test_isolation.sql stayed 12/12 throughout — migrations 041–050 are purely additive.
A few real things surfaced and were fixed during testing:
Nassau can be blocked by the existing "match already decided" lock (ADR-012) if the overall 18-hole match closes early — documented as a known v1 limitation, not fixed (would mean loosening an established safety guard).
Københavner's points depend on all 3 players at once, breaking the usual "recompute one participant" pattern — handled with a dedicated recompute path.
High-low-high doesn't fit the existing win/loss/halved match cache, so it gets its own dedicated read endpoint (like Nassau) — verified digit-for-digit against your own worked example ("1-1 etter hull 1", "2-1 til lag 2").
Two missing-column crashes (caught in scratch, never reached anything resembling "done").
2026-07-30 14:54:11 +02:00
|
|
|
"""
|
|
|
|
|
SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config, t.scoring_method
|
|
|
|
|
FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
|
|
|
|
|
WHERE tr.id = $1
|
|
|
|
|
""",
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
round_id,
|
|
|
|
|
)
|
|
|
|
|
if round_row is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
|
|
|
if hole_number not in played_hole_numbers(round_row["hole_config"]):
|
|
|
|
|
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor rundens hullomfang.")
|
|
|
|
|
|
2026-07-30 09:36:52 +02:00
|
|
|
rp = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
SELECT trp.id, trp.tournament_participant_id::text AS tournament_participant_id
|
|
|
|
|
FROM tournament_round_participant trp
|
|
|
|
|
WHERE trp.id = $1 AND trp.tournament_round_id = $2
|
|
|
|
|
""",
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
round_participant_id,
|
|
|
|
|
round_id,
|
|
|
|
|
)
|
|
|
|
|
if rp is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
|
|
|
|
|
|
2026-07-30 09:36:52 +02:00
|
|
|
if not await user_is_own_tournament_participant(
|
|
|
|
|
conn, organization_id, rp["tournament_participant_id"], user.user_id
|
|
|
|
|
):
|
|
|
|
|
raise app_error(
|
|
|
|
|
403,
|
|
|
|
|
"NOT_TOURNAMENT_PARTICIPANT",
|
|
|
|
|
"Du kan kun registrere score for deg selv (eller være organisasjonsadministrator).",
|
|
|
|
|
)
|
|
|
|
|
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
hole = await conn.fetchrow(
|
|
|
|
|
"SELECT par, stroke_index FROM hole WHERE course_id = $1 AND hole_number = $2",
|
|
|
|
|
round_row["course_id"],
|
|
|
|
|
hole_number,
|
|
|
|
|
)
|
|
|
|
|
if hole is None:
|
|
|
|
|
raise app_error(
|
|
|
|
|
400, "VALIDATION_FAILED", "Banen mangler hull-data for dette hullnummeret."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await conn.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO tournament_round_hole
|
|
|
|
|
(organization_id, tournament_round_participant_id, hole_number, gross_strokes, updated_at)
|
|
|
|
|
VALUES ($1, $2, $3, $4, now())
|
|
|
|
|
ON CONFLICT (tournament_round_participant_id, hole_number) DO UPDATE SET
|
|
|
|
|
gross_strokes = $4, updated_at = now()
|
|
|
|
|
""",
|
|
|
|
|
organization_id,
|
|
|
|
|
round_participant_id,
|
|
|
|
|
hole_number,
|
|
|
|
|
body.gross_strokes,
|
|
|
|
|
)
|
|
|
|
|
await _recompute_round_score(conn, round_id, round_participant_id, organization_id)
|
All eight new turneringsformater have backend + engine complete and scratch-verified — Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, and High-low-high. Built and tested one format at a time (engine tests → migration → API wiring → scratch verification) exactly per the approved plan:
Verification totals: handicap_engine.py unit tests went from 63 → 98 (all passing), plus 400+ scratch-API checks across the eight formats' test scripts, covering both frittstående runder and the appropriate org-scoped system (team or individual) for each. test_isolation.sql stayed 12/12 throughout — migrations 041–050 are purely additive.
A few real things surfaced and were fixed during testing:
Nassau can be blocked by the existing "match already decided" lock (ADR-012) if the overall 18-hole match closes early — documented as a known v1 limitation, not fixed (would mean loosening an established safety guard).
Københavner's points depend on all 3 players at once, breaking the usual "recompute one participant" pattern — handled with a dedicated recompute path.
High-low-high doesn't fit the existing win/loss/halved match cache, so it gets its own dedicated read endpoint (like Nassau) — verified digit-for-digit against your own worked example ("1-1 etter hull 1", "2-1 til lag 2").
Two missing-column crashes (caught in scratch, never reached anything resembling "done").
2026-07-30 14:54:11 +02:00
|
|
|
if round_row["scoring_method"] == "copenhagen":
|
|
|
|
|
# Kobenhavner-poeng avhenger av ALLE tre deltakernes netto på
|
|
|
|
|
# hvert hull -- regn om for de to andre ogsa (sikrer at deres
|
|
|
|
|
# tournament_round_score-rad finnes) FOR selve poeng-fordelingen.
|
|
|
|
|
other_ids = await conn.fetch(
|
|
|
|
|
"SELECT id::text AS id FROM tournament_round_participant "
|
|
|
|
|
"WHERE tournament_round_id = $1 AND id != $2",
|
|
|
|
|
round_id,
|
|
|
|
|
round_participant_id,
|
|
|
|
|
)
|
|
|
|
|
for other in other_ids:
|
|
|
|
|
await _recompute_round_score(conn, round_id, other["id"], organization_id)
|
|
|
|
|
await _recompute_copenhagen_points(conn, round_id, organization_id)
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
|
|
|
|
|
strokes_received = None
|
|
|
|
|
playing_handicap = await conn.fetchval(
|
|
|
|
|
"SELECT playing_handicap FROM tournament_round_participant WHERE id = $1", round_participant_id
|
|
|
|
|
)
|
|
|
|
|
if playing_handicap is not None:
|
|
|
|
|
all_18 = await conn.fetch(
|
|
|
|
|
"SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
|
|
|
|
|
round_row["course_id"],
|
|
|
|
|
)
|
|
|
|
|
allocation = allocate_over_played_holes(
|
|
|
|
|
playing_handicap, [h["stroke_index"] for h in all_18], [hole_number]
|
|
|
|
|
)
|
|
|
|
|
strokes_received = allocation[0]
|
|
|
|
|
|
|
|
|
|
return RoundHoleOut(
|
|
|
|
|
hole_number=hole_number,
|
|
|
|
|
par=hole["par"],
|
|
|
|
|
stroke_index=hole["stroke_index"],
|
|
|
|
|
gross_strokes=body.gross_strokes,
|
|
|
|
|
strokes_received=strokes_received,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
|
|
|
|
"/participants/{round_participant_id}/holes",
|
|
|
|
|
response_model=list[RoundHoleOut],
|
|
|
|
|
)
|
|
|
|
|
async def list_round_participant_holes(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
round_id: str,
|
|
|
|
|
round_participant_id: str,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> list[RoundHoleOut]:
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
round_row = await conn.fetchrow(
|
|
|
|
|
"SELECT course_id::text AS course_id FROM tournament_round WHERE id = $1", round_id
|
|
|
|
|
)
|
|
|
|
|
if round_row is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
|
|
|
playing_handicap = await conn.fetchval(
|
|
|
|
|
"SELECT playing_handicap FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2",
|
|
|
|
|
round_participant_id,
|
|
|
|
|
round_id,
|
|
|
|
|
)
|
|
|
|
|
holes = await conn.fetch(
|
|
|
|
|
"""
|
|
|
|
|
SELECT h.hole_number, h.par, h.stroke_index, trh.gross_strokes
|
|
|
|
|
FROM hole h
|
|
|
|
|
LEFT JOIN tournament_round_hole trh
|
|
|
|
|
ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
|
|
|
|
|
WHERE h.course_id = $2
|
|
|
|
|
ORDER BY h.hole_number
|
|
|
|
|
""",
|
|
|
|
|
round_participant_id,
|
|
|
|
|
round_row["course_id"],
|
|
|
|
|
)
|
|
|
|
|
allocation = (
|
|
|
|
|
allocate_over_played_holes(
|
|
|
|
|
playing_handicap, [h["stroke_index"] for h in holes], [h["hole_number"] for h in holes]
|
|
|
|
|
)
|
|
|
|
|
if playing_handicap is not None
|
|
|
|
|
else [None] * len(holes)
|
|
|
|
|
)
|
|
|
|
|
return [
|
|
|
|
|
RoundHoleOut(
|
|
|
|
|
hole_number=h["hole_number"],
|
|
|
|
|
par=h["par"],
|
|
|
|
|
stroke_index=h["stroke_index"],
|
|
|
|
|
gross_strokes=h["gross_strokes"],
|
|
|
|
|
strokes_received=a,
|
|
|
|
|
)
|
|
|
|
|
for h, a in zip(holes, allocation)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =====================================================================
|
|
|
|
|
# Leaderboard (summert VED LESING på tvers av alle runder, Beslutning C)
|
|
|
|
|
# =====================================================================
|
|
|
|
|
|
|
|
|
|
class LeaderboardEntry(BaseModel):
|
|
|
|
|
tournament_participant_id: str
|
|
|
|
|
player_name: str
|
|
|
|
|
rounds_played: int
|
|
|
|
|
gross_total: int | None
|
|
|
|
|
net_total: int | None
|
|
|
|
|
stableford_total: int | None
|
All eight new turneringsformater have backend + engine complete and scratch-verified — Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, and High-low-high. Built and tested one format at a time (engine tests → migration → API wiring → scratch verification) exactly per the approved plan:
Verification totals: handicap_engine.py unit tests went from 63 → 98 (all passing), plus 400+ scratch-API checks across the eight formats' test scripts, covering both frittstående runder and the appropriate org-scoped system (team or individual) for each. test_isolation.sql stayed 12/12 throughout — migrations 041–050 are purely additive.
A few real things surfaced and were fixed during testing:
Nassau can be blocked by the existing "match already decided" lock (ADR-012) if the overall 18-hole match closes early — documented as a known v1 limitation, not fixed (would mean loosening an established safety guard).
Københavner's points depend on all 3 players at once, breaking the usual "recompute one participant" pattern — handled with a dedicated recompute path.
High-low-high doesn't fit the existing win/loss/halved match cache, so it gets its own dedicated read endpoint (like Nassau) — verified digit-for-digit against your own worked example ("1-1 etter hull 1", "2-1 til lag 2").
Two missing-column crashes (caught in scratch, never reached anything resembling "done").
2026-07-30 14:54:11 +02:00
|
|
|
copenhagen_total: int | None
|
|
|
|
|
bbb_total: int | None
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
# IKKE /leaderboard -- den stien er allerede tournaments.py sitt
|
|
|
|
|
# LAG-leaderboard (points_side_a/b, forutsetter nøyaktig to lag). Samme
|
|
|
|
|
# klasse feil som /rounds vs. /my-rounds tidligere i prosjektet -- unngått
|
|
|
|
|
# her ved å gi den et eget, ikke-overlappende navn fra start.
|
|
|
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/individual-leaderboard",
|
|
|
|
|
response_model=list[LeaderboardEntry],
|
|
|
|
|
)
|
|
|
|
|
async def individual_leaderboard(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> list[LeaderboardEntry]:
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
tournament = await conn.fetchrow(
|
|
|
|
|
"SELECT format_type, scoring_method FROM tournament WHERE id = $1", tournament_id
|
|
|
|
|
)
|
|
|
|
|
if tournament is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
|
|
|
if tournament["format_type"] != "individual":
|
|
|
|
|
raise app_error(400, "VALIDATION_FAILED", "Kun individuelle turneringer har dette leaderboardet.")
|
|
|
|
|
|
|
|
|
|
rows = await conn.fetch(
|
|
|
|
|
"""
|
|
|
|
|
SELECT tp.id::text AS tournament_participant_id, p.display_name AS player_name,
|
|
|
|
|
count(trs.id) FILTER (WHERE trs.holes_played > 0) AS rounds_played,
|
|
|
|
|
SUM(trs.gross_total)::int AS gross_total,
|
|
|
|
|
SUM(trs.net_total)::int AS net_total,
|
All eight new turneringsformater have backend + engine complete and scratch-verified — Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, and High-low-high. Built and tested one format at a time (engine tests → migration → API wiring → scratch verification) exactly per the approved plan:
Verification totals: handicap_engine.py unit tests went from 63 → 98 (all passing), plus 400+ scratch-API checks across the eight formats' test scripts, covering both frittstående runder and the appropriate org-scoped system (team or individual) for each. test_isolation.sql stayed 12/12 throughout — migrations 041–050 are purely additive.
A few real things surfaced and were fixed during testing:
Nassau can be blocked by the existing "match already decided" lock (ADR-012) if the overall 18-hole match closes early — documented as a known v1 limitation, not fixed (would mean loosening an established safety guard).
Københavner's points depend on all 3 players at once, breaking the usual "recompute one participant" pattern — handled with a dedicated recompute path.
High-low-high doesn't fit the existing win/loss/halved match cache, so it gets its own dedicated read endpoint (like Nassau) — verified digit-for-digit against your own worked example ("1-1 etter hull 1", "2-1 til lag 2").
Two missing-column crashes (caught in scratch, never reached anything resembling "done").
2026-07-30 14:54:11 +02:00
|
|
|
SUM(trs.stableford_points)::int AS stableford_total,
|
|
|
|
|
SUM(trs.copenhagen_points)::int AS copenhagen_total,
|
|
|
|
|
SUM(trs.bbb_points)::int AS bbb_total
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
FROM tournament_participant tp
|
|
|
|
|
JOIN player p ON p.id = tp.player_id
|
|
|
|
|
LEFT JOIN tournament_round_score trs ON trs.tournament_participant_id = tp.id
|
|
|
|
|
WHERE tp.tournament_id = $1
|
|
|
|
|
GROUP BY tp.id, p.display_name
|
|
|
|
|
""",
|
|
|
|
|
tournament_id,
|
|
|
|
|
)
|
|
|
|
|
entries = [LeaderboardEntry(**dict(r)) for r in rows]
|
|
|
|
|
|
|
|
|
|
method = tournament["scoring_method"]
|
|
|
|
|
if method == "stableford":
|
|
|
|
|
entries.sort(key=lambda e: (e.stableford_total is None, -(e.stableford_total or 0)))
|
|
|
|
|
elif method == "stroke_net":
|
|
|
|
|
entries.sort(key=lambda e: (e.net_total is None, e.net_total or 0))
|
All eight new turneringsformater have backend + engine complete and scratch-verified — Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, and High-low-high. Built and tested one format at a time (engine tests → migration → API wiring → scratch verification) exactly per the approved plan:
Verification totals: handicap_engine.py unit tests went from 63 → 98 (all passing), plus 400+ scratch-API checks across the eight formats' test scripts, covering both frittstående runder and the appropriate org-scoped system (team or individual) for each. test_isolation.sql stayed 12/12 throughout — migrations 041–050 are purely additive.
A few real things surfaced and were fixed during testing:
Nassau can be blocked by the existing "match already decided" lock (ADR-012) if the overall 18-hole match closes early — documented as a known v1 limitation, not fixed (would mean loosening an established safety guard).
Københavner's points depend on all 3 players at once, breaking the usual "recompute one participant" pattern — handled with a dedicated recompute path.
High-low-high doesn't fit the existing win/loss/halved match cache, so it gets its own dedicated read endpoint (like Nassau) — verified digit-for-digit against your own worked example ("1-1 etter hull 1", "2-1 til lag 2").
Two missing-column crashes (caught in scratch, never reached anything resembling "done").
2026-07-30 14:54:11 +02:00
|
|
|
elif method == "copenhagen":
|
|
|
|
|
# Flest poeng totalt vinner (kilden, spilletyper-og-spilleformer-2023.pdf s.4).
|
|
|
|
|
entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0)))
|
|
|
|
|
elif method == "bingo_bango_bongo":
|
|
|
|
|
entries.sort(key=lambda e: (e.bbb_total is None, -(e.bbb_total or 0)))
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
else:
|
|
|
|
|
entries.sort(key=lambda e: (e.gross_total is None, e.gross_total or 0))
|
|
|
|
|
return entries
|
All eight new turneringsformater have backend + engine complete and scratch-verified — Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, and High-low-high. Built and tested one format at a time (engine tests → migration → API wiring → scratch verification) exactly per the approved plan:
Verification totals: handicap_engine.py unit tests went from 63 → 98 (all passing), plus 400+ scratch-API checks across the eight formats' test scripts, covering both frittstående runder and the appropriate org-scoped system (team or individual) for each. test_isolation.sql stayed 12/12 throughout — migrations 041–050 are purely additive.
A few real things surfaced and were fixed during testing:
Nassau can be blocked by the existing "match already decided" lock (ADR-012) if the overall 18-hole match closes early — documented as a known v1 limitation, not fixed (would mean loosening an established safety guard).
Københavner's points depend on all 3 players at once, breaking the usual "recompute one participant" pattern — handled with a dedicated recompute path.
High-low-high doesn't fit the existing win/loss/halved match cache, so it gets its own dedicated read endpoint (like Nassau) — verified digit-for-digit against your own worked example ("1-1 etter hull 1", "2-1 til lag 2").
Two missing-column crashes (caught in scratch, never reached anything resembling "done").
2026-07-30 14:54:11 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Flaggturnering/Flag tournament (2026-07-30) -- egen, PER-RUNDE visning
|
|
|
|
|
# (ikke summert på tvers av turneringen slik individual_leaderboard gjør
|
|
|
|
|
# for de andre scoring_method-ene) -- Flag er strukturelt en
|
|
|
|
|
# enkelt-rundes-konkurranse ("hvem kommer lengst DENNE runden"), ikke et
|
|
|
|
|
# akkumulerbart poeng-/slagtall på tvers av flere runder.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
class FlagParticipantResult(BaseModel):
|
|
|
|
|
tournament_participant_id: str
|
|
|
|
|
player_name: str
|
|
|
|
|
holes_completed: int
|
|
|
|
|
ran_out: bool
|
|
|
|
|
strokes_remaining: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/flag-result",
|
|
|
|
|
response_model=list[FlagParticipantResult],
|
|
|
|
|
)
|
|
|
|
|
async def flag_round_result(
|
|
|
|
|
tournament_id: str,
|
|
|
|
|
round_id: str,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> list[FlagParticipantResult]:
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
round_row = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
SELECT tr.id::text AS id, tr.hole_config::text AS hole_config, t.scoring_method
|
|
|
|
|
FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
|
|
|
|
|
WHERE tr.id = $1 AND tr.tournament_id = $2
|
|
|
|
|
""",
|
|
|
|
|
round_id,
|
|
|
|
|
tournament_id,
|
|
|
|
|
)
|
|
|
|
|
if round_row is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
|
|
|
if round_row["scoring_method"] != "flag":
|
|
|
|
|
raise app_error(400, "VALIDATION_FAILED", "Denne turneringen bruker ikke Flaggturnering.")
|
|
|
|
|
|
|
|
|
|
play_order = played_hole_numbers(round_row["hole_config"])
|
|
|
|
|
participants = await conn.fetch(
|
|
|
|
|
"""
|
|
|
|
|
SELECT trp.id::text AS id, trp.course_handicap, tp.id::text AS tournament_participant_id,
|
|
|
|
|
p.display_name AS player_name
|
|
|
|
|
FROM tournament_round_participant trp
|
|
|
|
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
|
|
|
|
JOIN player p ON p.id = tp.player_id
|
|
|
|
|
WHERE trp.tournament_round_id = $1
|
|
|
|
|
""",
|
|
|
|
|
round_id,
|
|
|
|
|
)
|
|
|
|
|
results: list[FlagParticipantResult] = []
|
|
|
|
|
for p in participants:
|
|
|
|
|
if p["course_handicap"] is None:
|
|
|
|
|
continue
|
|
|
|
|
hole_rows = await conn.fetch(
|
|
|
|
|
"""
|
|
|
|
|
SELECT h.hole_number, h.par, trh.gross_strokes
|
|
|
|
|
FROM hole h
|
|
|
|
|
LEFT JOIN tournament_round_hole trh
|
|
|
|
|
ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
|
|
|
|
|
WHERE h.course_id = (SELECT course_id FROM tournament_round WHERE id = $2)
|
|
|
|
|
""",
|
|
|
|
|
p["id"],
|
|
|
|
|
round_id,
|
|
|
|
|
)
|
|
|
|
|
by_hole = {r["hole_number"]: r for r in hole_rows}
|
|
|
|
|
total_par = sum(by_hole[h]["par"] for h in play_order if h in by_hole)
|
|
|
|
|
gross_prefix = []
|
|
|
|
|
for h in play_order:
|
|
|
|
|
row = by_hole.get(h)
|
|
|
|
|
if row is None or row["gross_strokes"] is None:
|
|
|
|
|
break
|
|
|
|
|
gross_prefix.append(row["gross_strokes"])
|
|
|
|
|
budget = total_par + p["course_handicap"]
|
|
|
|
|
fr = flag_result(gross_prefix, budget)
|
|
|
|
|
results.append(
|
|
|
|
|
FlagParticipantResult(
|
|
|
|
|
tournament_participant_id=p["tournament_participant_id"],
|
|
|
|
|
player_name=p["player_name"],
|
|
|
|
|
holes_completed=fr.holes_completed,
|
|
|
|
|
ran_out=fr.ran_out,
|
|
|
|
|
strokes_remaining=fr.strokes_remaining,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
results.sort(key=lambda r: -r.holes_completed)
|
|
|
|
|
return results
|