Bygg Order of Merit: sesong-sammenlagt spillerrangering på tvers av turneringer
Ny funksjon (ADR-043, migrasjon 055): organisatorer kan opprette en
Order of Merit, lenke individuelle turneringer til den, og få en
sesong-sammenlagt rangering regnet ut ved lesing -- fem resultattyper
(poeng etter plassering, Stableford-, brutto- og nettoscore-sum,
pengeliste), sum/snitt-aggregering, med valgfri behold-N-beste,
minimum antall resultater og aldersgrense. Motor i handicap_engine.py
(9 nye enhetstester), ny app/routers/order_of_merit.py, og to nye
håndkodede frontend-sider under /organizations/[id]/order-of-merit.
Eclectic-aggregering og lag-OOM sin faktiske resultatberegning er
bevisst ikke bygget denne runden (skjema/CRUD for lag finnes, men
leaderboardet avviser kind='team' med en tydelig feilmelding).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:19:30 +02:00
|
|
|
"""
|
|
|
|
|
Order of Merit (ADR-043 [OOM]): sesong-sammenlagt rangering på tvers av
|
|
|
|
|
FLERE individuelle turneringer (`tournament.format_type = 'individual'`,
|
|
|
|
|
ADR-037) -- IKKE org-lagturneringer (ADR-011, poeng er der knyttet til
|
|
|
|
|
hele kamper, ikke enkeltspillere, samme begrensning som
|
|
|
|
|
konkurranseklassenes leaderboard-splitting, ADR-041).
|
|
|
|
|
|
|
|
|
|
To uavhengige akser (GolfBox-inspirert, se plan-loggen 2026-08-04):
|
|
|
|
|
HVA telles per lenket turnering (`result_type`: poeng-etter-plassering/
|
|
|
|
|
Stableford-sum/brutto/netto/pengeliste) og HVORDAN det summeres over
|
|
|
|
|
flere turneringer (`aggregation_mode`: sum/snitt/eclectic -- eclectic
|
|
|
|
|
ikke bygget i denne runden, se FEATURE_BACKLOG.md).
|
|
|
|
|
|
|
|
|
|
Gjenbruker `_compute_individual_standings` fra individual_tournaments.py
|
|
|
|
|
for HVER lenket turnering sin egen leaderboard/plassering -- ingen
|
|
|
|
|
duplisering av posisjon-/til-par-beregningen. "Regn ut ved lesing" for
|
|
|
|
|
selve OOM-leaderboardet også (samme filosofi som Nassau/High-low-high/
|
|
|
|
|
individual_leaderboard selv) -- ingen cache-tabell.
|
|
|
|
|
|
|
|
|
|
Bygget i stadier (se plan-loggen): denne runden dekker spiller-OOM med
|
|
|
|
|
poeng/stableford/brutto/netto/pengeliste-resultattyper, sum/snitt-
|
|
|
|
|
aggregering, og behold-N-beste/minimum-resultater/aldersgrense-grenser.
|
|
|
|
|
Lag-OOM og Eclectic-aggregering er bevisst IKKE bygget ennå (egne,
|
|
|
|
|
senere stadier) -- se FEATURE_BACKLOG.md.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
from typing import Literal
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends
|
|
|
|
|
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 .individual_tournaments import _compute_individual_standings
|
|
|
|
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
_RESULT_TYPES = Literal["points", "stableford", "gross", "net", "money"]
|
|
|
|
|
_AGGREGATION_MODES = Literal["sum", "average", "eclectic"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# CRUD: selve OOM-en
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
class OrderOfMeritCreate(BaseModel):
|
|
|
|
|
name: str = Field(min_length=1, max_length=200)
|
|
|
|
|
kind: Literal["player", "team"]
|
|
|
|
|
result_type: _RESULT_TYPES
|
|
|
|
|
aggregation_mode: _AGGREGATION_MODES = "sum"
|
|
|
|
|
count_best_n: int | None = Field(default=None, ge=1)
|
|
|
|
|
min_results_required: int | None = Field(default=None, ge=1)
|
|
|
|
|
birth_year_from: int | None = None
|
|
|
|
|
birth_year_to: int | None = None
|
|
|
|
|
public_visible: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OrderOfMeritUpdate(BaseModel):
|
|
|
|
|
"""Alle felt valgfrie -- exclude_unset, samme PATCH-semantikk som
|
|
|
|
|
TournamentUpdate/SponsorUpdate/TournamentClassUpdate.
|
|
|
|
|
|
|
|
|
|
`kind` og `result_type` er IKKE med -- begge låses etter opprettelse.
|
|
|
|
|
`kind` per plan (matcher GolfBox sin egen tilsvarende låsing).
|
|
|
|
|
`result_type` er strukket til samme regel her: å endre den i
|
|
|
|
|
etterkant ville gjort eksisterende lenkers `points_table`/
|
|
|
|
|
`money_payout_table`-konfig meningsløs uten en egen migreringssti --
|
|
|
|
|
opprett en ny OOM i stedet."""
|
|
|
|
|
|
|
|
|
|
name: str | None = Field(default=None, min_length=1, max_length=200)
|
|
|
|
|
aggregation_mode: _AGGREGATION_MODES | None = None
|
|
|
|
|
count_best_n: int | None = None
|
|
|
|
|
min_results_required: int | None = None
|
|
|
|
|
birth_year_from: int | None = None
|
|
|
|
|
birth_year_to: int | None = None
|
|
|
|
|
public_visible: bool | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OrderOfMeritOut(BaseModel):
|
|
|
|
|
id: str
|
|
|
|
|
name: str
|
|
|
|
|
kind: str
|
|
|
|
|
result_type: str
|
|
|
|
|
aggregation_mode: str
|
|
|
|
|
count_best_n: int | None
|
|
|
|
|
min_results_required: int | None
|
|
|
|
|
birth_year_from: int | None
|
|
|
|
|
birth_year_to: int | None
|
|
|
|
|
public_visible: bool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_OOM_COLUMNS = """
|
|
|
|
|
id::text, name, kind, result_type, aggregation_mode, count_best_n,
|
|
|
|
|
min_results_required, birth_year_from, birth_year_to, public_visible
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/orgs/{organization_id}/order-of-merits", response_model=list[OrderOfMeritOut])
|
|
|
|
|
async def list_order_of_merits(
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> list[OrderOfMeritOut]:
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
rows = await conn.fetch(f"SELECT {_OOM_COLUMNS} FROM order_of_merit ORDER BY created_at DESC")
|
|
|
|
|
return [OrderOfMeritOut(**dict(r)) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/orgs/{organization_id}/order-of-merits", response_model=OrderOfMeritOut, status_code=201)
|
|
|
|
|
async def create_order_of_merit(
|
|
|
|
|
body: OrderOfMeritCreate,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> OrderOfMeritOut:
|
|
|
|
|
if body.kind == "team" and body.aggregation_mode == "eclectic":
|
|
|
|
|
raise app_error(400, "VALIDATION_FAILED", "Eclectic er ikke støttet for lag-OOM ennå.")
|
|
|
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
f"""
|
|
|
|
|
INSERT INTO order_of_merit
|
|
|
|
|
(organization_id, name, kind, result_type, aggregation_mode,
|
|
|
|
|
count_best_n, min_results_required, birth_year_from, birth_year_to, public_visible)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
|
|
|
RETURNING {_OOM_COLUMNS}
|
|
|
|
|
""",
|
|
|
|
|
organization_id,
|
|
|
|
|
body.name,
|
|
|
|
|
body.kind,
|
|
|
|
|
body.result_type,
|
|
|
|
|
body.aggregation_mode,
|
|
|
|
|
body.count_best_n,
|
|
|
|
|
body.min_results_required,
|
|
|
|
|
body.birth_year_from,
|
|
|
|
|
body.birth_year_to,
|
|
|
|
|
body.public_visible,
|
|
|
|
|
)
|
|
|
|
|
return OrderOfMeritOut(**dict(row))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/orgs/{organization_id}/order-of-merits/{oom_id}", response_model=OrderOfMeritOut)
|
|
|
|
|
async def update_order_of_merit(
|
|
|
|
|
oom_id: str,
|
|
|
|
|
body: OrderOfMeritUpdate,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> OrderOfMeritOut:
|
|
|
|
|
updates = body.model_dump(exclude_unset=True)
|
|
|
|
|
if not updates:
|
|
|
|
|
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
|
|
|
|
|
|
|
|
|
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
|
|
|
|
|
values = list(updates.values())
|
|
|
|
|
values.append(oom_id)
|
|
|
|
|
|
|
|
|
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
|
|
|
existing = await conn.fetchrow("SELECT kind, result_type FROM order_of_merit WHERE id = $1", oom_id)
|
|
|
|
|
if existing is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
|
|
|
|
if updates.get("aggregation_mode") == "eclectic" and existing["kind"] == "team":
|
|
|
|
|
raise app_error(400, "VALIDATION_FAILED", "Eclectic er ikke støttet for lag-OOM ennå.")
|
|
|
|
|
result_type = existing["result_type"]
|
|
|
|
|
effective_mode = updates.get("aggregation_mode")
|
|
|
|
|
if effective_mode == "eclectic" and result_type not in ("stableford", "gross", "net"):
|
|
|
|
|
raise app_error(
|
|
|
|
|
400, "VALIDATION_FAILED", "Eclectic krever resultattype Stableford, brutto eller netto."
|
|
|
|
|
)
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
f"""
|
|
|
|
|
UPDATE order_of_merit SET {', '.join(set_clauses)}, updated_at = now()
|
|
|
|
|
WHERE id = ${len(values)}
|
|
|
|
|
RETURNING {_OOM_COLUMNS}
|
|
|
|
|
""",
|
|
|
|
|
*values,
|
|
|
|
|
)
|
|
|
|
|
return OrderOfMeritOut(**dict(row))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/orgs/{organization_id}/order-of-merits/{oom_id}", status_code=204)
|
|
|
|
|
async def delete_order_of_merit(
|
|
|
|
|
oom_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 WHERE id = $1", oom_id)
|
|
|
|
|
if result == "DELETE 0":
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Lenkede turneringer
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
class OrderOfMeritLinkCreate(BaseModel):
|
|
|
|
|
tournament_id: str
|
|
|
|
|
# Kun gyldig (og påkrevd) når OOM-ens result_type='points': liste av
|
|
|
|
|
# heltall, indeks 0 = poeng for 1. plass.
|
|
|
|
|
points_table: list[int] | None = None
|
|
|
|
|
# Kun gyldig (og påkrevd) når result_type='money'.
|
|
|
|
|
money_pool_total: float | None = None
|
|
|
|
|
money_payout_table: list[dict] | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OrderOfMeritLinkOut(BaseModel):
|
|
|
|
|
id: str
|
|
|
|
|
tournament_id: str
|
|
|
|
|
tournament_name: str
|
|
|
|
|
points_table: list[int] | None
|
|
|
|
|
money_pool_total: float | None
|
|
|
|
|
money_payout_table: list[dict] | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_LINK_COLUMNS = """
|
|
|
|
|
l.id::text, l.tournament_id::text, t.name AS tournament_name,
|
|
|
|
|
l.points_table::text AS points_table, l.money_pool_total,
|
|
|
|
|
l.money_payout_table::text AS money_payout_table
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
"/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments",
|
|
|
|
|
response_model=list[OrderOfMeritLinkOut],
|
|
|
|
|
)
|
|
|
|
|
async def list_order_of_merit_links(
|
|
|
|
|
oom_id: str,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> list[OrderOfMeritLinkOut]:
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
rows = await conn.fetch(
|
|
|
|
|
f"""
|
|
|
|
|
SELECT {_LINK_COLUMNS}
|
|
|
|
|
FROM order_of_merit_link l
|
|
|
|
|
JOIN tournament t ON t.id = l.tournament_id
|
|
|
|
|
WHERE l.order_of_merit_id = $1
|
|
|
|
|
ORDER BY l.created_at
|
|
|
|
|
""",
|
|
|
|
|
oom_id,
|
|
|
|
|
)
|
|
|
|
|
return [OrderOfMeritLinkOut(**_parse_link_row(r)) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_link_row(r) -> dict:
|
|
|
|
|
d = dict(r)
|
|
|
|
|
d["points_table"] = json.loads(d["points_table"]) if d.get("points_table") else None
|
|
|
|
|
d["money_payout_table"] = json.loads(d["money_payout_table"]) if d.get("money_payout_table") else None
|
|
|
|
|
if d.get("money_pool_total") is not None:
|
|
|
|
|
d["money_pool_total"] = float(d["money_pool_total"])
|
|
|
|
|
return d
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments",
|
|
|
|
|
response_model=OrderOfMeritLinkOut,
|
|
|
|
|
status_code=201,
|
|
|
|
|
)
|
|
|
|
|
async def link_tournament_to_order_of_merit(
|
|
|
|
|
oom_id: str,
|
|
|
|
|
body: OrderOfMeritLinkCreate,
|
|
|
|
|
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)
|
|
|
|
|
if oom is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
|
|
|
|
tournament = await conn.fetchrow(
|
|
|
|
|
"SELECT format_type FROM tournament WHERE id = $1", body.tournament_id
|
|
|
|
|
)
|
|
|
|
|
if tournament is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
|
|
|
|
if tournament["format_type"] != "individual":
|
|
|
|
|
raise app_error(
|
|
|
|
|
400, "VALIDATION_FAILED", "Kun individuelle turneringer kan lenkes til en Order of Merit."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result_type = oom["result_type"]
|
|
|
|
|
if result_type == "points" and not body.points_table:
|
|
|
|
|
raise app_error(400, "VALIDATION_FAILED", "points_table er påkrevd for en poeng-basert Order of Merit.")
|
|
|
|
|
if result_type != "points" and body.points_table:
|
|
|
|
|
raise app_error(400, "VALIDATION_FAILED", "points_table er kun gyldig for en poeng-basert Order of Merit.")
|
|
|
|
|
if result_type == "money" and not (body.money_pool_total and body.money_payout_table):
|
|
|
|
|
raise app_error(
|
|
|
|
|
400, "VALIDATION_FAILED", "money_pool_total og money_payout_table er påkrevd for en pengeliste-basert Order of Merit."
|
|
|
|
|
)
|
|
|
|
|
if result_type != "money" and (body.money_pool_total or body.money_payout_table):
|
|
|
|
|
raise app_error(400, "VALIDATION_FAILED", "Pengefelt er kun gyldig for en pengeliste-basert Order of Merit.")
|
|
|
|
|
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
WITH inserted AS (
|
|
|
|
|
INSERT INTO order_of_merit_link
|
|
|
|
|
(organization_id, order_of_merit_id, tournament_id,
|
|
|
|
|
points_table, money_pool_total, money_payout_table)
|
|
|
|
|
VALUES ($1, $2, $3, $4::jsonb, $5, $6::jsonb)
|
|
|
|
|
RETURNING id, tournament_id, points_table, money_pool_total, money_payout_table
|
|
|
|
|
)
|
|
|
|
|
SELECT inserted.id::text, inserted.tournament_id::text, t.name AS tournament_name,
|
|
|
|
|
inserted.points_table::text AS points_table, inserted.money_pool_total,
|
|
|
|
|
inserted.money_payout_table::text AS money_payout_table
|
|
|
|
|
FROM inserted JOIN tournament t ON t.id = inserted.tournament_id
|
|
|
|
|
""",
|
|
|
|
|
organization_id,
|
|
|
|
|
oom_id,
|
|
|
|
|
body.tournament_id,
|
|
|
|
|
json.dumps(body.points_table) if body.points_table else None,
|
|
|
|
|
body.money_pool_total,
|
|
|
|
|
json.dumps(body.money_payout_table) if body.money_payout_table else None,
|
|
|
|
|
)
|
|
|
|
|
return OrderOfMeritLinkOut(**_parse_link_row(row))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete(
|
|
|
|
|
"/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments/{link_id}",
|
|
|
|
|
status_code=204,
|
|
|
|
|
)
|
|
|
|
|
async def unlink_tournament_from_order_of_merit(
|
|
|
|
|
oom_id: str,
|
|
|
|
|
link_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_link WHERE id = $1 AND order_of_merit_id = $2", link_id, oom_id
|
|
|
|
|
)
|
|
|
|
|
if result == "DELETE 0":
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Lenken finnes ikke.")
|
|
|
|
|
|
|
|
|
|
|
2026-08-15 16:52:37 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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.")
|
|
|
|
|
|
|
|
|
|
|
Bygg Order of Merit: sesong-sammenlagt spillerrangering på tvers av turneringer
Ny funksjon (ADR-043, migrasjon 055): organisatorer kan opprette en
Order of Merit, lenke individuelle turneringer til den, og få en
sesong-sammenlagt rangering regnet ut ved lesing -- fem resultattyper
(poeng etter plassering, Stableford-, brutto- og nettoscore-sum,
pengeliste), sum/snitt-aggregering, med valgfri behold-N-beste,
minimum antall resultater og aldersgrense. Motor i handicap_engine.py
(9 nye enhetstester), ny app/routers/order_of_merit.py, og to nye
håndkodede frontend-sider under /organizations/[id]/order-of-merit.
Eclectic-aggregering og lag-OOM sin faktiske resultatberegning er
bevisst ikke bygget denne runden (skjema/CRUD for lag finnes, men
leaderboardet avviser kind='team' med en tydelig feilmelding).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:19:30 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Leaderboard -- "regn ut ved lesing", ingen cache-tabell (samme filosofi
|
|
|
|
|
# som Nassau/High-low-high/individual_leaderboard selv).
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
class OrderOfMeritLeaderboardEntry(BaseModel):
|
|
|
|
|
player_id: str
|
|
|
|
|
player_name: str
|
|
|
|
|
results_counted: int
|
|
|
|
|
results_available: int
|
|
|
|
|
value: float | None
|
|
|
|
|
position: str | None
|
|
|
|
|
eligible: bool
|
|
|
|
|
ineligible_reason: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _money_for_position(position_label: str, payout_table: list[dict]) -> float:
|
|
|
|
|
"""Samme delt-plassering-regel som order_of_merit_points_for_position
|
|
|
|
|
(uavgjorte deler samme utbetaling ved sin felles plassering) -- egen
|
|
|
|
|
liten oppslagsfunksjon her (ikke i handicap_engine.py) siden dette er
|
|
|
|
|
et rent JSON-konfig-oppslag, ikke golf-domenematematikk om uavgjort."""
|
|
|
|
|
position = int(position_label.lstrip("T"))
|
|
|
|
|
for entry in payout_table:
|
|
|
|
|
if entry.get("position") == position:
|
|
|
|
|
return float(entry.get("amount") or 0)
|
|
|
|
|
return 0.0
|
|
|
|
|
|
|
|
|
|
|
2026-08-15 16:52:37 +02:00
|
|
|
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 på 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
|
|
|
|
|
|
|
|
|
|
|
Bygg Order of Merit: sesong-sammenlagt spillerrangering på tvers av turneringer
Ny funksjon (ADR-043, migrasjon 055): organisatorer kan opprette en
Order of Merit, lenke individuelle turneringer til den, og få en
sesong-sammenlagt rangering regnet ut ved lesing -- fem resultattyper
(poeng etter plassering, Stableford-, brutto- og nettoscore-sum,
pengeliste), sum/snitt-aggregering, med valgfri behold-N-beste,
minimum antall resultater og aldersgrense. Motor i handicap_engine.py
(9 nye enhetstester), ny app/routers/order_of_merit.py, og to nye
håndkodede frontend-sider under /organizations/[id]/order-of-merit.
Eclectic-aggregering og lag-OOM sin faktiske resultatberegning er
bevisst ikke bygget denne runden (skjema/CRUD for lag finnes, men
leaderboardet avviser kind='team' med en tydelig feilmelding).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:19:30 +02:00
|
|
|
@router.get(
|
|
|
|
|
"/orgs/{organization_id}/order-of-merits/{oom_id}/leaderboard",
|
|
|
|
|
response_model=list[OrderOfMeritLeaderboardEntry],
|
|
|
|
|
)
|
|
|
|
|
async def order_of_merit_leaderboard(
|
|
|
|
|
oom_id: str,
|
|
|
|
|
organization_id: str = Depends(get_authorized_org),
|
|
|
|
|
) -> list[OrderOfMeritLeaderboardEntry]:
|
|
|
|
|
async with org_connection(organization_id) as conn:
|
|
|
|
|
oom = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
SELECT result_type, aggregation_mode, count_best_n, min_results_required,
|
|
|
|
|
birth_year_from, birth_year_to, kind
|
|
|
|
|
FROM order_of_merit WHERE id = $1
|
|
|
|
|
""",
|
|
|
|
|
oom_id,
|
|
|
|
|
)
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
links = await conn.fetch(
|
|
|
|
|
"""
|
|
|
|
|
SELECT tournament_id::text, points_table::text AS points_table,
|
|
|
|
|
money_payout_table::text AS money_payout_table
|
|
|
|
|
FROM order_of_merit_link WHERE order_of_merit_id = $1
|
|
|
|
|
""",
|
|
|
|
|
oom_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# player_id -> [rå bidrags-verdier, ett per lenket turnering spilleren har deltatt i]
|
|
|
|
|
values_by_player: dict[str, list[float]] = {}
|
|
|
|
|
names_by_player: dict[str, str] = {}
|
|
|
|
|
|
|
|
|
|
for link in links:
|
2026-08-15 16:52:37 +02:00
|
|
|
tournament_row, entries = await _compute_individual_standings(conn, link["tournament_id"])
|
Bygg Order of Merit: sesong-sammenlagt spillerrangering på tvers av turneringer
Ny funksjon (ADR-043, migrasjon 055): organisatorer kan opprette en
Order of Merit, lenke individuelle turneringer til den, og få en
sesong-sammenlagt rangering regnet ut ved lesing -- fem resultattyper
(poeng etter plassering, Stableford-, brutto- og nettoscore-sum,
pengeliste), sum/snitt-aggregering, med valgfri behold-N-beste,
minimum antall resultater og aldersgrense. Motor i handicap_engine.py
(9 nye enhetstester), ny app/routers/order_of_merit.py, og to nye
håndkodede frontend-sider under /organizations/[id]/order-of-merit.
Eclectic-aggregering og lag-OOM sin faktiske resultatberegning er
bevisst ikke bygget denne runden (skjema/CRUD for lag finnes, men
leaderboardet avviser kind='team' med en tydelig feilmelding).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:19:30 +02:00
|
|
|
points_table = _decode_json(link["points_table"])
|
|
|
|
|
money_payout_table = _decode_json(link["money_payout_table"])
|
|
|
|
|
for e in entries:
|
2026-08-15 16:52:37 +02:00
|
|
|
contribution = _contribution_for_entry(
|
|
|
|
|
e, result_type, points_table, money_payout_table, tournament_row["scoring_method"]
|
|
|
|
|
)
|
Bygg Order of Merit: sesong-sammenlagt spillerrangering på tvers av turneringer
Ny funksjon (ADR-043, migrasjon 055): organisatorer kan opprette en
Order of Merit, lenke individuelle turneringer til den, og få en
sesong-sammenlagt rangering regnet ut ved lesing -- fem resultattyper
(poeng etter plassering, Stableford-, brutto- og nettoscore-sum,
pengeliste), sum/snitt-aggregering, med valgfri behold-N-beste,
minimum antall resultater og aldersgrense. Motor i handicap_engine.py
(9 nye enhetstester), ny app/routers/order_of_merit.py, og to nye
håndkodede frontend-sider under /organizations/[id]/order-of-merit.
Eclectic-aggregering og lag-OOM sin faktiske resultatberegning er
bevisst ikke bygget denne runden (skjema/CRUD for lag finnes, men
leaderboardet avviser kind='team' med en tydelig feilmelding).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:19:30 +02:00
|
|
|
if contribution is None:
|
|
|
|
|
continue
|
|
|
|
|
values_by_player.setdefault(e.player_id, []).append(contribution)
|
|
|
|
|
names_by_player[e.player_id] = e.player_name
|
|
|
|
|
|
|
|
|
|
# Aldersgrense: filtrert FØR aggregering (spillere utenfor
|
|
|
|
|
# fødselsårs-intervallet er ikke rangert i det hele tatt, ikke
|
|
|
|
|
# bare gitt 0 poeng -- de skal ikke telle med som "sist").
|
|
|
|
|
eligible_player_ids = set(values_by_player.keys())
|
|
|
|
|
birth_years: dict[str, int | None] = {}
|
|
|
|
|
if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None:
|
|
|
|
|
if eligible_player_ids:
|
|
|
|
|
rows = await conn.fetch(
|
|
|
|
|
"SELECT id::text, EXTRACT(YEAR FROM birth_date)::int AS birth_year FROM player WHERE id = ANY($1::uuid[])",
|
|
|
|
|
list(eligible_player_ids),
|
|
|
|
|
)
|
|
|
|
|
birth_years = {r["id"]: r["birth_year"] for r in rows}
|
|
|
|
|
|
|
|
|
|
results: list[OrderOfMeritLeaderboardEntry] = []
|
2026-08-15 16:52:37 +02:00
|
|
|
|
|
|
|
|
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.append(
|
|
|
|
|
OrderOfMeritLeaderboardEntry(
|
|
|
|
|
player_id=player_id,
|
|
|
|
|
player_name=names_by_player[player_id],
|
|
|
|
|
results_counted=counted,
|
|
|
|
|
results_available=results_available,
|
|
|
|
|
value=value,
|
|
|
|
|
position=None,
|
|
|
|
|
eligible=eligible,
|
|
|
|
|
ineligible_reason=ineligible_reason,
|
|
|
|
|
)
|
Bygg Order of Merit: sesong-sammenlagt spillerrangering på tvers av turneringer
Ny funksjon (ADR-043, migrasjon 055): organisatorer kan opprette en
Order of Merit, lenke individuelle turneringer til den, og få en
sesong-sammenlagt rangering regnet ut ved lesing -- fem resultattyper
(poeng etter plassering, Stableford-, brutto- og nettoscore-sum,
pengeliste), sum/snitt-aggregering, med valgfri behold-N-beste,
minimum antall resultater og aldersgrense. Motor i handicap_engine.py
(9 nye enhetstester), ny app/routers/order_of_merit.py, og to nye
håndkodede frontend-sider under /organizations/[id]/order-of-merit.
Eclectic-aggregering og lag-OOM sin faktiske resultatberegning er
bevisst ikke bygget denne runden (skjema/CRUD for lag finnes, men
leaderboardet avviser kind='team' med en tydelig feilmelding).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:19:30 +02:00
|
|
|
)
|
2026-08-15 16:52:37 +02:00
|
|
|
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,
|
Bygg Order of Merit: sesong-sammenlagt spillerrangering på tvers av turneringer
Ny funksjon (ADR-043, migrasjon 055): organisatorer kan opprette en
Order of Merit, lenke individuelle turneringer til den, og få en
sesong-sammenlagt rangering regnet ut ved lesing -- fem resultattyper
(poeng etter plassering, Stableford-, brutto- og nettoscore-sum,
pengeliste), sum/snitt-aggregering, med valgfri behold-N-beste,
minimum antall resultater og aldersgrense. Motor i handicap_engine.py
(9 nye enhetstester), ny app/routers/order_of_merit.py, og to nye
håndkodede frontend-sider under /organizations/[id]/order-of-merit.
Eclectic-aggregering og lag-OOM sin faktiske resultatberegning er
bevisst ikke bygget denne runden (skjema/CRUD for lag finnes, men
leaderboardet avviser kind='team' med en tydelig feilmelding).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:19:30 +02:00
|
|
|
)
|
2026-08-15 16:52:37 +02:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
)
|
Bygg Order of Merit: sesong-sammenlagt spillerrangering på tvers av turneringer
Ny funksjon (ADR-043, migrasjon 055): organisatorer kan opprette en
Order of Merit, lenke individuelle turneringer til den, og få en
sesong-sammenlagt rangering regnet ut ved lesing -- fem resultattyper
(poeng etter plassering, Stableford-, brutto- og nettoscore-sum,
pengeliste), sum/snitt-aggregering, med valgfri behold-N-beste,
minimum antall resultater og aldersgrense. Motor i handicap_engine.py
(9 nye enhetstester), ny app/routers/order_of_merit.py, og to nye
håndkodede frontend-sider under /organizations/[id]/order-of-merit.
Eclectic-aggregering og lag-OOM sin faktiske resultatberegning er
bevisst ikke bygget denne runden (skjema/CRUD for lag finnes, men
leaderboardet avviser kind='team' med en tydelig feilmelding).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:19:30 +02:00
|
|
|
|
|
|
|
|
# Rangering: kun blant de kvalifiserte, høyest verdi øverst for
|
|
|
|
|
# poeng/stableford/pengeliste, LAVEST for brutto/netto -- men
|
|
|
|
|
# `value` er allerede tilbake-negert til ekte visningsverdi over,
|
|
|
|
|
# så sorter på `value` med riktig retning per resultattype.
|
|
|
|
|
eligible_results = [r for r in results if r.eligible and r.value is not None]
|
|
|
|
|
ineligible_results = [r for r in results if not (r.eligible and r.value is not None)]
|
|
|
|
|
eligible_results.sort(key=lambda r: r.value, reverse=not lower_is_better)
|
|
|
|
|
|
|
|
|
|
i, n = 0, len(eligible_results)
|
|
|
|
|
while i < n:
|
|
|
|
|
j = i
|
|
|
|
|
while j < n and eligible_results[j].value == eligible_results[i].value:
|
|
|
|
|
j += 1
|
|
|
|
|
rank = i + 1
|
|
|
|
|
tied = (j - i) > 1
|
|
|
|
|
for k in range(i, j):
|
|
|
|
|
eligible_results[k].position = f"T{rank}" if tied else str(rank)
|
|
|
|
|
i = j
|
|
|
|
|
|
|
|
|
|
return eligible_results + ineligible_results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _decode_json(value):
|
|
|
|
|
return json.loads(value) if value else None
|
|
|
|
|
|
|
|
|
|
|
2026-08-15 16:52:37 +02:00
|
|
|
_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:
|
Bygg Order of Merit: sesong-sammenlagt spillerrangering på tvers av turneringer
Ny funksjon (ADR-043, migrasjon 055): organisatorer kan opprette en
Order of Merit, lenke individuelle turneringer til den, og få en
sesong-sammenlagt rangering regnet ut ved lesing -- fem resultattyper
(poeng etter plassering, Stableford-, brutto- og nettoscore-sum,
pengeliste), sum/snitt-aggregering, med valgfri behold-N-beste,
minimum antall resultater og aldersgrense. Motor i handicap_engine.py
(9 nye enhetstester), ny app/routers/order_of_merit.py, og to nye
håndkodede frontend-sider under /organizations/[id]/order-of-merit.
Eclectic-aggregering og lag-OOM sin faktiske resultatberegning er
bevisst ikke bygget denne runden (skjema/CRUD for lag finnes, men
leaderboardet avviser kind='team' med en tydelig feilmelding).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:19:30 +02:00
|
|
|
"""Utleder ÉN spillers bidrags-tall fra én lenket turnerings egen,
|
|
|
|
|
allerede beregnede leaderboard-entry -- `None` betyr "spilte ikke
|
|
|
|
|
denne turneringen" (skal ikke telle som et 0-resultat, kalleren
|
|
|
|
|
hopper over den).
|
|
|
|
|
|
|
|
|
|
`entry.position` (posisjon i DENNE turneringen) er KUN populert for
|
|
|
|
|
scoring_method i (stroke_gross, stroke_net, stableford) -- se
|
|
|
|
|
`_attach_stroke_play_columns` i individual_tournaments.py. Er OOM-en
|
|
|
|
|
poeng- eller pengeliste-basert og den lenkede turneringen bruker en
|
|
|
|
|
annen scoring_method (København/BBB/Flagg), er det bevisst ingen
|
|
|
|
|
posisjon å hente poeng/penger fra -- spilleren telles fortsatt som
|
2026-08-15 16:52:37 +02:00
|
|
|
"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` (rå sum på tvers av rundene, et tall som
|
|
|
|
|
aldri var det som ble kåret som resultat i den turneringen)."""
|
Bygg Order of Merit: sesong-sammenlagt spillerrangering på tvers av turneringer
Ny funksjon (ADR-043, migrasjon 055): organisatorer kan opprette en
Order of Merit, lenke individuelle turneringer til den, og få en
sesong-sammenlagt rangering regnet ut ved lesing -- fem resultattyper
(poeng etter plassering, Stableford-, brutto- og nettoscore-sum,
pengeliste), sum/snitt-aggregering, med valgfri behold-N-beste,
minimum antall resultater og aldersgrense. Motor i handicap_engine.py
(9 nye enhetstester), ny app/routers/order_of_merit.py, og to nye
håndkodede frontend-sider under /organizations/[id]/order-of-merit.
Eclectic-aggregering og lag-OOM sin faktiske resultatberegning er
bevisst ikke bygget denne runden (skjema/CRUD for lag finnes, men
leaderboardet avviser kind='team' med en tydelig feilmelding).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:19:30 +02:00
|
|
|
if entry.rounds_played == 0:
|
|
|
|
|
return None
|
|
|
|
|
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
|
|
|
|
|
if result_type == "money":
|
|
|
|
|
return _money_for_position(entry.position, money_payout_table) if entry.position and money_payout_table else 0.0
|
2026-08-15 16:52:37 +02:00
|
|
|
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
|
Bygg Order of Merit: sesong-sammenlagt spillerrangering på tvers av turneringer
Ny funksjon (ADR-043, migrasjon 055): organisatorer kan opprette en
Order of Merit, lenke individuelle turneringer til den, og få en
sesong-sammenlagt rangering regnet ut ved lesing -- fem resultattyper
(poeng etter plassering, Stableford-, brutto- og nettoscore-sum,
pengeliste), sum/snitt-aggregering, med valgfri behold-N-beste,
minimum antall resultater og aldersgrense. Motor i handicap_engine.py
(9 nye enhetstester), ny app/routers/order_of_merit.py, og to nye
håndkodede frontend-sider under /organizations/[id]/order-of-merit.
Eclectic-aggregering og lag-OOM sin faktiske resultatberegning er
bevisst ikke bygget denne runden (skjema/CRUD for lag finnes, men
leaderboardet avviser kind='team' med en tydelig feilmelding).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:19:30 +02:00
|
|
|
if result_type == "stableford":
|
|
|
|
|
return float(entry.stableford_total) if entry.stableford_total is not None else 0.0
|
|
|
|
|
if result_type == "gross":
|
|
|
|
|
return float(entry.gross_total) if entry.gross_total is not None else 0.0
|
|
|
|
|
if result_type == "net":
|
|
|
|
|
return float(entry.net_total) if entry.net_total is not None else 0.0
|
|
|
|
|
return 0.0
|