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)
|
||||
|
||||
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
|
||||
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
|
||||
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 ..db import org_connection
|
||||
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
|
||||
|
||||
|
|
@ -165,6 +172,24 @@ async def update_order_of_merit(
|
|||
raise app_error(
|
||||
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(
|
||||
f"""
|
||||
UPDATE order_of_merit SET {', '.join(set_clauses)}, updated_at = now()
|
||||
|
|
@ -248,6 +273,39 @@ def _parse_link_row(r) -> dict:
|
|||
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(
|
||||
"/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments",
|
||||
response_model=OrderOfMeritLinkOut,
|
||||
|
|
@ -259,7 +317,7 @@ async def link_tournament_to_order_of_merit(
|
|||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> OrderOfMeritLinkOut:
|
||||
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:
|
||||
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
||||
tournament = await conn.fetchrow(
|
||||
|
|
@ -271,6 +329,8 @@ async def link_tournament_to_order_of_merit(
|
|||
raise app_error(
|
||||
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"]
|
||||
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.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# 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
|
||||
|
||||
|
||||
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
|
||||
eligibility-sjekket EKSAKT som en spiller ville vært alene)."""
|
||||
results_available = len(raw_values)
|
||||
eligibility-sjekket EKSAKT som en spiller ville vært alene), OG mellom
|
||||
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 birth_year is None:
|
||||
return False, "Ingen registrert fødselsdato."
|
||||
|
|
@ -571,11 +746,10 @@ async def order_of_merit_leaderboard(
|
|||
)
|
||||
if oom is None:
|
||||
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"]
|
||||
lower_is_better = result_type in ("gross", "net")
|
||||
is_eclectic = oom["aggregation_mode"] == "eclectic"
|
||||
|
||||
links = await conn.fetch(
|
||||
"""
|
||||
|
|
@ -587,21 +761,40 @@ async def order_of_merit_leaderboard(
|
|||
)
|
||||
|
||||
# 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]] = {}
|
||||
names_by_player: dict[str, str] = {}
|
||||
results_available_by_player: dict[str, int] = {}
|
||||
|
||||
for link in links:
|
||||
tournament_row, entries = await _compute_individual_standings(conn, link["tournament_id"])
|
||||
points_table = _decode_json(link["points_table"])
|
||||
money_payout_table = _decode_json(link["money_payout_table"])
|
||||
for e in entries:
|
||||
contribution = _contribution_for_entry(
|
||||
e, result_type, points_table, money_payout_table, tournament_row["scoring_method"]
|
||||
)
|
||||
if contribution is None:
|
||||
continue
|
||||
values_by_player.setdefault(e.player_id, []).append(contribution)
|
||||
names_by_player[e.player_id] = e.player_name
|
||||
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:
|
||||
tournament_row, entries = await _compute_individual_standings(conn, link["tournament_id"])
|
||||
points_table = _decode_json(link["points_table"])
|
||||
money_payout_table = _decode_json(link["money_payout_table"])
|
||||
for e in entries:
|
||||
contribution = _contribution_for_entry(
|
||||
e, result_type, points_table, money_payout_table, tournament_row["scoring_method"]
|
||||
)
|
||||
if contribution is None:
|
||||
continue
|
||||
values_by_player.setdefault(e.player_id, []).append(contribution)
|
||||
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
|
||||
# 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":
|
||||
for player_id, raw_values in values_by_player.items():
|
||||
eligible, ineligible_reason = _player_eligibility(raw_values, oom, birth_years.get(player_id))
|
||||
value = _aggregate_value(raw_values, oom, lower_is_better)
|
||||
results_available = len(raw_values)
|
||||
counted = min(oom["count_best_n"], results_available) if oom["count_best_n"] is not None else results_available
|
||||
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)
|
||||
counted = (
|
||||
min(oom["count_best_n"], results_available) if oom["count_best_n"] is not None else results_available
|
||||
)
|
||||
|
||||
results.append(
|
||||
OrderOfMeritLeaderboardEntry(
|
||||
|
|
@ -667,7 +871,9 @@ async def order_of_merit_leaderboard(
|
|||
raw_values = values_by_player.get(pid)
|
||||
if not raw_values:
|
||||
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:
|
||||
continue
|
||||
member_value = _aggregate_value(raw_values, oom, lower_is_better)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,16 @@ import { cn } from "@/lib/utils"
|
|||
|
||||
type OomKind = "player" | "team"
|
||||
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 = {
|
||||
id: string
|
||||
|
|
@ -656,14 +665,25 @@ function SettingsSection({ oom, onSave }: { oom: ApiOrderOfMerit; onSave: (patch
|
|||
|
||||
<div className="flex flex-col gap-2">
|
||||
<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")}>
|
||||
Sum
|
||||
</SettingsSegment>
|
||||
<SettingsSegment selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
|
||||
Snitt
|
||||
</SettingsSegment>
|
||||
{eclecticAllowed(oom.kind, oom.result_type) && (
|
||||
<SettingsSegment selected={aggregationMode === "eclectic"} onClick={() => setAggregationMode("eclectic")}>
|
||||
Eclectic
|
||||
</SettingsSegment>
|
||||
)}
|
||||
</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 className="flex flex-col gap-1.5">
|
||||
|
|
|
|||
|
|
@ -19,7 +19,15 @@ import { cn } from "@/lib/utils"
|
|||
|
||||
type OomKind = "player" | "team"
|
||||
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 = {
|
||||
id: string
|
||||
|
|
@ -42,7 +50,11 @@ const RESULT_TYPE_LABELS: Record<OomResultType, string> = {
|
|||
net: "Nettoscore-sum",
|
||||
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 }) {
|
||||
const router = useRouter()
|
||||
|
|
@ -229,6 +241,17 @@ function CreateForm({
|
|||
const [aggregationMode, setAggregationMode] = useState<OomAggregationMode>("sum")
|
||||
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) {
|
||||
e.preventDefault()
|
||||
if (!name.trim() || submitting) return
|
||||
|
|
@ -261,10 +284,10 @@ function CreateForm({
|
|||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">Type</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SegmentButton selected={kind === "player"} onClick={() => setKind("player")}>
|
||||
<SegmentButton selected={kind === "player"} onClick={() => selectKind("player")}>
|
||||
Spiller
|
||||
</SegmentButton>
|
||||
<SegmentButton selected={kind === "team"} onClick={() => setKind("team")}>
|
||||
<SegmentButton selected={kind === "team"} onClick={() => selectKind("team")}>
|
||||
Lag
|
||||
</SegmentButton>
|
||||
</div>
|
||||
|
|
@ -274,7 +297,7 @@ function CreateForm({
|
|||
<span className="text-sm font-semibold text-foreground">Resultattype</span>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{(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]}
|
||||
</SegmentButton>
|
||||
))}
|
||||
|
|
@ -283,13 +306,18 @@ function CreateForm({
|
|||
|
||||
<div className="flex flex-col gap-2">
|
||||
<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")}>
|
||||
Sum
|
||||
</SegmentButton>
|
||||
<SegmentButton selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
|
||||
Snitt
|
||||
</SegmentButton>
|
||||
{showEclectic && (
|
||||
<SegmentButton selected={aggregationMode === "eclectic"} onClick={() => setAggregationMode("eclectic")}>
|
||||
Eclectic
|
||||
</SegmentButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from app.routers.order_of_merit import (
|
|||
OrderOfMeritLinkCreate,
|
||||
OrderOfMeritTeamCreate,
|
||||
OrderOfMeritTeamMemberCreate,
|
||||
OrderOfMeritUpdate,
|
||||
add_order_of_merit_team_member,
|
||||
create_order_of_merit,
|
||||
create_order_of_merit_team,
|
||||
|
|
@ -25,6 +26,7 @@ from app.routers.order_of_merit import (
|
|||
list_order_of_merit_teams,
|
||||
order_of_merit_leaderboard,
|
||||
remove_order_of_merit_team_member,
|
||||
update_order_of_merit,
|
||||
)
|
||||
|
||||
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)
|
||||
teams = await list_order_of_merit_teams(team_oom.id, organization_id=org_id)
|
||||
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