Order of Merit: eclectic-aggregering på tvers av lenkede turneringer (ADR-075)
Sesong-"drømmerunde" -- beste resultat per hull på tvers av ALLE lenkede turneringer (stableford/brutto/netto), med samme-bane-håndheving ved lenking og modus-bytte. Gjenbruker eclectic_best_per_hole() uendret fra handicap_engine.py. Frontend: "Eclectic" lagt til i både opprettelses- skjema og innstillinger, gated til gyldige kind/resultattype-kombinasjoner. 80/80 backend-tester, 45/45 vitest, scratch-verifisert i nettleser (lys+mørk). Rullet ut. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
5d502f305a
commit
fe13a82009
6 changed files with 571 additions and 32 deletions
|
|
@ -7400,6 +7400,111 @@ bruker. Begge containere startet rent.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-075: Order of Merit -- eclectic-aggregering på tvers av lenkede turneringer ("Steg 2" av gjenstår-listen) (2026-08-15)
|
||||||
|
|
||||||
|
Foranledning: andre av de tre gjenstående OOM-punktene fra ADR-043, se
|
||||||
|
ADR-074 sin innledning for hele bakgrunnen/bekreftelsen. Eclectic-
|
||||||
|
aggregering ("sesong-drømmerunde": beste resultat PER HULL på tvers av
|
||||||
|
ALLE lenkede turneringer, kun `result_type IN ('stableford','gross',
|
||||||
|
'net')`) var forberedt i skjemaet (`aggregation_mode='eclectic'` fantes
|
||||||
|
som verdi) men eksplisitt avvist i `order_of_merit_leaderboard` ("ikke
|
||||||
|
støttet ennå").
|
||||||
|
|
||||||
|
**Samme-bane-håndheving** (app-laget, ingen migrasjon): eclectic-
|
||||||
|
aggregering på tvers av turneringer er meningsløs hvis turneringene ikke
|
||||||
|
er spilt på samme bane (ulike hull-oppsett/par/stroke index). Håndhevet
|
||||||
|
på TO steder, samme "fail loudly"-filosofi som ADR-068/ADR-019
|
||||||
|
Beslutning C:
|
||||||
|
- `link_tournament_to_order_of_merit`: avviser lenking av en turnering
|
||||||
|
hvis OOM-en allerede er `aggregation_mode='eclectic'` OG den nye
|
||||||
|
turneringens bane avviker fra en allerede lenket turnerings bane.
|
||||||
|
- `update_order_of_merit`: avviser å endre `aggregation_mode` TIL
|
||||||
|
`'eclectic'` hvis turneringer med ulik bane allerede er lenket.
|
||||||
|
|
||||||
|
Ny `_validate_same_course_for_eclectic()` + `_course_id_for_tournament()`
|
||||||
|
(samme `LIMIT 1`-antakelse på `tournament_round.course_id` som
|
||||||
|
`_attach_eclectic_totals` allerede gjorde -- én bane per turnering,
|
||||||
|
udiskutert forutsetning i hele kodebasen).
|
||||||
|
|
||||||
|
**Datainnsamling** (ny `_compute_eclectic_oom_values`, gjenbruker
|
||||||
|
`eclectic_best_per_hole()` fra `handicap_engine.py` HELT UENDRET --
|
||||||
|
motoren viste seg, som antatt i planen, kildeuavhengig nok til å
|
||||||
|
fungere identisk for "flere runder i én turnering" (ADR-068) og "flere
|
||||||
|
turneringer i én OOM" (dette steget), kun datainnsamlingen foran den er
|
||||||
|
forskjellig): tilpasset variant av `_attach_eclectic_totals` sin
|
||||||
|
`tournament_round_hole`-spørring, filtrert på `tr.tournament_id =
|
||||||
|
ANY($linked_ids)` i stedet for én turnering. Nøkkelforskjell fra
|
||||||
|
innad-i-turnering-varianten: keyed på `player_id` (ikke
|
||||||
|
`tournament_participant_id`, som er ulik per turnering for samme
|
||||||
|
spiller), og kilde-indeksen er sammensatt av (turnering-id, runde-
|
||||||
|
sekvens) siden `sequence` kun er unik INNENFOR én turnering. Returnerer
|
||||||
|
også `tournament_count_by_player` -- antall DISTINKTE turneringer som
|
||||||
|
faktisk bidro minst ett hull til spillerens sluttsum, brukt som
|
||||||
|
`results_available` for `min_results_required`-sjekken (rå `len(values)`
|
||||||
|
ville alltid vært 1 i eclectic-modus, meningsløst som eligibility-mål).
|
||||||
|
|
||||||
|
`_player_eligibility` sin signatur endret fra å ta `raw_values: list`
|
||||||
|
til å ta `results_available: int` direkte -- frikobler "hvor mange
|
||||||
|
resultater talte" fra "de faktiske verdiene", nødvendig fordi eclectic-
|
||||||
|
modusens tall ikke lenger kommer fra `len(raw_values)`.
|
||||||
|
|
||||||
|
**Leaderboard**: `order_of_merit_leaderboard` sin tidligere eksplisitte
|
||||||
|
avvisning for `aggregation_mode == 'eclectic'` erstattet med faktisk
|
||||||
|
beregning -- kun for `kind='player'` (kind='team' + eclectic er allerede
|
||||||
|
sperret ved opprettelse, uendret fra ADR-043).
|
||||||
|
|
||||||
|
**Frontend**: "Eclectic"-valget lagt til begge steder
|
||||||
|
`aggregation_mode` settes, gated identisk til (og speilende) server-
|
||||||
|
sidens regel (`kind==='player' && result_type IN
|
||||||
|
('stableford','gross','net')`) slik at UI-et aldri tilbyr et valg
|
||||||
|
serveren uansett ville avvist:
|
||||||
|
- `order-of-merit-list.tsx` (opprettelsesskjema): ny `eclecticAllowed()`-
|
||||||
|
helper, tredje `SegmentButton` vist betinget, PLUSS auto-reset til
|
||||||
|
`'sum'` hvis brukeren endrer `kind`/`resultType` ETTER å ha valgt
|
||||||
|
Eclectic slik at den ikke lenger er gyldig (kind/resultType er fritt
|
||||||
|
redigerbare før opprettelse, i motsetning til detail-siden under).
|
||||||
|
- `order-of-merit-detail.tsx` (innstillinger, eksisterende OOM): samme
|
||||||
|
`eclecticAllowed()`-mønster, men INGEN reset-logikk trengs -- `kind`/
|
||||||
|
`result_type` er låst etter opprettelse her (vist eksplisitt i UI-en,
|
||||||
|
se ADR-074), så gatingen er ren visning av et fast faktum. Hjelpetekst
|
||||||
|
lagt til under Eclectic-valget som forklarer samme-bane-kravet.
|
||||||
|
|
||||||
|
**Verifisert:** `python3 -m py_compile` + full
|
||||||
|
`./scripts/run_backend_tests.sh` (80/80 -- 3 nye tester i
|
||||||
|
`test_order_of_merit.py`: cross-tournament eclectic velger faktisk
|
||||||
|
beste-per-hull på TVERS av turneringer, ikke bare beste turnering eller
|
||||||
|
snitt (hånd-utregnet: par 1-9 fra turnering A + par 10-18 fra turnering
|
||||||
|
B = 72, `results_available == 2`), avvisning ved lenking av turnering på
|
||||||
|
annen bane, avvisning ved modus-bytte til eclectic med allerede ulike
|
||||||
|
baner lenket). `tsc --noEmit` rent + 45/45 vitest.
|
||||||
|
|
||||||
|
Egen scratch-database + scratch `teecup_api`-container (port 18102,
|
||||||
|
live-montert kode, `TEECUP_DEV_LOG_MAGIC_LINKS=true` for å logge inn
|
||||||
|
uten SMTP) + lokal `next dev` (port 13102): seedet to turneringer på
|
||||||
|
samme bane (par hull 1-9/bogey 10-18 i A, omvendt i B) + én tredje
|
||||||
|
turnering på en ANNEN bane, opprettet en eclectic-OOM, lenket A+B --
|
||||||
|
leaderboard viste 72 (bekreftet også direkte mot API før nettleser-
|
||||||
|
verifisering). Innstillinger-seksjonen viste "Eclectic" korrekt valgt
|
||||||
|
med hjelpeteksten synlig. Forsøk på å lenke turnering C (annen bane) ga
|
||||||
|
en tydelig, lesbar feilmelding i UI-et: "Eclectic-aggregering krever at
|
||||||
|
alle lenkede turneringer spilles på samme bane." Opprettelsesskjemaets
|
||||||
|
gating verifisert direkte i nettleseren: Eclectic-knappen dukker opp for
|
||||||
|
`Spiller`+`Stableford-sum`/`Bruttoscore-sum`/`Nettoscore-sum`, forsvinner
|
||||||
|
og aggregering nullstilles automatisk til `Sum` når resultattype endres
|
||||||
|
til `Poeng etter plassering` ELLER type endres til `Lag`. Lys+mørk
|
||||||
|
bekreftet (skjermbilde av Innstillinger-seksjonen i mørk modus). Scratch-
|
||||||
|
stacken fullstendig revet ned (database, rolle, container, MinIO-bøtte,
|
||||||
|
`next dev`) -- ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt.
|
||||||
|
|
||||||
|
**Ingen migrasjon** -- ren kode-endring, `aggregation_mode='eclectic'`
|
||||||
|
fantes som gyldig verdi fra migrasjon 055.
|
||||||
|
|
||||||
|
**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
|
||||||
|
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
|
||||||
|
bruker. Begge containere startet rent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Utviklingsplan (rekkefølge)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
||||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||||
|
|
|
||||||
47
CHANGELOG.md
47
CHANGELOG.md
|
|
@ -11572,3 +11572,50 @@ Neste steg:
|
||||||
**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
|
**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
|
||||||
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
|
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
|
||||||
bruker. Begge containere startet rent.
|
bruker. Begge containere startet rent.
|
||||||
|
|
||||||
|
91. **Order of Merit: eclectic-aggregering på tvers av lenkede
|
||||||
|
turneringer (ADR-075) — 2026-08-15.** Tredje av de gjenstående OOM-
|
||||||
|
punktene fra ADR-043/074 -- sesong-"drømmerunde": beste resultat PER
|
||||||
|
HULL på tvers av ALLE lenkede turneringer (kun `result_type IN
|
||||||
|
('stableford','gross','net')`), tidligere eksplisitt avvist i
|
||||||
|
leaderboard-endepunktet.
|
||||||
|
|
||||||
|
Samme-bane-håndheving lagt til (ny, ingen migrasjon): en eclectic-
|
||||||
|
OOM kan ikke lenke en turnering på en annen bane enn de allerede
|
||||||
|
lenkede, verken ved lenking eller ved å bytte `aggregation_mode` til
|
||||||
|
eclectic med ulike baner allerede lenket. Datainnsamlingen (ny
|
||||||
|
`_compute_eclectic_oom_values`) gjenbruker `eclectic_best_per_hole()`
|
||||||
|
fra `handicap_engine.py` HELT UENDRET -- samme motor som ADR-068
|
||||||
|
(flere runder i én turnering), kun datainnsamlingen foran er
|
||||||
|
tilpasset til å samle på tvers av turneringer i stedet, keyed på
|
||||||
|
`player_id` med en sammensatt (turnering-id, runde-sekvens)-kilde-
|
||||||
|
indeks. Frontend: "Eclectic" lagt til som tredje aggregeringsvalg
|
||||||
|
både i opprettelsesskjemaet (`order-of-merit-list.tsx`, med auto-
|
||||||
|
reset til Sum hvis kind/resultattype endres til noe eclectic ikke
|
||||||
|
støtter) og innstillinger for eksisterende OOM-er (`order-of-merit-
|
||||||
|
detail.tsx`, ingen reset nødvendig der siden kind/resultattype er
|
||||||
|
låst etter opprettelse) -- begge gated identisk til (og speilende)
|
||||||
|
serverens regel.
|
||||||
|
|
||||||
|
**Verifisert:** full `./scripts/run_backend_tests.sh` (80/80 -- 3 nye
|
||||||
|
tester: cross-tournament eclectic velger faktisk beste-per-hull på
|
||||||
|
TVERS av turneringer med hånd-utregnet forventet verdi, avvisning
|
||||||
|
ved lenking på annen bane, avvisning ved modus-bytte med ulike baner
|
||||||
|
allerede lenket). `tsc --noEmit` rent + 45/45 vitest. Scratch-
|
||||||
|
database + scratch `teecup_api` (port 18102, live-montert kode,
|
||||||
|
`TEECUP_DEV_LOG_MAGIC_LINKS=true` for innlogging uten SMTP) + lokal
|
||||||
|
`next dev` (port 13102): opprettet ekte eclectic-OOM i nettleseren,
|
||||||
|
lenket to turneringer på samme bane -- leaderboard viste korrekt
|
||||||
|
kryss-turnering-tall (72). Forsøk på å lenke en turnering på en annen
|
||||||
|
bane ga en tydelig feilmelding i UI-et. Opprettelsesskjemaets gating
|
||||||
|
verifisert live (Eclectic dukker opp/forsvinner + nullstiller
|
||||||
|
korrekt ved endring av type/resultattype). Lys+mørk bekreftet.
|
||||||
|
Scratch-stacken fullstendig revet ned (database, rolle, container,
|
||||||
|
MinIO-bøtte, `next dev`) -- ekte `teecup_db`/`teecup_api`/
|
||||||
|
`teecup_frontend` urørt.
|
||||||
|
|
||||||
|
**Ingen migrasjon.**
|
||||||
|
|
||||||
|
**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
|
||||||
|
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
|
||||||
|
bruker. Begge containere startet rent.
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,14 @@ from pydantic import BaseModel, Field
|
||||||
from ..auth import get_authorized_org
|
from ..auth import get_authorized_org
|
||||||
from ..db import org_connection
|
from ..db import org_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from handicap_engine import order_of_merit_aggregate, order_of_merit_points_for_position
|
from handicap_engine import (
|
||||||
|
allocate_strokes_by_index,
|
||||||
|
EclecticHoleValue,
|
||||||
|
eclectic_best_per_hole,
|
||||||
|
order_of_merit_aggregate,
|
||||||
|
order_of_merit_points_for_position,
|
||||||
|
stableford_points_for_hole,
|
||||||
|
)
|
||||||
|
|
||||||
from .individual_tournaments import _compute_individual_standings
|
from .individual_tournaments import _compute_individual_standings
|
||||||
|
|
||||||
|
|
@ -165,6 +172,24 @@ async def update_order_of_merit(
|
||||||
raise app_error(
|
raise app_error(
|
||||||
400, "VALIDATION_FAILED", "Eclectic krever resultattype Stableford, brutto eller netto."
|
400, "VALIDATION_FAILED", "Eclectic krever resultattype Stableford, brutto eller netto."
|
||||||
)
|
)
|
||||||
|
if effective_mode == "eclectic":
|
||||||
|
# Slår PÅ eclectic mens flere turneringer med ulik bane
|
||||||
|
# allerede er lenket -- avvis her også, ikke bare ved lenking
|
||||||
|
# av en NY turnering (ADR-075).
|
||||||
|
links = await conn.fetch(
|
||||||
|
"SELECT tournament_id::text FROM order_of_merit_link WHERE order_of_merit_id = $1", oom_id
|
||||||
|
)
|
||||||
|
course_ids = set()
|
||||||
|
for link in links:
|
||||||
|
cid = await _course_id_for_tournament(conn, link["tournament_id"])
|
||||||
|
if cid is not None:
|
||||||
|
course_ids.add(cid)
|
||||||
|
if len(course_ids) > 1:
|
||||||
|
raise app_error(
|
||||||
|
400,
|
||||||
|
"VALIDATION_FAILED",
|
||||||
|
"Eclectic-aggregering krever at alle lenkede turneringer spilles på samme bane.",
|
||||||
|
)
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
f"""
|
f"""
|
||||||
UPDATE order_of_merit SET {', '.join(set_clauses)}, updated_at = now()
|
UPDATE order_of_merit SET {', '.join(set_clauses)}, updated_at = now()
|
||||||
|
|
@ -248,6 +273,39 @@ def _parse_link_row(r) -> dict:
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
async def _course_id_for_tournament(conn, tournament_id: str) -> str | None:
|
||||||
|
"""Samme mønster som _attach_eclectic_totals sin egen bane-oppslag
|
||||||
|
(individual_tournaments.py) -- LIMIT 1 antar (bevisst, håndhevet av
|
||||||
|
resten av appen ved rundeopprettelse) at en turnerings runder deler
|
||||||
|
samme bane."""
|
||||||
|
return await conn.fetchval(
|
||||||
|
"SELECT course_id::text FROM tournament_round WHERE tournament_id = $1 LIMIT 1", tournament_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_same_course_for_eclectic(conn, oom_id: str, new_tournament_id: str) -> None:
|
||||||
|
"""Eclectic-aggregering (ADR-075) plukker beste resultat PER HULL på
|
||||||
|
tvers av alle lenkede turneringer -- meningsløst hvis banene ikke er
|
||||||
|
de samme (hull 7 på én bane er ikke hull 7 på en annen). Håndhevet
|
||||||
|
her (lenke-tidspunkt) OG i update_order_of_merit (modus-endring),
|
||||||
|
samme "fail loudly, ikke stille feil tall"-filosofi som ADR-068/
|
||||||
|
ADR-019 Beslutning C."""
|
||||||
|
new_course_id = await _course_id_for_tournament(conn, new_tournament_id)
|
||||||
|
if new_course_id is None:
|
||||||
|
return # ingen runder ennå på den nye turneringen -- ingenting å sammenligne mot
|
||||||
|
links = await conn.fetch(
|
||||||
|
"SELECT tournament_id::text FROM order_of_merit_link WHERE order_of_merit_id = $1", oom_id
|
||||||
|
)
|
||||||
|
for link in links:
|
||||||
|
existing_course_id = await _course_id_for_tournament(conn, link["tournament_id"])
|
||||||
|
if existing_course_id is not None and existing_course_id != new_course_id:
|
||||||
|
raise app_error(
|
||||||
|
400,
|
||||||
|
"VALIDATION_FAILED",
|
||||||
|
"Eclectic-aggregering krever at alle lenkede turneringer spilles på samme bane.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments",
|
"/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments",
|
||||||
response_model=OrderOfMeritLinkOut,
|
response_model=OrderOfMeritLinkOut,
|
||||||
|
|
@ -259,7 +317,7 @@ async def link_tournament_to_order_of_merit(
|
||||||
organization_id: str = Depends(get_authorized_org),
|
organization_id: str = Depends(get_authorized_org),
|
||||||
) -> OrderOfMeritLinkOut:
|
) -> OrderOfMeritLinkOut:
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
oom = await conn.fetchrow("SELECT result_type FROM order_of_merit WHERE id = $1", oom_id)
|
oom = await conn.fetchrow("SELECT result_type, aggregation_mode FROM order_of_merit WHERE id = $1", oom_id)
|
||||||
if oom is None:
|
if oom is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
||||||
tournament = await conn.fetchrow(
|
tournament = await conn.fetchrow(
|
||||||
|
|
@ -271,6 +329,8 @@ async def link_tournament_to_order_of_merit(
|
||||||
raise app_error(
|
raise app_error(
|
||||||
400, "VALIDATION_FAILED", "Kun individuelle turneringer kan lenkes til en Order of Merit."
|
400, "VALIDATION_FAILED", "Kun individuelle turneringer kan lenkes til en Order of Merit."
|
||||||
)
|
)
|
||||||
|
if oom["aggregation_mode"] == "eclectic":
|
||||||
|
await _validate_same_course_for_eclectic(conn, oom_id, body.tournament_id)
|
||||||
|
|
||||||
result_type = oom["result_type"]
|
result_type = oom["result_type"]
|
||||||
if result_type == "points" and not body.points_table:
|
if result_type == "points" and not body.points_table:
|
||||||
|
|
@ -496,6 +556,117 @@ async def remove_order_of_merit_team_member(
|
||||||
raise app_error(404, "NOT_FOUND", "Medlemmet finnes ikke.")
|
raise app_error(404, "NOT_FOUND", "Medlemmet finnes ikke.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Eclectic-aggregering på tvers av lenkede turneringer (ADR-075) -- sesong-
|
||||||
|
# "drømmerunde": beste resultat PER HULL på tvers av ALLE en spillers
|
||||||
|
# lenkede turneringer (bekreftet samme bane av _validate_same_course_for_
|
||||||
|
# eclectic ved lenking/modus-endring). IKKE det samme som ADR-068 sitt
|
||||||
|
# Eclectic-format (scoping til RUNDENE innenfor ÉN turnering) -- samme
|
||||||
|
# motorfunksjon (`eclectic_best_per_hole`) gjenbrukes UENDRET, men
|
||||||
|
# datainnsamlingen her spenner over FLERE turneringers `tournament_round_
|
||||||
|
# hole`, ikke bare én. Nøkkelen er `player_id` (samme spiller har ulik
|
||||||
|
# `tournament_participant_id` per turnering), ikke `tournament_
|
||||||
|
# participant_id` slik innad-i-én-turnering-varianten (`_attach_eclectic_
|
||||||
|
# totals` i individual_tournaments.py) bruker.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _compute_eclectic_oom_values(
|
||||||
|
conn, tournament_ids: list[str], result_type: Literal["gross", "net", "stableford"]
|
||||||
|
) -> tuple[dict[str, float], dict[str, str], dict[str, int]]:
|
||||||
|
"""Returnerer (totaler, navn, antall-kilde-turneringer) per spiller.
|
||||||
|
Eclectic-aggregering ER selve tvers-av-turneringer-sammenslåingen --
|
||||||
|
det finnes ingen ekstra "sum/snitt av N eclectic-totaler" ovenpå
|
||||||
|
(`order_of_merit_aggregate` er derfor bevisst IKKE brukt her, se
|
||||||
|
kalleren)."""
|
||||||
|
if not tournament_ids:
|
||||||
|
return {}, {}, {}
|
||||||
|
|
||||||
|
course_id = None
|
||||||
|
for tid in tournament_ids:
|
||||||
|
course_id = await _course_id_for_tournament(conn, tid)
|
||||||
|
if course_id is not None:
|
||||||
|
break
|
||||||
|
if course_id is None:
|
||||||
|
return {}, {}, {}
|
||||||
|
|
||||||
|
hole_rows = await conn.fetch(
|
||||||
|
"SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", course_id
|
||||||
|
)
|
||||||
|
par_by_hole = {r["hole_number"]: r["par"] for r in hole_rows}
|
||||||
|
si_by_hole = [r["stroke_index"] for r in hole_rows]
|
||||||
|
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT tr.tournament_id::text AS tournament_id, tr.sequence,
|
||||||
|
tp.player_id::text AS player_id, p.display_name AS player_name,
|
||||||
|
trp.course_handicap, trh.hole_number, trh.gross_strokes
|
||||||
|
FROM tournament_round_hole trh
|
||||||
|
JOIN tournament_round_participant trp ON trp.id = trh.tournament_round_participant_id
|
||||||
|
JOIN tournament_round tr ON tr.id = trp.tournament_round_id
|
||||||
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
||||||
|
JOIN player p ON p.id = tp.player_id
|
||||||
|
WHERE tr.tournament_id = ANY($1::uuid[]) AND trh.gross_strokes IS NOT NULL
|
||||||
|
ORDER BY tr.tournament_id, tr.sequence
|
||||||
|
""",
|
||||||
|
tournament_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sammensatt kilde-indeks: (turnering-id, sekvens) -> global indeks,
|
||||||
|
# siden `sequence` kun er unik INNENFOR én turnering (speiler
|
||||||
|
# _attach_eclectic_totals sin sequence_by_index, men på tvers av flere
|
||||||
|
# turneringer i stedet for kun én).
|
||||||
|
index_by_source: dict[tuple[str, int], int] = {}
|
||||||
|
source_by_index: dict[int, tuple[str, int]] = {}
|
||||||
|
for r in rows:
|
||||||
|
key = (r["tournament_id"], r["sequence"])
|
||||||
|
if key not in index_by_source:
|
||||||
|
idx = len(index_by_source)
|
||||||
|
index_by_source[key] = idx
|
||||||
|
source_by_index[idx] = key
|
||||||
|
|
||||||
|
allocation_cache: dict[int, list[int]] = {}
|
||||||
|
values_by_player: dict[str, list[list[EclecticHoleValue]]] = {}
|
||||||
|
names_by_player: dict[str, str] = {}
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
if r["course_handicap"] is None:
|
||||||
|
continue
|
||||||
|
pid = r["player_id"]
|
||||||
|
names_by_player[pid] = r["player_name"]
|
||||||
|
if pid not in values_by_player:
|
||||||
|
values_by_player[pid] = [[] for _ in range(18)]
|
||||||
|
|
||||||
|
strokes_received = 0
|
||||||
|
if result_type != "gross":
|
||||||
|
hcp = r["course_handicap"]
|
||||||
|
if hcp not in allocation_cache:
|
||||||
|
allocation_cache[hcp] = allocate_strokes_by_index(hcp, si_by_hole)
|
||||||
|
strokes_received = allocation_cache[hcp][r["hole_number"] - 1]
|
||||||
|
|
||||||
|
if result_type == "gross":
|
||||||
|
value = r["gross_strokes"]
|
||||||
|
elif result_type == "net":
|
||||||
|
value = r["gross_strokes"] - strokes_received
|
||||||
|
else:
|
||||||
|
value = stableford_points_for_hole(par_by_hole[r["hole_number"]], r["gross_strokes"], strokes_received)
|
||||||
|
|
||||||
|
source_index = index_by_source[(r["tournament_id"], r["sequence"])]
|
||||||
|
values_by_player[pid][r["hole_number"] - 1].append(
|
||||||
|
EclecticHoleValue(round_index=source_index, value=value)
|
||||||
|
)
|
||||||
|
|
||||||
|
totals_by_player: dict[str, float] = {}
|
||||||
|
tournament_count_by_player: dict[str, int] = {}
|
||||||
|
for pid, values_by_hole in values_by_player.items():
|
||||||
|
result = eclectic_best_per_hole(values_by_hole, result_type)
|
||||||
|
if not result.holes:
|
||||||
|
continue
|
||||||
|
totals_by_player[pid] = float(result.total)
|
||||||
|
tournament_count_by_player[pid] = len({source_by_index[h.round_index][0] for h in result.holes})
|
||||||
|
|
||||||
|
return totals_by_player, names_by_player, tournament_count_by_player
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Leaderboard -- "regn ut ved lesing", ingen cache-tabell (samme filosofi
|
# Leaderboard -- "regn ut ved lesing", ingen cache-tabell (samme filosofi
|
||||||
# som Nassau/High-low-high/individual_leaderboard selv).
|
# som Nassau/High-low-high/individual_leaderboard selv).
|
||||||
|
|
@ -524,10 +695,14 @@ def _money_for_position(position_label: str, payout_table: list[dict]) -> float:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
def _player_eligibility(raw_values: list[float], oom, birth_year: int | None) -> tuple[bool, str | None]:
|
def _player_eligibility(results_available: int, oom, birth_year: int | None) -> tuple[bool, str | None]:
|
||||||
"""Delt mellom spiller- og lag-leaderboardet (et lags medlem er
|
"""Delt mellom spiller- og lag-leaderboardet (et lags medlem er
|
||||||
eligibility-sjekket EKSAKT som en spiller ville vært alene)."""
|
eligibility-sjekket EKSAKT som en spiller ville vært alene), OG mellom
|
||||||
results_available = len(raw_values)
|
sum/snitt (`results_available = len(raw_values)`) og eclectic
|
||||||
|
(`results_available` = antall KILDE-turneringer som faktisk bidro et
|
||||||
|
hull til sesong-drømmerunden, se _compute_eclectic_oom_values) --
|
||||||
|
tallet betyr det samme ("hvor mange turneringer talte") uansett hvilken
|
||||||
|
modus som produserte det."""
|
||||||
if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None:
|
if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None:
|
||||||
if birth_year is None:
|
if birth_year is None:
|
||||||
return False, "Ingen registrert fødselsdato."
|
return False, "Ingen registrert fødselsdato."
|
||||||
|
|
@ -571,11 +746,10 @@ async def order_of_merit_leaderboard(
|
||||||
)
|
)
|
||||||
if oom is None:
|
if oom is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
||||||
if oom["aggregation_mode"] == "eclectic":
|
|
||||||
raise app_error(400, "VALIDATION_FAILED", "Eclectic-aggregering er ikke bygget ennå.")
|
|
||||||
|
|
||||||
result_type = oom["result_type"]
|
result_type = oom["result_type"]
|
||||||
lower_is_better = result_type in ("gross", "net")
|
lower_is_better = result_type in ("gross", "net")
|
||||||
|
is_eclectic = oom["aggregation_mode"] == "eclectic"
|
||||||
|
|
||||||
links = await conn.fetch(
|
links = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
|
|
@ -587,9 +761,26 @@ async def order_of_merit_leaderboard(
|
||||||
)
|
)
|
||||||
|
|
||||||
# player_id -> [rå bidrags-verdier, ett per lenket turnering spilleren har deltatt i]
|
# player_id -> [rå bidrags-verdier, ett per lenket turnering spilleren har deltatt i]
|
||||||
|
# (eclectic-modus: nøyaktig ÉN verdi per spiller -- sesongens
|
||||||
|
# ferdig-sammenslåtte drømmerunde-total, se _compute_eclectic_oom_
|
||||||
|
# values. "results_available" betyr da antall KILDE-turneringer
|
||||||
|
# som bidro et hull, ikke antall verdier i denne listen.)
|
||||||
values_by_player: dict[str, list[float]] = {}
|
values_by_player: dict[str, list[float]] = {}
|
||||||
names_by_player: dict[str, str] = {}
|
names_by_player: dict[str, str] = {}
|
||||||
|
results_available_by_player: dict[str, int] = {}
|
||||||
|
|
||||||
|
if is_eclectic:
|
||||||
|
# ADR-075: krever result_type IN (stableford,gross,net) og
|
||||||
|
# kind='player', begge håndhevet ved opprettelse/PATCH --
|
||||||
|
# ingen ekstra sjekk nødvendig her.
|
||||||
|
tournament_ids = [link["tournament_id"] for link in links]
|
||||||
|
totals, names_by_player, tournament_counts = await _compute_eclectic_oom_values(
|
||||||
|
conn, tournament_ids, result_type
|
||||||
|
)
|
||||||
|
for player_id, total in totals.items():
|
||||||
|
values_by_player[player_id] = [total]
|
||||||
|
results_available_by_player[player_id] = tournament_counts[player_id]
|
||||||
|
else:
|
||||||
for link in links:
|
for link in links:
|
||||||
tournament_row, entries = await _compute_individual_standings(conn, link["tournament_id"])
|
tournament_row, entries = await _compute_individual_standings(conn, link["tournament_id"])
|
||||||
points_table = _decode_json(link["points_table"])
|
points_table = _decode_json(link["points_table"])
|
||||||
|
|
@ -602,6 +793,8 @@ async def order_of_merit_leaderboard(
|
||||||
continue
|
continue
|
||||||
values_by_player.setdefault(e.player_id, []).append(contribution)
|
values_by_player.setdefault(e.player_id, []).append(contribution)
|
||||||
names_by_player[e.player_id] = e.player_name
|
names_by_player[e.player_id] = e.player_name
|
||||||
|
for player_id, raw_values in values_by_player.items():
|
||||||
|
results_available_by_player[player_id] = len(raw_values)
|
||||||
|
|
||||||
# Aldersgrense: filtrert FØR aggregering (spillere utenfor
|
# Aldersgrense: filtrert FØR aggregering (spillere utenfor
|
||||||
# fødselsårs-intervallet er ikke rangert i det hele tatt, ikke
|
# fødselsårs-intervallet er ikke rangert i det hele tatt, ikke
|
||||||
|
|
@ -620,10 +813,21 @@ async def order_of_merit_leaderboard(
|
||||||
|
|
||||||
if oom["kind"] == "player":
|
if oom["kind"] == "player":
|
||||||
for player_id, raw_values in values_by_player.items():
|
for player_id, raw_values in values_by_player.items():
|
||||||
eligible, ineligible_reason = _player_eligibility(raw_values, oom, birth_years.get(player_id))
|
results_available = results_available_by_player[player_id]
|
||||||
|
eligible, ineligible_reason = _player_eligibility(results_available, oom, birth_years.get(player_id))
|
||||||
|
# Eclectic: raw_values er allerede ÉN ferdig-sammenslått
|
||||||
|
# sesongtotal (se _compute_eclectic_oom_values) -- ingen
|
||||||
|
# order_of_merit_aggregate-kall (som uansett kun kjenner
|
||||||
|
# sum/snitt, ikke eclectic, se motoren), og count_best_n
|
||||||
|
# gir ingen mening ("beste N av ÉN verdi").
|
||||||
|
if is_eclectic:
|
||||||
|
value = raw_values[0]
|
||||||
|
counted = results_available
|
||||||
|
else:
|
||||||
value = _aggregate_value(raw_values, oom, lower_is_better)
|
value = _aggregate_value(raw_values, oom, lower_is_better)
|
||||||
results_available = len(raw_values)
|
counted = (
|
||||||
counted = min(oom["count_best_n"], results_available) if oom["count_best_n"] is not None else results_available
|
min(oom["count_best_n"], results_available) if oom["count_best_n"] is not None else results_available
|
||||||
|
)
|
||||||
|
|
||||||
results.append(
|
results.append(
|
||||||
OrderOfMeritLeaderboardEntry(
|
OrderOfMeritLeaderboardEntry(
|
||||||
|
|
@ -667,7 +871,9 @@ async def order_of_merit_leaderboard(
|
||||||
raw_values = values_by_player.get(pid)
|
raw_values = values_by_player.get(pid)
|
||||||
if not raw_values:
|
if not raw_values:
|
||||||
continue
|
continue
|
||||||
member_eligible, _ = _player_eligibility(raw_values, oom, birth_years.get(pid))
|
member_eligible, _ = _player_eligibility(
|
||||||
|
results_available_by_player[pid], oom, birth_years.get(pid)
|
||||||
|
)
|
||||||
if not member_eligible:
|
if not member_eligible:
|
||||||
continue
|
continue
|
||||||
member_value = _aggregate_value(raw_values, oom, lower_is_better)
|
member_value = _aggregate_value(raw_values, oom, lower_is_better)
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,16 @@ import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type OomKind = "player" | "team"
|
type OomKind = "player" | "team"
|
||||||
type OomResultType = "points" | "stableford" | "gross" | "net" | "money"
|
type OomResultType = "points" | "stableford" | "gross" | "net" | "money"
|
||||||
type OomAggregationMode = "sum" | "average"
|
type OomAggregationMode = "sum" | "average" | "eclectic"
|
||||||
|
|
||||||
|
// Eclectic-aggregering (ADR-075) krever kind='player' og en resultattype med
|
||||||
|
// per-hull-granularitet (stableford/brutto/netto) -- håndhevet server-side
|
||||||
|
// (order_of_merit.py). Kind/resultattype er låst etter opprettelse her, så
|
||||||
|
// ingen reset-logikk trengs (i motsetning til order-of-merit-list.tsx sitt
|
||||||
|
// opprettelsesskjema).
|
||||||
|
function eclecticAllowed(kind: OomKind, resultType: OomResultType): boolean {
|
||||||
|
return kind === "player" && (resultType === "stableford" || resultType === "gross" || resultType === "net")
|
||||||
|
}
|
||||||
|
|
||||||
type ApiOrderOfMerit = {
|
type ApiOrderOfMerit = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -656,14 +665,25 @@ function SettingsSection({ oom, onSave }: { oom: ApiOrderOfMerit; onSave: (patch
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-sm font-semibold text-foreground">Aggregering</span>
|
<span className="text-sm font-semibold text-foreground">Aggregering</span>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className={`grid grid-cols-2 gap-2 ${eclecticAllowed(oom.kind, oom.result_type) ? "sm:grid-cols-3" : ""}`}>
|
||||||
<SettingsSegment selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
|
<SettingsSegment selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
|
||||||
Sum
|
Sum
|
||||||
</SettingsSegment>
|
</SettingsSegment>
|
||||||
<SettingsSegment selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
|
<SettingsSegment selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
|
||||||
Snitt
|
Snitt
|
||||||
</SettingsSegment>
|
</SettingsSegment>
|
||||||
|
{eclecticAllowed(oom.kind, oom.result_type) && (
|
||||||
|
<SettingsSegment selected={aggregationMode === "eclectic"} onClick={() => setAggregationMode("eclectic")}>
|
||||||
|
Eclectic
|
||||||
|
</SettingsSegment>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{aggregationMode === "eclectic" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Eclectic krever at alle lenkede turneringer spilles på samme bane -- lenking av en turnering på en annen
|
||||||
|
bane avvises så lenge denne modusen er valgt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,15 @@ import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type OomKind = "player" | "team"
|
type OomKind = "player" | "team"
|
||||||
type OomResultType = "points" | "stableford" | "gross" | "net" | "money"
|
type OomResultType = "points" | "stableford" | "gross" | "net" | "money"
|
||||||
type OomAggregationMode = "sum" | "average"
|
type OomAggregationMode = "sum" | "average" | "eclectic"
|
||||||
|
|
||||||
|
// Eclectic-aggregering (ADR-075) krever kind='player' og en resultattype
|
||||||
|
// med per-hull-granularitet (stableford/brutto/netto) -- håndhevet
|
||||||
|
// server-side (order_of_merit.py), speilet her for å skjule valget når
|
||||||
|
// det uansett ville blitt avvist.
|
||||||
|
function eclecticAllowed(kind: OomKind, resultType: OomResultType): boolean {
|
||||||
|
return kind === "player" && (resultType === "stableford" || resultType === "gross" || resultType === "net")
|
||||||
|
}
|
||||||
|
|
||||||
type ApiOrderOfMerit = {
|
type ApiOrderOfMerit = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -42,7 +50,11 @@ const RESULT_TYPE_LABELS: Record<OomResultType, string> = {
|
||||||
net: "Nettoscore-sum",
|
net: "Nettoscore-sum",
|
||||||
money: "Pengeliste",
|
money: "Pengeliste",
|
||||||
}
|
}
|
||||||
const AGGREGATION_LABELS: Record<OomAggregationMode, string> = { sum: "Sum", average: "Snitt" }
|
const AGGREGATION_LABELS: Record<OomAggregationMode, string> = {
|
||||||
|
sum: "Sum",
|
||||||
|
average: "Snitt",
|
||||||
|
eclectic: "Eclectic (drømmerunde)",
|
||||||
|
}
|
||||||
|
|
||||||
export function OrderOfMeritList({ organizationId, orgName }: { organizationId: string; orgName: string }) {
|
export function OrderOfMeritList({ organizationId, orgName }: { organizationId: string; orgName: string }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -229,6 +241,17 @@ function CreateForm({
|
||||||
const [aggregationMode, setAggregationMode] = useState<OomAggregationMode>("sum")
|
const [aggregationMode, setAggregationMode] = useState<OomAggregationMode>("sum")
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const showEclectic = eclecticAllowed(kind, resultType)
|
||||||
|
|
||||||
|
function selectKind(k: OomKind) {
|
||||||
|
setKind(k)
|
||||||
|
if (aggregationMode === "eclectic" && !eclecticAllowed(k, resultType)) setAggregationMode("sum")
|
||||||
|
}
|
||||||
|
function selectResultType(rt: OomResultType) {
|
||||||
|
setResultType(rt)
|
||||||
|
if (aggregationMode === "eclectic" && !eclecticAllowed(kind, rt)) setAggregationMode("sum")
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!name.trim() || submitting) return
|
if (!name.trim() || submitting) return
|
||||||
|
|
@ -261,10 +284,10 @@ function CreateForm({
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-sm font-semibold text-foreground">Type</span>
|
<span className="text-sm font-semibold text-foreground">Type</span>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<SegmentButton selected={kind === "player"} onClick={() => setKind("player")}>
|
<SegmentButton selected={kind === "player"} onClick={() => selectKind("player")}>
|
||||||
Spiller
|
Spiller
|
||||||
</SegmentButton>
|
</SegmentButton>
|
||||||
<SegmentButton selected={kind === "team"} onClick={() => setKind("team")}>
|
<SegmentButton selected={kind === "team"} onClick={() => selectKind("team")}>
|
||||||
Lag
|
Lag
|
||||||
</SegmentButton>
|
</SegmentButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -274,7 +297,7 @@ function CreateForm({
|
||||||
<span className="text-sm font-semibold text-foreground">Resultattype</span>
|
<span className="text-sm font-semibold text-foreground">Resultattype</span>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
{(Object.keys(RESULT_TYPE_LABELS) as OomResultType[]).map((rt) => (
|
{(Object.keys(RESULT_TYPE_LABELS) as OomResultType[]).map((rt) => (
|
||||||
<SegmentButton key={rt} selected={resultType === rt} onClick={() => setResultType(rt)}>
|
<SegmentButton key={rt} selected={resultType === rt} onClick={() => selectResultType(rt)}>
|
||||||
{RESULT_TYPE_LABELS[rt]}
|
{RESULT_TYPE_LABELS[rt]}
|
||||||
</SegmentButton>
|
</SegmentButton>
|
||||||
))}
|
))}
|
||||||
|
|
@ -283,13 +306,18 @@ function CreateForm({
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-sm font-semibold text-foreground">Aggregering</span>
|
<span className="text-sm font-semibold text-foreground">Aggregering</span>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className={`grid grid-cols-2 gap-2 ${showEclectic ? "sm:grid-cols-3" : ""}`}>
|
||||||
<SegmentButton selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
|
<SegmentButton selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
|
||||||
Sum
|
Sum
|
||||||
</SegmentButton>
|
</SegmentButton>
|
||||||
<SegmentButton selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
|
<SegmentButton selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
|
||||||
Snitt
|
Snitt
|
||||||
</SegmentButton>
|
</SegmentButton>
|
||||||
|
{showEclectic && (
|
||||||
|
<SegmentButton selected={aggregationMode === "eclectic"} onClick={() => setAggregationMode("eclectic")}>
|
||||||
|
Eclectic
|
||||||
|
</SegmentButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from app.routers.order_of_merit import (
|
||||||
OrderOfMeritLinkCreate,
|
OrderOfMeritLinkCreate,
|
||||||
OrderOfMeritTeamCreate,
|
OrderOfMeritTeamCreate,
|
||||||
OrderOfMeritTeamMemberCreate,
|
OrderOfMeritTeamMemberCreate,
|
||||||
|
OrderOfMeritUpdate,
|
||||||
add_order_of_merit_team_member,
|
add_order_of_merit_team_member,
|
||||||
create_order_of_merit,
|
create_order_of_merit,
|
||||||
create_order_of_merit_team,
|
create_order_of_merit_team,
|
||||||
|
|
@ -25,6 +26,7 @@ from app.routers.order_of_merit import (
|
||||||
list_order_of_merit_teams,
|
list_order_of_merit_teams,
|
||||||
order_of_merit_leaderboard,
|
order_of_merit_leaderboard,
|
||||||
remove_order_of_merit_team_member,
|
remove_order_of_merit_team_member,
|
||||||
|
update_order_of_merit,
|
||||||
)
|
)
|
||||||
|
|
||||||
from tests.conftest import (
|
from tests.conftest import (
|
||||||
|
|
@ -255,3 +257,134 @@ async def test_team_crud_and_kind_guard(pool):
|
||||||
await delete_order_of_merit_team(team_oom.id, team.id, organization_id=org_id)
|
await delete_order_of_merit_team(team_oom.id, team.id, organization_id=org_id)
|
||||||
teams = await list_order_of_merit_teams(team_oom.id, organization_id=org_id)
|
teams = await list_order_of_merit_teams(team_oom.id, organization_id=org_id)
|
||||||
assert len(teams) == 0
|
assert len(teams) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Steg 2 (ADR-075): eclectic-aggregering på tvers av lenkede turneringer.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _setup_tournament_on_course(org_id, name, course_id, tee_id, participants: list[tuple[str, str, list[int]]]):
|
||||||
|
"""participants: liste av (player_id, user_id, [score per hull 1..18])
|
||||||
|
-- DELER en allerede opprettet bane med kalleren (kreves for eclectic-
|
||||||
|
testene, der to turneringer må ha SAMME course_id)."""
|
||||||
|
tournament_id = await create_tournament(org_id, name=name)
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE tournament SET format_type = 'individual', scoring_method = 'stroke_gross' WHERE id = $1",
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
round_id = await create_tournament_round(org_id, tournament_id, course_id)
|
||||||
|
for player_id, user_id, scores in participants:
|
||||||
|
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
|
||||||
|
rp_id = await create_tournament_round_participant(org_id, round_id, tp_id, tee_id, course_handicap=0)
|
||||||
|
user = CurrentUser(user_id=user_id)
|
||||||
|
for n, score in enumerate(scores, start=1):
|
||||||
|
await tournament_update_hole(
|
||||||
|
tournament_id, round_id, rp_id, n,
|
||||||
|
TournamentHoleUpdate(gross_strokes=score, expected_version=None),
|
||||||
|
organization_id=org_id, user=user,
|
||||||
|
)
|
||||||
|
return tournament_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_eclectic_oom_picks_best_hole_across_tournaments(pool):
|
||||||
|
"""To turneringer, SAMME bane. Spiller: par (4) på hull 1-9 i A, bogey
|
||||||
|
(5) på hull 10-18 i A -- omvendt i B. Eclectic gross skal plukke par
|
||||||
|
(4) på ALLE 18 hull (9 fra A, 9 fra B) = 72, IKKE 81 (snitt) eller noe
|
||||||
|
annet -- beviser faktisk kryss-turnering-utvelgelse, samme bevis-
|
||||||
|
mønster som ADR-068 sin egen innad-i-turnering-test."""
|
||||||
|
org_id = await create_org()
|
||||||
|
owner_id = await create_user()
|
||||||
|
await add_membership(org_id, owner_id, role="owner")
|
||||||
|
user_id = await create_user()
|
||||||
|
player_id = await create_player(org_id, user_id=user_id)
|
||||||
|
|
||||||
|
course_id = await create_course(org_id, name="Delt bane")
|
||||||
|
for n in range(1, 19):
|
||||||
|
await create_org_hole(org_id, course_id, hole_number=n, par=4, stroke_index=n)
|
||||||
|
tee_id = await create_tee(org_id, course_id)
|
||||||
|
|
||||||
|
scores_a = [4] * 9 + [5] * 9 # par hull 1-9, bogey hull 10-18
|
||||||
|
scores_b = [5] * 9 + [4] * 9 # bogey hull 1-9, par hull 10-18
|
||||||
|
tournament_a = await _setup_tournament_on_course(org_id, "A", course_id, tee_id, [(player_id, user_id, scores_a)])
|
||||||
|
tournament_b = await _setup_tournament_on_course(org_id, "B", course_id, tee_id, [(player_id, user_id, scores_b)])
|
||||||
|
|
||||||
|
oom = await create_order_of_merit(
|
||||||
|
OrderOfMeritCreate(name="Eclectic-OOM", kind="player", result_type="gross", aggregation_mode="eclectic"),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
await link_tournament_to_order_of_merit(oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_a), organization_id=org_id)
|
||||||
|
await link_tournament_to_order_of_merit(oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_b), organization_id=org_id)
|
||||||
|
|
||||||
|
leaderboard = await order_of_merit_leaderboard(oom.id, organization_id=org_id)
|
||||||
|
assert len(leaderboard) == 1
|
||||||
|
assert leaderboard[0].value == 72.0
|
||||||
|
assert leaderboard[0].results_available == 2 # begge turneringene bidro minst ett hull
|
||||||
|
|
||||||
|
|
||||||
|
async def test_eclectic_oom_rejects_linking_tournament_on_different_course(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
owner_id = await create_user()
|
||||||
|
await add_membership(org_id, owner_id, role="owner")
|
||||||
|
user_id = await create_user()
|
||||||
|
player_id = await create_player(org_id, user_id=user_id)
|
||||||
|
|
||||||
|
course_a = await create_course(org_id, name="Bane A")
|
||||||
|
for n in range(1, 19):
|
||||||
|
await create_org_hole(org_id, course_a, hole_number=n, par=4, stroke_index=n)
|
||||||
|
tee_a = await create_tee(org_id, course_a)
|
||||||
|
tournament_a = await _setup_tournament_on_course(org_id, "A", course_a, tee_a, [(player_id, user_id, [4] * 18)])
|
||||||
|
|
||||||
|
course_b = await create_course(org_id, name="Bane B")
|
||||||
|
for n in range(1, 19):
|
||||||
|
await create_org_hole(org_id, course_b, hole_number=n, par=4, stroke_index=n)
|
||||||
|
tee_b = await create_tee(org_id, course_b)
|
||||||
|
tournament_b = await _setup_tournament_on_course(org_id, "B", course_b, tee_b, [(player_id, user_id, [5] * 18)])
|
||||||
|
|
||||||
|
oom = await create_order_of_merit(
|
||||||
|
OrderOfMeritCreate(name="Eclectic-OOM", kind="player", result_type="gross", aggregation_mode="eclectic"),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
await link_tournament_to_order_of_merit(oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_a), organization_id=org_id)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await link_tournament_to_order_of_merit(
|
||||||
|
oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_b), organization_id=org_id
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_eclectic_oom_rejects_mode_switch_with_existing_different_course_links(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
owner_id = await create_user()
|
||||||
|
await add_membership(org_id, owner_id, role="owner")
|
||||||
|
user_id = await create_user()
|
||||||
|
player_id = await create_player(org_id, user_id=user_id)
|
||||||
|
|
||||||
|
course_a = await create_course(org_id, name="Bane A")
|
||||||
|
for n in range(1, 19):
|
||||||
|
await create_org_hole(org_id, course_a, hole_number=n, par=4, stroke_index=n)
|
||||||
|
tee_a = await create_tee(org_id, course_a)
|
||||||
|
tournament_a = await _setup_tournament_on_course(org_id, "A", course_a, tee_a, [(player_id, user_id, [4] * 18)])
|
||||||
|
|
||||||
|
course_b = await create_course(org_id, name="Bane B")
|
||||||
|
for n in range(1, 19):
|
||||||
|
await create_org_hole(org_id, course_b, hole_number=n, par=4, stroke_index=n)
|
||||||
|
tee_b = await create_tee(org_id, course_b)
|
||||||
|
tournament_b = await _setup_tournament_on_course(org_id, "B", course_b, tee_b, [(player_id, user_id, [5] * 18)])
|
||||||
|
|
||||||
|
# Opprettes med sum (ikke eclectic), slik at begge ulikbane-turneringene
|
||||||
|
# kan lenkes uten å treffe lenke-tidspunktets sjekk -- selve bug-en som
|
||||||
|
# skal testes er modus-BYTTET etterpå.
|
||||||
|
oom = await create_order_of_merit(
|
||||||
|
OrderOfMeritCreate(name="Sum-OOM", kind="player", result_type="gross", aggregation_mode="sum"),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
await link_tournament_to_order_of_merit(oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_a), organization_id=org_id)
|
||||||
|
await link_tournament_to_order_of_merit(oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_b), organization_id=org_id)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await update_order_of_merit(
|
||||||
|
oom.id, OrderOfMeritUpdate(aggregation_mode="eclectic"), organization_id=org_id
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue