Order of Merit: lag-leaderboard + eclectic-total-bug fikset (ADR-074)

Fikser først en reell bug: en lenket eclectic-scoret turnering bidro med
rå slagsum i stedet for eclectic_total (turneringens faktiske
"drømmerunde"-resultat) i stableford/brutto/netto-OOM-er -- bugen fantes
siden Eclectic-formatet ble bygget (ADR-068), OOM ble aldri revidert.

Bygger deretter lag-OOM sin leaderboard (ADR-043 Beslutning C, skjema
fantes fra migrasjon 055): ny CRUD for team/medlemmer, og leaderboard-
grenen for kind='team' som summerer/velger-beste-N-av medlemmenes
allerede beregnede individuelle OOM-resultater -- samme
order_of_merit_aggregate-kall gjenbrukt på lagnivå med OOM-ens allerede
lagrede aggregeringsvalg. Frontend: fjernet "ikke bygget ennå"-
plassholderen, den eksisterende leaderboard-tabellen var allerede
generisk nok. Ny lag-administrasjonsseksjon lagt til.

Ingen migrasjon. Se ADR-074/CHANGELOG for full detalj og verifisering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-15 16:52:37 +02:00
parent 3770509c46
commit 5d502f305a
5 changed files with 976 additions and 50 deletions

View file

@ -7310,6 +7310,96 @@ Containeren startet rent.
--- ---
## ADR-074: Order of Merit -- lag-leaderboard + eclectic-total-bug fikset ("Steg 0+1" av gjenstår-listen) (2026-08-15)
Foranledning: bruker ba om å gå videre med de to store gjenstående OOM-
punktene fra ADR-043 (spiller-OOM, 2026-08-04): lag-OOM sin faktiske
leaderboard-beregning (skjema fantes, `GET .../leaderboard` avviste
eksplisitt for `kind='team'`), og eclectic-aggregering på tvers av
lenkede turneringer. Utvidet til tre punkter da FEATURE_BACKLOG.md sin
"gjenstår"-liste viste seg å ha et tredje, ikke opprinnelig nevnt punkt
(offentlig/delt visning) -- bekreftet med bruker at alle tre tas i denne
runden (se ADR-075/076 for de to andre).
**Bifunn tatt FØRST, uavhengig av resten:** `_contribution_for_entry`
(brukt av BÅDE eksisterende sum/snitt-OOM-er og den nye eclectic-
aggregeringen) leste alltid `entry.gross_total`/`net_total`/
`stableford_total` for en lenket turnering -- ALDRI `entry.
eclectic_total`, selv når den lenkede turneringens `scoring_method` var
`eclectic_gross`/`net`/`stableford` (ADR-068, bygget 2026-08-14, EFTER
OOM-en selv 2026-08-04 -- aldri revidert siden). Konsekvens: en klubb som
lenket en eclectic-scoret turnering inn i en stableford/brutto/netto-OOM
fikk stille FEIL tall -- spillerens rå slagsum på tvers av rundene (et
tall som ikke engang avgjorde DEN turneringens egen vinner), i stedet
for dens faktiske "drømmerunde"-resultat. Fikset: `_contribution_for_
entry` tar nå også imot turneringens `scoring_method` (allerede
tilgjengelig fra `_compute_individual_standings` sin returnerte
metadata, kun forkastet før) og leser `eclectic_total` når relevant.
**Lag-OOM sin leaderboard** (ADR-043 Beslutning C, aldri bygget):
- Ny CRUD (`order_of_merit_team`/`_team_member`, tabellene fantes
allerede fra migrasjon 055 -- INGEN ny migrasjon i dette steget):
`POST/GET/DELETE .../order-of-merits/{id}/teams`,
`POST/DELETE .../teams/{id}/members`. Samme mønster som
`order_of_merit_link`-CRUD-en (eksisterer-sjekk + `translate_db_
errors()`). `player_id`-FK-en i skjemaet er IKKE org-scopet (migrasjon
055-avgjørelse) -- egen eksplisitt eksistenssjekk lagt til i
`add_order_of_merit_team_member` for å ikke stole blindt på klienten.
- Leaderboard-grenen for `kind='team'` erstatter den tidligere eksplisitte
avvisningen: for hvert lag, regn ut HVERT medlems allerede-etablerte
individuelle OOM-sluttresultat (samme `_player_eligibility`+
`_aggregate_value`-par som spillernivået, faktorisert ut og delt), slå
deretter disse medlemsverdiene sammen med SAMME `order_of_merit_
aggregate`-kall -- gjenbrukt EN GANG TIL på lagnivå, med OOM-ens
ALLEREDE LAGREDE `aggregation_mode`/`count_best_n`. **Bevisst
designvalg, ingen ny konfig**: ETT lagret aggregeringsvalg styrer
BEGGE nivåer -- `count_best_n=1` betyr dermed BÅDE "hver spillers
beste 1 av N turneringer" OG "lagets beste 1 av M medlemmer" samtidig
(bekreftet i test: `test_team_leaderboard_best_n_applies_at_both_
levels`, ikke en overraskelse men en direkte konsekvens av
gjenbruket). `aggregation_mode='eclectic'` er allerede sperret for
`kind='team'` ved opprettelse (eksisterende sjekk, uendret) -- lagnivået
trenger derfor aldri håndtere eclectic.
- `OrderOfMeritLeaderboardEntry` sitt `player_id`/`player_name`-par
gjenbrukes UENDRET for lagnivået (bærer der lagets EGEN id/navn) --
ingen ny modell, kalleren vet allerede OOM-ens `kind`.
**Frontend**: `order-of-merit-detail.tsx` sin `kind === "team"`-
placeholder ("Lag-resultatliste er ikke bygget ennå") fjernet helt --
`LeaderboardTable` var allerede generisk nok til å vise lag-resultater
uendret (leser kun `player_id`/`player_name`/`value`/`position`, ingen
spiller-spesifikk logikk å fjerne). Ny "Lag"-seksjon (kun for
`kind='team'`) mellom Innstillinger og Resultatliste -- samme liste+
skjema-mønster som Lenkede turneringer-seksjonen: opprett/slett lag,
legg til/fjern medlem fra org-ens spillerpool (enkelt `<select>`, samme
presedens som andre administrasjons-sammenhenger i appen -- IKKE i strid
med "ingen dropdowns"-regelen, som gjelder scoring-registrering
spesifikt, se ADR-071).
**Verifisert:** `python3 -m py_compile` + full
`./scripts/run_backend_tests.sh` (77/77 -- 5 nye tester i
`test_order_of_merit.py`: eclectic-total-bug-regresjon, lag-leaderboard
summerer medlemmers individuelle OOM-totaler korrekt (håndregnet: 162 +
198 = 360), `count_best_n` sin to-nivås-anvendelse bevist eksplisitt,
full lag-CRUD + `kind='player'`-guard). `tsc --noEmit` rent + 45/45
vitest. Egen scratch-database + scratch `teecup_api`-container (port
18008, live-montert kode) + lokal `next dev` (port 13008): opprettet en
ekte lag-OOM i nettleseren, lenket to turneringer, opprettet ett lag,
la til to medlemmer ETT OM GANGEN -- bekreftet leaderboardet oppdaterte
seg LIVE til riktig verdi ved hver endring (162 → 360 ved andre medlem,
tilbake til 162 ved fjerning), gull-fremhevet førsteplass-rad vist
korrekt. Lys+mørk bekreftet. Scratch-stacken fullstendig revet ned --
ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt.
**Ingen migrasjon** -- ren kode-endring, tabellene fantes 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)

View file

@ -11530,3 +11530,45 @@ Neste steg:
**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build **Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
teecup_frontend && up -d` etter eksplisitt bekreftelse fra bruker. teecup_frontend && up -d` etter eksplisitt bekreftelse fra bruker.
Containeren startet rent. Containeren startet rent.
90. **Order of Merit: lag-leaderboard + eclectic-total-bug fikset
(ADR-074) — 2026-08-15.** Går videre med de gjenstående OOM-punktene
fra ADR-043 (2026-08-04): lag-OOM sin leaderboard, eclectic-
aggregering på tvers av turneringer, og (nytt oppdaget) offentlig
visning. Denne runden: bug-fiks + lag-OOM. Se ADR-075/076 for de to
andre.
Bifunn fikset FØRST: `_contribution_for_entry` leste alltid rå
gross/net/stableford_total for en lenket turnering, ALDRI
`eclectic_total` -- en klubb som lenket en eclectic-scoret turnering
(ADR-068, bygget etter OOM selv) inn i en stableford/brutto/netto-OOM
fikk stille feil tall (rå slagsum i stedet for turneringens faktiske
"drømmerunde"-resultat). Fikset ved å gi funksjonen tilgang til
scoring_method.
Lag-OOM: ny CRUD for `order_of_merit_team`/`_team_member` (tabellene
fantes fra migrasjon 055, ingen ny migrasjon). Leaderboard-grenen for
`kind='team'` regner ut hvert medlems individuelle OOM-sluttresultat,
slår dem sammen med SAMME `order_of_merit_aggregate`-kall gjenbrukt
på lagnivå -- bevisst: ett lagret aggregeringsvalg styrer begge
nivåer (count_best_n=1 betyr "beste turnering per spiller" OG "beste
medlem per lag" samtidig, bekreftet eksplisitt i test). Frontend:
fjernet "ikke bygget ennå"-plassholderen -- den eksisterende
leaderboard-tabellen var allerede generisk nok til å vise lag
uendret. Ny "Lag"-administrasjonsseksjon lagt til.
**Verifisert:** full `./scripts/run_backend_tests.sh` (77/77 -- 5 nye
tester, inkl. håndregnet lag-sum 162+198=360 og en eksplisitt test av
to-nivås count_best_n-anvendelsen). `tsc --noEmit` rent + 45/45
vitest. Scratch-database + scratch `teecup_api` (port 18008, live-
montert kode) + lokal `next dev` (port 13008): opprettet ekte lag-OOM
i nettleseren, lenket to turneringer, la til to medlemmer ett om
gangen -- leaderboardet oppdaterte seg live til riktig verdi ved hver
endring. Lys+mørk bekreftet. Scratch-stacken fullstendig revet ned --
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.

View file

@ -325,6 +325,177 @@ async def unlink_tournament_from_order_of_merit(
raise app_error(404, "NOT_FOUND", "Lenken finnes ikke.") raise app_error(404, "NOT_FOUND", "Lenken finnes ikke.")
# ---------------------------------------------------------------------------
# Lag-OOM (ADR-043 Beslutning C, bygget ADR-074): sesong-par satt opp
# DIREKTE i OOM-en (IKKE strengmatching mot turnering-lag, ADR-011-lag er
# strukturelt urelatert -- se migrasjon 055 sin kommentar). Kun gyldig for
# `kind='team'`. Selve leaderboard-beregningen (summerer/velger-beste-N-av
# medlemmenes allerede beregnede individuelle OOM-resultater) står lenger
# ned, sammen med spiller-leaderboardet den gjenbruker.
# ---------------------------------------------------------------------------
class OrderOfMeritTeamCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
class OrderOfMeritTeamMemberOut(BaseModel):
id: str
player_id: str
player_name: str
class OrderOfMeritTeamOut(BaseModel):
id: str
name: str
members: list[OrderOfMeritTeamMemberOut]
class OrderOfMeritTeamMemberCreate(BaseModel):
player_id: str
async def _require_team_kind(conn, oom_id: str) -> None:
kind = await conn.fetchval("SELECT kind FROM order_of_merit WHERE id = $1", oom_id)
if kind is None:
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
if kind != "team":
raise app_error(400, "VALIDATION_FAILED", "Lag kan kun settes opp for en lag-OOM.")
@router.get(
"/orgs/{organization_id}/order-of-merits/{oom_id}/teams",
response_model=list[OrderOfMeritTeamOut],
)
async def list_order_of_merit_teams(
oom_id: str,
organization_id: str = Depends(get_authorized_org),
) -> list[OrderOfMeritTeamOut]:
async with org_connection(organization_id) as conn:
team_rows = await conn.fetch(
"SELECT id::text, name FROM order_of_merit_team WHERE order_of_merit_id = $1 ORDER BY created_at",
oom_id,
)
member_rows = await conn.fetch(
"""
SELECT m.id::text, m.order_of_merit_team_id::text AS team_id,
m.player_id::text AS player_id, p.display_name AS player_name
FROM order_of_merit_team_member m
JOIN order_of_merit_team t ON t.id = m.order_of_merit_team_id
JOIN player p ON p.id = m.player_id
WHERE t.order_of_merit_id = $1
ORDER BY p.display_name
""",
oom_id,
)
members_by_team: dict[str, list[OrderOfMeritTeamMemberOut]] = {}
for m in member_rows:
members_by_team.setdefault(m["team_id"], []).append(
OrderOfMeritTeamMemberOut(id=m["id"], player_id=m["player_id"], player_name=m["player_name"])
)
return [
OrderOfMeritTeamOut(id=t["id"], name=t["name"], members=members_by_team.get(t["id"], []))
for t in team_rows
]
@router.post(
"/orgs/{organization_id}/order-of-merits/{oom_id}/teams",
response_model=OrderOfMeritTeamOut,
status_code=201,
)
async def create_order_of_merit_team(
oom_id: str,
body: OrderOfMeritTeamCreate,
organization_id: str = Depends(get_authorized_org),
) -> OrderOfMeritTeamOut:
async with org_connection(organization_id) as conn, translate_db_errors():
await _require_team_kind(conn, oom_id)
row = await conn.fetchrow(
"""
INSERT INTO order_of_merit_team (organization_id, order_of_merit_id, name)
VALUES ($1, $2, $3)
RETURNING id::text, name
""",
organization_id, oom_id, body.name,
)
return OrderOfMeritTeamOut(id=row["id"], name=row["name"], members=[])
@router.delete(
"/orgs/{organization_id}/order-of-merits/{oom_id}/teams/{team_id}",
status_code=204,
)
async def delete_order_of_merit_team(
oom_id: str,
team_id: str,
organization_id: str = Depends(get_authorized_org),
) -> None:
async with org_connection(organization_id) as conn:
result = await conn.execute(
"DELETE FROM order_of_merit_team WHERE id = $1 AND order_of_merit_id = $2", team_id, oom_id
)
if result == "DELETE 0":
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
@router.post(
"/orgs/{organization_id}/order-of-merits/{oom_id}/teams/{team_id}/members",
response_model=OrderOfMeritTeamMemberOut,
status_code=201,
)
async def add_order_of_merit_team_member(
oom_id: str,
team_id: str,
body: OrderOfMeritTeamMemberCreate,
organization_id: str = Depends(get_authorized_org),
) -> OrderOfMeritTeamMemberOut:
async with org_connection(organization_id) as conn, translate_db_errors():
team = await conn.fetchval(
"SELECT id FROM order_of_merit_team WHERE id = $1 AND order_of_merit_id = $2", team_id, oom_id
)
if team is None:
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
# player.id er IKKE org-scopet i sin FK (migrasjon 055) -- egen
# eksplisitt sjekk her, samme forsiktighet som andre steder i
# appen som tar imot en player_id fra klienten.
player = await conn.fetchrow("SELECT display_name 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 order_of_merit_team_member (organization_id, order_of_merit_team_id, player_id)
VALUES ($1, $2, $3)
RETURNING id::text
""",
organization_id, team_id, body.player_id,
)
return OrderOfMeritTeamMemberOut(id=row["id"], player_id=body.player_id, player_name=player["display_name"])
@router.delete(
"/orgs/{organization_id}/order-of-merits/{oom_id}/teams/{team_id}/members/{member_id}",
status_code=204,
)
async def remove_order_of_merit_team_member(
oom_id: str,
team_id: str,
member_id: str,
organization_id: str = Depends(get_authorized_org),
) -> None:
async with org_connection(organization_id) as conn:
team = await conn.fetchval(
"SELECT id FROM order_of_merit_team WHERE id = $1 AND order_of_merit_id = $2", team_id, oom_id
)
if team is None:
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
result = await conn.execute(
"DELETE FROM order_of_merit_team_member WHERE id = $1 AND order_of_merit_team_id = $2",
member_id, team_id,
)
if result == "DELETE 0":
raise app_error(404, "NOT_FOUND", "Medlemmet finnes ikke.")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 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).
@ -353,6 +524,34 @@ 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]:
"""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)
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."
if oom["birth_year_from"] is not None and birth_year < oom["birth_year_from"]:
return False, "Utenfor aldersgrensen."
if oom["birth_year_to"] is not None and birth_year > oom["birth_year_to"]:
return False, "Utenfor aldersgrensen."
if oom["min_results_required"] is not None and results_available < oom["min_results_required"]:
return False, f"Trenger minst {oom['min_results_required']} tellende resultater, har {results_available}."
return True, None
def _aggregate_value(raw_values: list[float], oom, lower_is_better: bool) -> float | None:
"""Negerer FØR order_of_merit_aggregate (som alltid regner «størst er
best») og negerer tilbake etterpå for brutto/netto -- delt av spiller-
OG lag-nivået. Trygt å kalle EN GANG TIL lag-nivå med allerede
korrekt-signerte medlemsverdier: samme frem-og-tilbake-negering gir
riktig resultat begge ganger (ADR-074 Beslutning: ett lagret
aggregeringsvalg styrer begge nivåer, ingen egen lag-konfig)."""
values_for_aggregate = [(-v if lower_is_better else v) for v in raw_values]
aggregated = order_of_merit_aggregate(values_for_aggregate, oom["aggregation_mode"], oom["count_best_n"])
return -aggregated if lower_is_better and aggregated is not None else aggregated
@router.get( @router.get(
"/orgs/{organization_id}/order-of-merits/{oom_id}/leaderboard", "/orgs/{organization_id}/order-of-merits/{oom_id}/leaderboard",
response_model=list[OrderOfMeritLeaderboardEntry], response_model=list[OrderOfMeritLeaderboardEntry],
@ -372,8 +571,6 @@ 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["kind"] != "player":
raise app_error(400, "VALIDATION_FAILED", "Lag-OOM-leaderboard er ikke bygget ennå.")
if oom["aggregation_mode"] == "eclectic": if oom["aggregation_mode"] == "eclectic":
raise app_error(400, "VALIDATION_FAILED", "Eclectic-aggregering er ikke bygget ennå.") raise app_error(400, "VALIDATION_FAILED", "Eclectic-aggregering er ikke bygget ennå.")
@ -394,11 +591,13 @@ async def order_of_merit_leaderboard(
names_by_player: dict[str, str] = {} names_by_player: dict[str, str] = {}
for link in links: for link in links:
_, 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"])
money_payout_table = _decode_json(link["money_payout_table"]) money_payout_table = _decode_json(link["money_payout_table"])
for e in entries: for e in entries:
contribution = _contribution_for_entry(e, result_type, points_table, money_payout_table) contribution = _contribution_for_entry(
e, result_type, points_table, money_payout_table, tournament_row["scoring_method"]
)
if contribution is None: if contribution is None:
continue continue
values_by_player.setdefault(e.player_id, []).append(contribution) values_by_player.setdefault(e.player_id, []).append(contribution)
@ -418,30 +617,12 @@ async def order_of_merit_leaderboard(
birth_years = {r["id"]: r["birth_year"] for r in rows} birth_years = {r["id"]: r["birth_year"] for r in rows}
results: list[OrderOfMeritLeaderboardEntry] = [] results: list[OrderOfMeritLeaderboardEntry] = []
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))
value = _aggregate_value(raw_values, oom, lower_is_better)
results_available = len(raw_values) results_available = len(raw_values)
eligible = True
ineligible_reason = None
if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None:
by = birth_years.get(player_id)
if by is None:
eligible = False
ineligible_reason = "Ingen registrert fødselsdato."
elif oom["birth_year_from"] is not None and by < oom["birth_year_from"]:
eligible = False
ineligible_reason = "Utenfor aldersgrensen."
elif oom["birth_year_to"] is not None and by > oom["birth_year_to"]:
eligible = False
ineligible_reason = "Utenfor aldersgrensen."
if eligible and oom["min_results_required"] is not None and results_available < oom["min_results_required"]:
eligible = False
ineligible_reason = f"Trenger minst {oom['min_results_required']} tellende resultater, har {results_available}."
values_for_aggregate = [(-v if lower_is_better else v) for v in raw_values]
aggregated = order_of_merit_aggregate(values_for_aggregate, oom["aggregation_mode"], oom["count_best_n"])
value = (-aggregated if lower_is_better and aggregated is not None else aggregated)
counted = min(oom["count_best_n"], results_available) if oom["count_best_n"] is not None else results_available counted = min(oom["count_best_n"], results_available) if oom["count_best_n"] is not None else results_available
results.append( results.append(
@ -456,6 +637,74 @@ async def order_of_merit_leaderboard(
ineligible_reason=ineligible_reason, ineligible_reason=ineligible_reason,
) )
) )
else:
# Lag-OOM (ADR-043 Beslutning C, ADR-074): summerer/velger-
# beste-N-av medlemmenes allerede beregnede individuelle OOM-
# resultater -- IKKE strengmatching mot turnering-lag.
# `player_id`/`player_name` på OrderOfMeritLeaderboardEntry
# bærer her lagets EGEN id/navn (samme uttrykk, uendret
# skjema -- kalleren vet allerede OOM-ens `kind`).
team_rows = await conn.fetch(
"SELECT id::text, name FROM order_of_merit_team WHERE order_of_merit_id = $1", oom_id
)
member_rows = await conn.fetch(
"""
SELECT m.order_of_merit_team_id::text AS team_id, m.player_id::text AS player_id
FROM order_of_merit_team_member m
JOIN order_of_merit_team t ON t.id = m.order_of_merit_team_id
WHERE t.order_of_merit_id = $1
""",
oom_id,
)
member_ids_by_team: dict[str, list[str]] = {}
for m in member_rows:
member_ids_by_team.setdefault(m["team_id"], []).append(m["player_id"])
for team in team_rows:
member_ids = member_ids_by_team.get(team["id"], [])
member_values: list[float] = []
for pid in member_ids:
raw_values = values_by_player.get(pid)
if not raw_values:
continue
member_eligible, _ = _player_eligibility(raw_values, oom, birth_years.get(pid))
if not member_eligible:
continue
member_value = _aggregate_value(raw_values, oom, lower_is_better)
if member_value is not None:
member_values.append(member_value)
eligible_members = len(member_values)
team_eligible = eligible_members > 0
team_reason = None if team_eligible else "Ingen av lagets medlemmer har tellende resultater."
if (
team_eligible
and oom["min_results_required"] is not None
and eligible_members < oom["min_results_required"]
):
team_eligible = False
team_reason = (
f"Trenger minst {oom['min_results_required']} medlemmer med tellende resultater, "
f"har {eligible_members}."
)
value = _aggregate_value(member_values, oom, lower_is_better) if team_eligible else None
counted = (
min(oom["count_best_n"], eligible_members) if oom["count_best_n"] is not None else eligible_members
)
results.append(
OrderOfMeritLeaderboardEntry(
player_id=team["id"],
player_name=team["name"],
results_counted=counted,
results_available=len(member_ids),
value=value,
position=None,
eligible=team_eligible,
ineligible_reason=team_reason,
)
)
# Rangering: kun blant de kvalifiserte, høyest verdi øverst for # Rangering: kun blant de kvalifiserte, høyest verdi øverst for
# poeng/stableford/pengeliste, LAVEST for brutto/netto -- men # poeng/stableford/pengeliste, LAVEST for brutto/netto -- men
@ -483,7 +732,12 @@ def _decode_json(value):
return json.loads(value) if value else None return json.loads(value) if value else None
def _contribution_for_entry(entry, result_type: str, points_table, money_payout_table) -> float | None: _ECLECTIC_METHODS = ("eclectic_gross", "eclectic_net", "eclectic_stableford")
def _contribution_for_entry(
entry, result_type: str, points_table, money_payout_table, scoring_method: str | None = None
) -> float | None:
"""Utleder ÉN spillers bidrags-tall fra én lenket turnerings egen, """Utleder ÉN spillers bidrags-tall fra én lenket turnerings egen,
allerede beregnede leaderboard-entry -- `None` betyr "spilte ikke allerede beregnede leaderboard-entry -- `None` betyr "spilte ikke
denne turneringen" (skal ikke telle som et 0-resultat, kalleren denne turneringen" (skal ikke telle som et 0-resultat, kalleren
@ -495,13 +749,23 @@ def _contribution_for_entry(entry, result_type: str, points_table, money_payout_
poeng- eller pengeliste-basert og den lenkede turneringen bruker en poeng- eller pengeliste-basert og den lenkede turneringen bruker en
annen scoring_method (København/BBB/Flagg), er det bevisst ingen annen scoring_method (København/BBB/Flagg), er det bevisst ingen
posisjon å hente poeng/penger fra -- spilleren telles fortsatt som posisjon å hente poeng/penger fra -- spilleren telles fortsatt som
"deltok" (rounds_played > 0) men bidrar med 0, ikke ekskludert helt.""" "deltok" (rounds_played > 0) men bidrar med 0, ikke ekskludert helt.
Bug fikset 2026-08-15 (ADR-074): for en lenket turnering med
scoring_method IN eclectic_gross/net/stableford, skal
stableford/gross/net-resultattype-OOM-er lese `entry.eclectic_total`
(turneringens FAKTISKE "drømmerunde"-resultat, det som avgjorde DEN
turneringens egen vinner) -- IKKE `entry.stableford_total`/
`gross_total`/`net_total` ( sum tvers av rundene, et tall som
aldri var det som ble kåret som resultat i den turneringen)."""
if entry.rounds_played == 0: if entry.rounds_played == 0:
return None return None
if result_type == "points": if result_type == "points":
return float(order_of_merit_points_for_position(entry.position, points_table)) if entry.position and points_table else 0.0 return float(order_of_merit_points_for_position(entry.position, points_table)) if entry.position and points_table else 0.0
if result_type == "money": if result_type == "money":
return _money_for_position(entry.position, money_payout_table) if entry.position and money_payout_table else 0.0 return _money_for_position(entry.position, money_payout_table) if entry.position and money_payout_table else 0.0
if scoring_method in _ECLECTIC_METHODS and result_type in ("stableford", "gross", "net"):
return float(entry.eclectic_total) if entry.eclectic_total is not None else 0.0
if result_type == "stableford": if result_type == "stableford":
return float(entry.stableford_total) if entry.stableford_total is not None else 0.0 return float(entry.stableford_total) if entry.stableford_total is not None else 0.0
if result_type == "gross": if result_type == "gross":

View file

@ -55,6 +55,12 @@ type ApiLeaderboardEntry = {
type ApiTournament = { id: string; name: string; format_type: string } type ApiTournament = { id: string; name: string; format_type: string }
// Lag-OOM (ADR-043 Beslutning C, ADR-074) -- sesong-par satt opp DIREKTE i
// OOM-en, ikke turnering-lag. Kun relevant når oom.kind === "team".
type ApiOomTeamMember = { id: string; player_id: string; player_name: string }
type ApiOomTeam = { id: string; name: string; members: ApiOomTeamMember[] }
type ApiPlayer = { id: string; display_name: string }
const KIND_LABELS: Record<OomKind, string> = { player: "Spiller", team: "Lag" } const KIND_LABELS: Record<OomKind, string> = { player: "Spiller", team: "Lag" }
const RESULT_TYPE_LABELS: Record<OomResultType, string> = { const RESULT_TYPE_LABELS: Record<OomResultType, string> = {
points: "Poeng etter plassering", points: "Poeng etter plassering",
@ -83,6 +89,8 @@ export function OrderOfMeritDetail({
const [links, setLinks] = useState<ApiLink[] | null>(null) const [links, setLinks] = useState<ApiLink[] | null>(null)
const [leaderboard, setLeaderboard] = useState<ApiLeaderboardEntry[] | null>(null) const [leaderboard, setLeaderboard] = useState<ApiLeaderboardEntry[] | null>(null)
const [allTournaments, setAllTournaments] = useState<ApiTournament[]>([]) const [allTournaments, setAllTournaments] = useState<ApiTournament[]>([])
const [teams, setTeams] = useState<ApiOomTeam[] | null>(null)
const [players, setPlayers] = useState<ApiPlayer[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [notFound, setNotFound] = useState(false) const [notFound, setNotFound] = useState(false)
@ -114,6 +122,16 @@ export function OrderOfMeritDetail({
if (res.ok) setAllTournaments(await res.json()) if (res.ok) setAllTournaments(await res.json())
} }
async function loadTeams() {
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams`, { credentials: "include" })
if (res.ok) setTeams(await res.json())
}
async function loadPlayers() {
const res = await fetch(`/orgs/${organizationId}/players`, { credentials: "include" })
if (res.ok) setPlayers(await res.json())
}
useEffect(() => { useEffect(() => {
void loadOom() void loadOom()
void loadLinks() void loadLinks()
@ -122,7 +140,12 @@ export function OrderOfMeritDetail({
}, [organizationId, oomId]) }, [organizationId, oomId])
useEffect(() => { useEffect(() => {
if (oom?.kind === "player") void loadLeaderboard() if (!oom) return
void loadLeaderboard()
if (oom.kind === "team") {
void loadTeams()
void loadPlayers()
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [oom?.kind, links]) }, [oom?.kind, links])
@ -160,6 +183,65 @@ export function OrderOfMeritDetail({
setLinks((prev) => (prev ?? []).filter((l) => l.id !== linkId)) setLinks((prev) => (prev ?? []).filter((l) => l.id !== linkId))
} }
async function createTeam(name: string) {
setError(null)
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name }),
})
if (!res.ok) {
setError("Klarte ikke å opprette laget.")
return
}
await loadTeams()
}
async function deleteTeam(teamId: string) {
setError(null)
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams/${teamId}`, {
method: "DELETE",
credentials: "include",
})
if (res.status !== 204) {
setError("Klarte ikke å slette laget.")
return
}
setTeams((prev) => (prev ?? []).filter((t) => t.id !== teamId))
await loadLeaderboard()
}
async function addMember(teamId: string, playerId: string) {
setError(null)
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams/${teamId}/members`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ player_id: playerId }),
})
if (!res.ok) {
setError("Klarte ikke å legge til spilleren.")
return
}
await loadTeams()
await loadLeaderboard()
}
async function removeMember(teamId: string, memberId: string) {
setError(null)
const res = await fetch(
`/orgs/${organizationId}/order-of-merits/${oomId}/teams/${teamId}/members/${memberId}`,
{ method: "DELETE", credentials: "include" },
)
if (res.status !== 204) {
setError("Klarte ikke å fjerne spilleren.")
return
}
await loadTeams()
await loadLeaderboard()
}
async function saveSettings(patch: Record<string, unknown>) { async function saveSettings(patch: Record<string, unknown>) {
setError(null) setError(null)
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}`, { const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}`, {
@ -257,7 +339,18 @@ export function OrderOfMeritDetail({
<SettingsSection oom={oom} onSave={saveSettings} /> <SettingsSection oom={oom} onSave={saveSettings} />
<LeaderboardSection kind={oom.kind} resultType={oom.result_type} entries={leaderboard} /> {oom.kind === "team" && (
<TeamsSection
teams={teams}
players={players}
onCreateTeam={createTeam}
onDeleteTeam={deleteTeam}
onAddMember={addMember}
onRemoveMember={removeMember}
/>
)}
<LeaderboardSection resultType={oom.result_type} entries={leaderboard} />
</main> </main>
</div> </div>
) )
@ -651,11 +744,9 @@ function SettingsSegment({ selected, onClick, children }: { selected: boolean; o
// --- D. Resultatliste ---------------------------------------------------------- // --- D. Resultatliste ----------------------------------------------------------
function LeaderboardSection({ function LeaderboardSection({
kind,
resultType, resultType,
entries, entries,
}: { }: {
kind: OomKind
resultType: OomResultType resultType: OomResultType
entries: ApiLeaderboardEntry[] | null entries: ApiLeaderboardEntry[] | null
}) { }) {
@ -668,12 +759,7 @@ function LeaderboardSection({
<h2 className="text-base font-bold text-foreground">Resultatliste</h2> <h2 className="text-base font-bold text-foreground">Resultatliste</h2>
</div> </div>
{kind === "team" ? ( {entries === null ? (
<div className="rounded-2xl border border-dashed border-border bg-muted/40 px-4 py-6 text-center">
<Users aria-hidden="true" className="mx-auto mb-2 size-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Lag-resultatliste er ikke bygget ennå.</p>
</div>
) : entries === null ? (
<p className="text-sm text-muted-foreground">Laster</p> <p className="text-sm text-muted-foreground">Laster</p>
) : ( ) : (
<LeaderboardTable entries={entries} resultType={resultType} /> <LeaderboardTable entries={entries} resultType={resultType} />
@ -682,6 +768,193 @@ function LeaderboardSection({
) )
} }
// --- C.5 Lag (kun kind === "team", ADR-043 Beslutning C, ADR-074) ----------
// Sesong-par satt opp DIREKTE i OOM-en -- IKKE turnering-lag. Samme
// liste+skjema-mønster som "Lenkede turneringer" over.
function TeamsSection({
teams,
players,
onCreateTeam,
onDeleteTeam,
onAddMember,
onRemoveMember,
}: {
teams: ApiOomTeam[] | null
players: ApiPlayer[]
onCreateTeam: (name: string) => Promise<void>
onDeleteTeam: (teamId: string) => Promise<void>
onAddMember: (teamId: string, playerId: string) => Promise<void>
onRemoveMember: (teamId: string, memberId: string) => Promise<void>
}) {
const [adding, setAdding] = useState(false)
const [name, setName] = useState("")
const [submitting, setSubmitting] = useState(false)
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
if (!name.trim() || submitting) return
setSubmitting(true)
try {
await onCreateTeam(name.trim())
setName("")
setAdding(false)
} finally {
setSubmitting(false)
}
}
return (
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<Users aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<h2 className="text-base font-bold text-foreground">Lag</h2>
</div>
{teams === null ? (
<p className="text-sm text-muted-foreground">Laster</p>
) : teams.length === 0 ? (
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Ingen lag opprettet ennå -- lag er sesong-par satt opp direkte her, ikke koblet til noe turnering-lag.
</p>
) : (
<ul className="flex flex-col gap-3">
{teams.map((t) => (
<TeamCard
key={t.id}
team={t}
players={players}
onDelete={() => onDeleteTeam(t.id)}
onAddMember={(playerId) => onAddMember(t.id, playerId)}
onRemoveMember={(memberId) => onRemoveMember(t.id, memberId)}
/>
))}
</ul>
)}
{!adding ? (
<Button
type="button"
variant="outline"
onClick={() => setAdding(true)}
className="h-11 self-start rounded-xl text-sm font-bold"
>
<Plus aria-hidden="true" className="size-4" />
Nytt lag
</Button>
) : (
<form onSubmit={handleCreate} className="flex items-center gap-2 rounded-2xl border border-border bg-background p-3">
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Lagnavn"
className="h-10 flex-1 rounded-lg text-sm"
/>
<Button type="submit" disabled={!name.trim() || submitting} className="h-10 rounded-lg text-sm font-bold">
{submitting ? "Oppretter…" : "Opprett"}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setAdding(false)
setName("")
}}
className="h-10 rounded-lg text-sm font-semibold text-muted-foreground"
>
Avbryt
</Button>
</form>
)}
</section>
)
}
function TeamCard({
team,
players,
onDelete,
onAddMember,
onRemoveMember,
}: {
team: ApiOomTeam
players: ApiPlayer[]
onDelete: () => Promise<void>
onAddMember: (playerId: string) => Promise<void>
onRemoveMember: (memberId: string) => Promise<void>
}) {
const memberPlayerIds = new Set(team.members.map((m) => m.player_id))
const availablePlayers = players.filter((p) => !memberPlayerIds.has(p.id))
const [selectedPlayerId, setSelectedPlayerId] = useState("")
return (
<li className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-bold text-foreground">{team.name}</span>
<Button
type="button"
variant="ghost"
onClick={onDelete}
className="h-9 shrink-0 rounded-lg px-3 text-xs font-bold text-muted-foreground hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
Slett lag
</Button>
</div>
{team.members.length > 0 && (
<ul className="flex flex-col divide-y divide-border rounded-xl border border-border">
{team.members.map((m) => (
<li key={m.id} className="flex items-center justify-between gap-3 px-3 py-2">
<span className="text-sm text-foreground">{m.player_name}</span>
<Button
type="button"
variant="ghost"
onClick={() => onRemoveMember(m.id)}
className="h-8 shrink-0 rounded-lg px-2 text-xs font-semibold text-muted-foreground hover:text-destructive"
>
Fjern
</Button>
</li>
))}
</ul>
)}
{availablePlayers.length > 0 && (
<div className="flex items-center gap-2">
<select
value={selectedPlayerId}
onChange={(e) => setSelectedPlayerId(e.target.value)}
className="h-10 flex-1 rounded-lg border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
>
<option value="">Velg spiller</option>
{availablePlayers.map((p) => (
<option key={p.id} value={p.id}>
{p.display_name}
</option>
))}
</select>
<Button
type="button"
variant="outline"
disabled={!selectedPlayerId}
onClick={() => {
void onAddMember(selectedPlayerId)
setSelectedPlayerId("")
}}
className="h-10 shrink-0 rounded-lg text-sm font-semibold"
>
Legg til
</Button>
</div>
)}
</li>
)
}
function LeaderboardTable({ entries, resultType }: { entries: ApiLeaderboardEntry[]; resultType: OomResultType }) { function LeaderboardTable({ entries, resultType }: { entries: ApiLeaderboardEntry[]; resultType: OomResultType }) {
const eligible = entries.filter((e) => e.eligible) const eligible = entries.filter((e) => e.eligible)
const ineligible = entries.filter((e) => !e.eligible) const ineligible = entries.filter((e) => !e.eligible)

View file

@ -0,0 +1,257 @@
"""
Order of Merit (ADR-043) -- Steg 0/1/2-tester (ADR-074/075). Kaller de
faktiske router-funksjonene direkte (samme mønster som
test_tournament_hole_stats.py/test_hole_history.py).
"""
import pytest
from fastapi import HTTPException
from app.auth import CurrentUser
from app.routers.individual_tournaments import (
HoleUpdate as TournamentHoleUpdate,
update_hole as tournament_update_hole,
)
from app.routers.order_of_merit import (
OrderOfMeritCreate,
OrderOfMeritLinkCreate,
OrderOfMeritTeamCreate,
OrderOfMeritTeamMemberCreate,
add_order_of_merit_team_member,
create_order_of_merit,
create_order_of_merit_team,
delete_order_of_merit_team,
link_tournament_to_order_of_merit,
list_order_of_merit_teams,
order_of_merit_leaderboard,
remove_order_of_merit_team_member,
)
from tests.conftest import (
add_membership,
create_org,
create_org_hole,
create_course,
create_player,
create_tee,
create_tournament,
create_tournament_participant,
create_tournament_round,
create_tournament_round_participant,
create_user,
)
import app.db as app_db
async def _score_all_holes(tournament_id, round_id, rp_id, org_id, user_id, score):
user = CurrentUser(user_id=user_id)
for n in range(1, 19):
await tournament_update_hole(
tournament_id, round_id, rp_id, n,
TournamentHoleUpdate(gross_strokes=score, expected_version=None),
organization_id=org_id, user=user,
)
async def _setup_eclectic_tournament(org_id, user_id, method="eclectic_stableford"):
tournament_id = await create_tournament(org_id, name="Eclectic OOM-turnering")
course_id = await create_course(org_id, name="OOM Links")
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)
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"UPDATE tournament SET format_type = 'individual', scoring_method = $2 WHERE id = $1",
tournament_id, method,
)
player_id = await create_player(org_id, user_id=user_id, display_name="OOM Spiller")
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
round1_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
round2_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=2)
rp1_id = await create_tournament_round_participant(org_id, round1_id, tp_id, tee_id, course_handicap=0)
rp2_id = await create_tournament_round_participant(org_id, round2_id, tp_id, tee_id, course_handicap=0)
# Runde 1: par (4) på alle 18 hull -> 2 poeng/hull = 36 Stableford-poeng.
await _score_all_holes(tournament_id, round1_id, rp1_id, org_id, user_id, score=4)
# Runde 2: bogey (5) på alle 18 hull -> 1 poeng/hull = 18 Stableford-poeng.
await _score_all_holes(tournament_id, round2_id, rp2_id, org_id, user_id, score=5)
# Rå SUM over begge runder = 36 + 18 = 54. Eclectic (beste PER HULL,
# runde 1 vinner hver gang) = 18 hull * 2 poeng = 36 -- tydelig
# forskjellig fra rå-summen, beviser at riktig tall faktisk brukes.
return tournament_id, player_id
async def test_oom_stableford_uses_eclectic_total_not_raw_sum(pool):
"""ADR-074: en lenket eclectic_stableford-turnering skal bidra med
entry.eclectic_total (36), IKKE stableford_total tvers av
begge runder (54)."""
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, player_id = await _setup_eclectic_tournament(org_id, owner_id)
oom = await create_order_of_merit(
OrderOfMeritCreate(name="Sesong-OOM", kind="player", result_type="stableford", aggregation_mode="sum"),
organization_id=org_id,
)
await link_tournament_to_order_of_merit(
oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_id), organization_id=org_id
)
leaderboard = await order_of_merit_leaderboard(oom.id, organization_id=org_id)
assert len(leaderboard) == 1
assert leaderboard[0].value == 36.0
assert leaderboard[0].value != 54.0
# ---------------------------------------------------------------------------
# Steg 1 (ADR-074): lag-OOM CRUD + leaderboard.
# ---------------------------------------------------------------------------
async def _setup_stroke_gross_tournament(org_id, name, participants: list[tuple[str, int]]):
"""participants: liste av (player_id, gross-score-per-hull) -- ETT
18-hulls slagspill-runde, samme score alle 18 hull for enkelhets
skyld."""
tournament_id = await create_tournament(org_id, name=name)
course_id = await create_course(org_id, name=f"{name} 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)
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, score 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_id = None
async with app_db.org_connection(org_id) as conn:
user_id = await conn.fetchval("SELECT user_id::text FROM player WHERE id = $1", player_id)
await _score_all_holes(tournament_id, round_id, rp_id, org_id, user_id, score=score)
return tournament_id
async def test_team_leaderboard_sums_member_individual_totals(pool):
"""To turneringer lenket til en gross/sum lag-OOM. Spiller 1: 72+90=162.
Spiller 2: 90+108=198. Ett lag med begge -> forventet lagsum 360."""
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
user1_id = await create_user(display_name="Spiller Én")
user2_id = await create_user(display_name="Spiller To")
player1_id = await create_player(org_id, user_id=user1_id, display_name="Spiller Én")
player2_id = await create_player(org_id, user_id=user2_id, display_name="Spiller To")
tournament_a = await _setup_stroke_gross_tournament(
org_id, "Turnering A", [(player1_id, 4), (player2_id, 5)]
)
tournament_b = await _setup_stroke_gross_tournament(
org_id, "Turnering B", [(player1_id, 5), (player2_id, 6)]
)
oom = await create_order_of_merit(
OrderOfMeritCreate(name="Lag-OOM", kind="team", 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
)
team = await create_order_of_merit_team(
oom.id, OrderOfMeritTeamCreate(name="Laget"), organization_id=org_id
)
await add_order_of_merit_team_member(
oom.id, team.id, OrderOfMeritTeamMemberCreate(player_id=player1_id), organization_id=org_id
)
await add_order_of_merit_team_member(
oom.id, team.id, OrderOfMeritTeamMemberCreate(player_id=player2_id), organization_id=org_id
)
leaderboard = await order_of_merit_leaderboard(oom.id, organization_id=org_id)
assert len(leaderboard) == 1
assert leaderboard[0].player_id == team.id
assert leaderboard[0].player_name == "Laget"
assert leaderboard[0].value == 360.0
assert leaderboard[0].results_available == 2
assert leaderboard[0].results_counted == 2
assert leaderboard[0].eligible is True
async def test_team_leaderboard_best_n_applies_at_both_levels(pool):
"""Samme oppsett som over, men count_best_n=1. ADR-074: ETT lagret
aggregeringsvalg styrer BEGGE nivåer -- count_best_n=1 betyr derfor
"hver spillers beste 1 av 2 turneringer" OG "lagets beste 1 av 2
medlemmer", ikke bare det ene. Spiller 1 sin beste (laveste) turnering
er 72, spiller 2 sin er 90 -- laget teller da KUN 72 (sitt beste
medlem, som allerede er redusert til sitt eget beste resultat)."""
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
user1_id = await create_user()
user2_id = await create_user()
player1_id = await create_player(org_id, user_id=user1_id)
player2_id = await create_player(org_id, user_id=user2_id)
tournament_a = await _setup_stroke_gross_tournament(org_id, "A", [(player1_id, 4), (player2_id, 5)])
tournament_b = await _setup_stroke_gross_tournament(org_id, "B", [(player1_id, 5), (player2_id, 6)])
oom = await create_order_of_merit(
OrderOfMeritCreate(
name="Lag-OOM beste-1", kind="team", result_type="gross", aggregation_mode="sum", count_best_n=1
),
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)
team = await create_order_of_merit_team(oom.id, OrderOfMeritTeamCreate(name="Laget"), organization_id=org_id)
await add_order_of_merit_team_member(oom.id, team.id, OrderOfMeritTeamMemberCreate(player_id=player1_id), organization_id=org_id)
await add_order_of_merit_team_member(oom.id, team.id, OrderOfMeritTeamMemberCreate(player_id=player2_id), organization_id=org_id)
leaderboard = await order_of_merit_leaderboard(oom.id, organization_id=org_id)
assert leaderboard[0].value == 72.0
assert leaderboard[0].results_counted == 1
async def test_team_crud_and_kind_guard(pool):
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
player_id = await create_player(org_id)
player_oom = await create_order_of_merit(
OrderOfMeritCreate(name="Spiller-OOM", kind="player", result_type="gross"), organization_id=org_id
)
with pytest.raises(HTTPException) as exc_info:
await create_order_of_merit_team(
player_oom.id, OrderOfMeritTeamCreate(name="Skal feile"), organization_id=org_id
)
assert exc_info.value.status_code == 400
team_oom = await create_order_of_merit(
OrderOfMeritCreate(name="Lag-OOM", kind="team", result_type="gross"), organization_id=org_id
)
team = await create_order_of_merit_team(team_oom.id, OrderOfMeritTeamCreate(name="Laget"), organization_id=org_id)
member = await add_order_of_merit_team_member(
team_oom.id, team.id, OrderOfMeritTeamMemberCreate(player_id=player_id), organization_id=org_id
)
teams = await list_order_of_merit_teams(team_oom.id, organization_id=org_id)
assert len(teams) == 1
assert len(teams[0].members) == 1
assert teams[0].members[0].player_id == player_id
await remove_order_of_merit_team_member(team_oom.id, team.id, member.id, organization_id=org_id)
teams = await list_order_of_merit_teams(team_oom.id, organization_id=org_id)
assert len(teams[0].members) == 0
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