""" 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.") # --------------------------------------------------------------------------- # 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 @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["kind"] != "player": raise app_error(400, "VALIDATION_FAILED", "Lag-OOM-leaderboard er ikke bygget ennå.") 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: _, entries = await _compute_individual_standings(conn, link["tournament_id"]) points_table = _decode_json(link["points_table"]) money_payout_table = _decode_json(link["money_payout_table"]) for e in entries: contribution = _contribution_for_entry(e, result_type, points_table, money_payout_table) 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] = [] for player_id, raw_values in values_by_player.items(): 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 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, ) ) # 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 def _contribution_for_entry(entry, result_type: str, points_table, money_payout_table) -> float | None: """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 "deltok" (rounds_played > 0) men bidrar med 0, ikke ekskludert helt.""" 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 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