From c33aaa1a4b49730d41ebaabfba462f076e326b09 Mon Sep 17 00:00:00 2001 From: linus Date: Sat, 5 Sep 2026 19:52:25 +0200 Subject: [PATCH] Meisterschaft: eine pro Saison, mit zugeordneten Turnieren Auf Vorgabe des Auftraggebers umgestellt: * genau eine Meisterschaft je Saison (unique auf saison_jahr) * turnier.meisterschaft_id (nullable FK) statt bool zaehlt_meisterschaft; NULL = "o. M.". Kompat-Property turnier.zaehlt_meisterschaft bleibt. * Teilwertungen (Erwachsene-Gesamt, Jugend J1-J5) sind (Kategorie, Klasse)- Sichten auf die eine Meisterschaft, keine eigenen Zeilen mehr * meisterschaft_stand: kategorie + klasse_id + Unique je Wertung/Fahrer * berechne_stand(kategorie=, klasse_id=), wertungen_fuer(), berechne_alle_wertungen() * Read-API: /meisterschaften/{id}/stand[?wertung=], .../endlauf-qualifikation?wertung= * Ingest: POST /turniere haengt zaehlende Turniere an die Jahres-Meisterschaft * Initialmigration neu erzeugt (Projekt noch nicht ausgerollt); Seed: eine "HTH-Meisterschaft " * Tests angepasst/ergaenzt (61 gruen) Co-Authored-By: Claude Sonnet 5 --- .gitignore | 2 +- ...ma.py => d7d7d8a2cf90_initiales_schema.py} | 89 ++++++----- src/oamc/api/v1/admin.py | 43 +++-- src/oamc/api/v1/ingest.py | 17 +- src/oamc/api/v1/read.py | 95 ++++++++--- src/oamc/frontend/public.py | 26 ++- src/oamc/importer/__init__.py | 2 + src/oamc/importer/ingest.py | 27 ++++ src/oamc/meisterschaft/__init__.py | 18 ++- src/oamc/meisterschaft/berechnung.py | 148 ++++++++++++++---- src/oamc/models/meisterschaft.py | 53 +++++-- src/oamc/models/veranstaltung.py | 15 +- src/oamc/seeds/saison_2026.py | 23 +-- .../public/meisterschaft_detail.html | 15 +- .../templates/public/meisterschaft_liste.html | 2 +- tests/factories.py | 12 +- tests/test_api_ingest.py | 33 ++++ tests/test_meisterschaft.py | 76 +++++---- tests/test_seed.py | 16 +- 19 files changed, 513 insertions(+), 199 deletions(-) rename migrations/versions/{df5e2dd5a864_initiales_schema.py => d7d7d8a2cf90_initiales_schema.py} (94%) diff --git a/.gitignore b/.gitignore index 5eabbc2..43412a2 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,6 @@ debian/debhelper-build-stamp .vscode/ *.swp -# Claude Code lokale (persoenliche) Einstellungen +# Claude Code .claude/ diff --git a/migrations/versions/df5e2dd5a864_initiales_schema.py b/migrations/versions/d7d7d8a2cf90_initiales_schema.py similarity index 94% rename from migrations/versions/df5e2dd5a864_initiales_schema.py rename to migrations/versions/d7d7d8a2cf90_initiales_schema.py index b466fd7..5da5028 100644 --- a/migrations/versions/df5e2dd5a864_initiales_schema.py +++ b/migrations/versions/d7d7d8a2cf90_initiales_schema.py @@ -1,8 +1,8 @@ """Initiales Schema -Revision ID: df5e2dd5a864 +Revision ID: d7d7d8a2cf90 Revises: -Create Date: 2026-09-05 18:08:45.999851+02:00 +Create Date: 2026-09-05 19:48:05.733306+02:00 """ from __future__ import annotations @@ -12,17 +12,15 @@ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql -revision: str = 'df5e2dd5a864' +revision: str = 'd7d7d8a2cf90' down_revision: str | None = None branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: - # pg_trgm fuer die unscharfe Fahrersuche beim Import (Plan §6.4) - op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") - # ### commands auto generated by Alembic - please adjust! ### + op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") op.create_table('benutzer', sa.Column('id', sa.Integer(), nullable=False), sa.Column('benutzername', sa.String(length=80), nullable=False), @@ -105,11 +103,11 @@ def upgrade() -> None: sa.PrimaryKeyConstraint('id', name=op.f('pk_fahrer')) ) op.create_index(op.f('ix_fahrer_geburtsjahr'), 'fahrer', ['geburtsjahr'], unique=False) + op.create_index(op.f('ix_fahrer_verein_id'), 'fahrer', ['verein_id'], unique=False) op.execute( "CREATE INDEX ix_fahrer_name_trgm ON fahrer USING gin " "((lower(nachname || ' ' || vorname)) gin_trgm_ops)" ) - op.create_index(op.f('ix_fahrer_verein_id'), 'fahrer', ['verein_id'], unique=False) op.create_table('klasse', sa.Column('id', sa.Integer(), nullable=False), sa.Column('saison_jahr', sa.Integer(), nullable=False), @@ -128,6 +126,16 @@ def upgrade() -> None: sa.UniqueConstraint('saison_jahr', 'code', name='klasse_saison_code') ) op.create_index(op.f('ix_klasse_saison_jahr'), 'klasse', ['saison_jahr'], unique=False) + op.create_table('meisterschaft', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('saison_jahr', sa.Integer(), nullable=False), + sa.Column('bezeichnung', sa.String(length=200), nullable=False), + sa.Column('regelwerk', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.ForeignKeyConstraint(['saison_jahr'], ['saison.jahr'], name=op.f('fk_meisterschaft_saison_jahr_saison')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_meisterschaft')), + sa.UniqueConstraint('saison_jahr', name='meisterschaft_saison_eindeutig') + ) + op.create_index(op.f('ix_meisterschaft_saison_jahr'), 'meisterschaft', ['saison_jahr'], unique=True) op.create_table('veranstaltung', sa.Column('id', sa.Integer(), nullable=False), sa.Column('datum', sa.Date(), nullable=False), @@ -145,40 +153,11 @@ def upgrade() -> None: sa.PrimaryKeyConstraint('id', name=op.f('pk_veranstaltung')) ) op.create_index(op.f('ix_veranstaltung_datum'), 'veranstaltung', ['datum'], unique=False) - op.create_table('meisterschaft', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('saison_jahr', sa.Integer(), nullable=False), - sa.Column('bezeichnung', sa.String(length=200), nullable=False), - sa.Column('kategorie', sa.Enum('J', 'S', 'A', name='meisterschaft_kategorie'), nullable=False), - sa.Column('klasse_id', sa.Integer(), nullable=True), - sa.Column('regelwerk', postgresql.JSONB(astext_type=sa.Text()), nullable=False), - sa.ForeignKeyConstraint(['klasse_id'], ['klasse.id'], name=op.f('fk_meisterschaft_klasse_id_klasse')), - sa.ForeignKeyConstraint(['saison_jahr'], ['saison.jahr'], name=op.f('fk_meisterschaft_saison_jahr_saison')), - sa.PrimaryKeyConstraint('id', name=op.f('pk_meisterschaft')) - ) - op.create_index(op.f('ix_meisterschaft_saison_jahr'), 'meisterschaft', ['saison_jahr'], unique=False) - op.create_table('turnier', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('veranstaltung_id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=120), nullable=False), - sa.Column('veranstalter_verein_id', sa.Integer(), nullable=True), - sa.Column('zaehlt_meisterschaft', sa.Boolean(), nullable=False), - sa.Column('ist_aktiv', sa.Boolean(), nullable=False), - sa.Column('status', sa.Enum('geplant', 'laufend', 'vorlaeufig', 'final', name='turnier_status'), nullable=False), - sa.Column('erstellt_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.Column('geaendert_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), - sa.ForeignKeyConstraint(['veranstalter_verein_id'], ['verein.id'], name=op.f('fk_turnier_veranstalter_verein_id_verein')), - sa.ForeignKeyConstraint(['veranstaltung_id'], ['veranstaltung.id'], name=op.f('fk_turnier_veranstaltung_id_veranstaltung'), ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_turnier')) - ) - op.create_index(op.f('ix_turnier_veranstaltung_id'), 'turnier', ['veranstaltung_id'], unique=False) - op.create_index( - 'uq_turnier_ist_aktiv', 'turnier', ['ist_aktiv'], unique=True, - postgresql_where=sa.text('ist_aktiv'), - ) op.create_table('meisterschaft_stand', sa.Column('id', sa.Integer(), nullable=False), sa.Column('meisterschaft_id', sa.Integer(), nullable=False), + sa.Column('kategorie', sa.Enum('J', 'S', 'A', name='meisterschaft_stand_kategorie'), nullable=False), + sa.Column('klasse_id', sa.Integer(), nullable=True), sa.Column('fahrer_id', sa.Integer(), nullable=False), sa.Column('punkte_gesamt', sa.Numeric(precision=9, scale=2), nullable=False), sa.Column('punkte_je_lauf', postgresql.JSONB(astext_type=sa.Text()), nullable=False), @@ -187,11 +166,31 @@ def upgrade() -> None: sa.Column('platz', sa.Integer(), nullable=True), sa.Column('berechnet_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), sa.ForeignKeyConstraint(['fahrer_id'], ['fahrer.id'], name=op.f('fk_meisterschaft_stand_fahrer_id_fahrer')), + sa.ForeignKeyConstraint(['klasse_id'], ['klasse.id'], name=op.f('fk_meisterschaft_stand_klasse_id_klasse')), sa.ForeignKeyConstraint(['meisterschaft_id'], ['meisterschaft.id'], name=op.f('fk_meisterschaft_stand_meisterschaft_id_meisterschaft'), ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_meisterschaft_stand')) + sa.PrimaryKeyConstraint('id', name=op.f('pk_meisterschaft_stand')), + sa.UniqueConstraint('meisterschaft_id', 'kategorie', 'klasse_id', 'fahrer_id', name='meisterschaft_stand_eindeutig') ) op.create_index(op.f('ix_meisterschaft_stand_fahrer_id'), 'meisterschaft_stand', ['fahrer_id'], unique=False) op.create_index(op.f('ix_meisterschaft_stand_meisterschaft_id'), 'meisterschaft_stand', ['meisterschaft_id'], unique=False) + op.create_table('turnier', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('veranstaltung_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('veranstalter_verein_id', sa.Integer(), nullable=True), + sa.Column('meisterschaft_id', sa.Integer(), nullable=True), + sa.Column('ist_aktiv', sa.Boolean(), nullable=False), + sa.Column('status', sa.Enum('geplant', 'laufend', 'vorlaeufig', 'final', name='turnier_status'), nullable=False), + sa.Column('erstellt_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('geaendert_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['meisterschaft_id'], ['meisterschaft.id'], name=op.f('fk_turnier_meisterschaft_id_meisterschaft'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['veranstalter_verein_id'], ['verein.id'], name=op.f('fk_turnier_veranstalter_verein_id_verein')), + sa.ForeignKeyConstraint(['veranstaltung_id'], ['veranstaltung.id'], name=op.f('fk_turnier_veranstaltung_id_veranstaltung'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_turnier')) + ) + op.create_index(op.f('ix_turnier_meisterschaft_id'), 'turnier', ['meisterschaft_id'], unique=False) + op.create_index(op.f('ix_turnier_veranstaltung_id'), 'turnier', ['veranstaltung_id'], unique=False) + op.create_index('uq_turnier_ist_aktiv', 'turnier', ['ist_aktiv'], unique=True, postgresql_where=sa.text('ist_aktiv')) op.create_table('wertungsklasse', sa.Column('id', sa.Integer(), nullable=False), sa.Column('turnier_id', sa.Integer(), nullable=False), @@ -275,20 +274,20 @@ def downgrade() -> None: op.drop_table('start') op.drop_index(op.f('ix_wertungsklasse_turnier_id'), table_name='wertungsklasse') op.drop_table('wertungsklasse') + op.drop_index('uq_turnier_ist_aktiv', table_name='turnier', postgresql_where=sa.text('ist_aktiv')) + op.drop_index(op.f('ix_turnier_veranstaltung_id'), table_name='turnier') + op.drop_index(op.f('ix_turnier_meisterschaft_id'), table_name='turnier') + op.drop_table('turnier') op.drop_index(op.f('ix_meisterschaft_stand_meisterschaft_id'), table_name='meisterschaft_stand') op.drop_index(op.f('ix_meisterschaft_stand_fahrer_id'), table_name='meisterschaft_stand') op.drop_table('meisterschaft_stand') - op.drop_index('uq_turnier_ist_aktiv', table_name='turnier') - op.drop_index(op.f('ix_turnier_veranstaltung_id'), table_name='turnier') - op.drop_table('turnier') - op.drop_index(op.f('ix_meisterschaft_saison_jahr'), table_name='meisterschaft') - op.drop_table('meisterschaft') op.drop_index(op.f('ix_veranstaltung_datum'), table_name='veranstaltung') op.drop_table('veranstaltung') + op.drop_index(op.f('ix_meisterschaft_saison_jahr'), table_name='meisterschaft') + op.drop_table('meisterschaft') op.drop_index(op.f('ix_klasse_saison_jahr'), table_name='klasse') op.drop_table('klasse') op.drop_index(op.f('ix_fahrer_verein_id'), table_name='fahrer') - op.drop_index('ix_fahrer_name_trgm', table_name='fahrer', postgresql_using='gin') op.drop_index(op.f('ix_fahrer_geburtsjahr'), table_name='fahrer') op.drop_table('fahrer') op.drop_table('verein') diff --git a/src/oamc/api/v1/admin.py b/src/oamc/api/v1/admin.py index 67f1a94..db50538 100644 --- a/src/oamc/api/v1/admin.py +++ b/src/oamc/api/v1/admin.py @@ -13,7 +13,7 @@ from pydantic import BaseModel, Field from sqlalchemy import select from oamc.matching.fahrer import fahrer_zusammenfuehren -from oamc.meisterschaft.berechnung import berechne_stand +from oamc.meisterschaft.berechnung import berechne_alle_wertungen from oamc.models import ( Geraet, ImportJob, @@ -21,7 +21,7 @@ from oamc.models import ( Start, Zeitmessung, ) -from oamc.models.enums import GeraetTyp, ZeitmessungStatus +from oamc.models.enums import GeraetTyp, Kategorie, ZeitmessungStatus from oamc.security.deps import Admin, Auswerter, DbDep from oamc.security.tokens import erzeuge_geraete_token from oamc.wertung.service import turnier_auswerten @@ -170,28 +170,37 @@ def import_jobs(db: DbDep, _auswerter: Auswerter, limit: int = 50): @router.post("/meisterschaften/{meisterschaft_id}/berechnen") def meisterschaft_berechnen(db: DbDep, _auswerter: Auswerter, meisterschaft_id: int): - """Stand neu berechnen und materialisieren (``meisterschaft_stand``).""" + """Alle Wertungen der Jahresmeisterschaft neu berechnen und materialisieren + (``meisterschaft_stand``).""" - bericht = berechne_stand(db, meisterschaft_id) + berichte = berechne_alle_wertungen(db, meisterschaft_id) db.query(MeisterschaftStand).filter( MeisterschaftStand.meisterschaft_id == meisterschaft_id ).delete() - for z in bericht.zeilen: - db.add( - MeisterschaftStand( - meisterschaft_id=meisterschaft_id, - fahrer_id=z.fahrer_id, - punkte_gesamt=z.punkte_gesamt, - punkte_je_lauf={str(k): float(v) for k, v in z.punkte_je_lauf.items()}, - gestrichene_laeufe=z.gestrichene_laeufe, - anzahl_siege=z.anzahl_siege, - platz=z.platz, + gesamt = 0 + hinweise: set[str] = set() + for b in berichte: + hinweise.update(b.hinweise) + for z in b.zeilen: + gesamt += 1 + db.add( + MeisterschaftStand( + meisterschaft_id=meisterschaft_id, + kategorie=Kategorie(b.kategorie), + klasse_id=b.klasse_id, + fahrer_id=z.fahrer_id, + punkte_gesamt=z.punkte_gesamt, + punkte_je_lauf={str(k): float(v) for k, v in z.punkte_je_lauf.items()}, + gestrichene_laeufe=z.gestrichene_laeufe, + anzahl_siege=z.anzahl_siege, + platz=z.platz, + ) ) - ) return { "meisterschaft_id": meisterschaft_id, - "zeilen": len(bericht.zeilen), - "hinweise": bericht.hinweise, + "wertungen": len(berichte), + "zeilen": gesamt, + "hinweise": sorted(hinweise), } diff --git a/src/oamc/api/v1/ingest.py b/src/oamc/api/v1/ingest.py index 20b7686..1b4b2ee 100644 --- a/src/oamc/api/v1/ingest.py +++ b/src/oamc/api/v1/ingest.py @@ -48,22 +48,33 @@ def veranstaltung_anlegen(db: DbDep, _key: IngestKey, daten: VeranstaltungCreate @router.post("/turniere", status_code=201) def turnier_anlegen(db: DbDep, _key: IngestKey, daten: TurnierCreate): - if db.get(Veranstaltung, daten.veranstaltung_id) is None: + veranstaltung = db.get(Veranstaltung, daten.veranstaltung_id) + if veranstaltung is None: raise HTTPException(404, "Veranstaltung nicht gefunden") verein = None if daten.veranstalter_verein: from oamc.importer.ingest import finde_verein verein = finde_verein(db, daten.veranstalter_verein) + + # Zaehlt das Turnier zur Jahreswertung, wird es an die (eine) Meisterschaft + # der Saison gehaengt. + meisterschaft_id = None + if daten.zaehlt_meisterschaft: + from oamc.importer.ingest import meisterschaft_der_saison + + m = meisterschaft_der_saison(db, veranstaltung.datum.year) + meisterschaft_id = m.id if m else None + t = Turnier( veranstaltung_id=daten.veranstaltung_id, name=daten.name, veranstalter_verein_id=verein.id if verein else None, - zaehlt_meisterschaft=daten.zaehlt_meisterschaft, + meisterschaft_id=meisterschaft_id, ) db.add(t) db.flush() - return {"id": t.id} + return {"id": t.id, "meisterschaft_id": meisterschaft_id} @router.post("/turniere/{turnier_id}/starts", response_model=ImportBericht) diff --git a/src/oamc/api/v1/read.py b/src/oamc/api/v1/read.py index 079cb0c..c41bc3c 100644 --- a/src/oamc/api/v1/read.py +++ b/src/oamc/api/v1/read.py @@ -16,7 +16,12 @@ from oamc.api.v1.serialisierung import ( turnier_kurz, veranstaltung_kurz, ) -from oamc.meisterschaft.berechnung import berechne_stand, endlauf_qualifikation +from oamc.meisterschaft.berechnung import ( + berechne_alle_wertungen, + berechne_stand, + endlauf_qualifikation, + wertungen_fuer, +) from oamc.models import ( Fahrer, Meisterschaft, @@ -161,27 +166,34 @@ def fahrer_historie(db: DbDep, fahrer_id: int): @router.get("/meisterschaften") def liste_meisterschaften(db: DbDep, saison: int | None = None): - stmt = select(Meisterschaft) + """Pro Saison genau eine Meisterschaft.""" + + stmt = select(Meisterschaft).order_by(Meisterschaft.saison_jahr.desc()) if saison: stmt = stmt.where(Meisterschaft.saison_jahr == saison) - return [ - { - "id": m.id, - "bezeichnung": m.bezeichnung, - "saison": m.saison_jahr, - "kategorie": m.kategorie.value, - } - for m in db.scalars(stmt) - ] + out = [] + for m in db.scalars(stmt): + out.append( + { + "id": m.id, + "bezeichnung": m.bezeichnung, + "saison": m.saison_jahr, + "anzahl_turniere": len(m.turniere), + "wertungen": [ + {"code": w.code, "bezeichnung": w.bezeichnung, "kategorie": w.kategorie.value, + "klasse_id": w.klasse_id} + for w in wertungen_fuer(db, m.id) + ], + } + ) + return out -@router.get("/meisterschaften/{meisterschaft_id}/stand") -def meisterschaft_stand(db: DbDep, meisterschaft_id: int): - bericht = berechne_stand(db, meisterschaft_id) +def _wertung_json(db, bericht) -> dict: return { - "meisterschaft_id": bericht.meisterschaft_id, - "saison": bericht.saison_jahr, + "wertung": bericht.wertung, "kategorie": bericht.kategorie, + "klasse_id": bericht.klasse_id, "laeufe_gesamt": bericht.laeufe_gesamt, "laeufe_gewertet": bericht.laeufe_gewertet, "hinweise": bericht.hinweise, @@ -198,11 +210,56 @@ def meisterschaft_stand(db: DbDep, meisterschaft_id: int): } -@router.get("/meisterschaften/{meisterschaft_id}/endlauf-qualifikation") -def meisterschaft_quali(db: DbDep, meisterschaft_id: int, plaetze: int = 10): - vorschlaege = endlauf_qualifikation(db, meisterschaft_id, plaetze) +@router.get("/meisterschaften/{meisterschaft_id}/stand") +def meisterschaft_stand( + db: DbDep, + meisterschaft_id: int, + wertung: str | None = Query(default=None, description='z. B. "erwachsene" oder "J 3"'), +): + """Stand der Jahresmeisterschaft. Ohne ``wertung`` alle Teilwertungen.""" + + if wertung: + gewuenscht = next( + (w for w in wertungen_fuer(db, meisterschaft_id) if w.code.lower() == wertung.lower()), + None, + ) + if gewuenscht is None: + raise HTTPException(404, f"Wertung {wertung!r} unbekannt") + bericht = berechne_stand( + db, meisterschaft_id, kategorie=gewuenscht.kategorie, klasse_id=gewuenscht.klasse_id + ) + return {"meisterschaft_id": meisterschaft_id, **_wertung_json(db, bericht)} + + berichte = berechne_alle_wertungen(db, meisterschaft_id) return { "meisterschaft_id": meisterschaft_id, + "saison": berichte[0].saison_jahr if berichte else None, + "wertungen": [_wertung_json(db, b) for b in berichte], + } + + +@router.get("/meisterschaften/{meisterschaft_id}/endlauf-qualifikation") +def meisterschaft_quali( + db: DbDep, + meisterschaft_id: int, + wertung: str = Query(default="erwachsene"), + plaetze: int = 10, +): + gewuenscht = next( + (w for w in wertungen_fuer(db, meisterschaft_id) if w.code.lower() == wertung.lower()), None + ) + if gewuenscht is None: + raise HTTPException(404, f"Wertung {wertung!r} unbekannt") + vorschlaege = endlauf_qualifikation( + db, + meisterschaft_id, + kategorie=gewuenscht.kategorie, + klasse_id=gewuenscht.klasse_id, + plaetze=plaetze, + ) + return { + "meisterschaft_id": meisterschaft_id, + "wertung": gewuenscht.code, "hinweis": "Vorschlag — die endgueltige Festlegung trifft der Bereichsleiter (Plan §3.4).", "plaetze": plaetze, "vorschlaege": [ diff --git a/src/oamc/frontend/public.py b/src/oamc/frontend/public.py index 9bdf0ef..5ef8cba 100644 --- a/src/oamc/frontend/public.py +++ b/src/oamc/frontend/public.py @@ -14,7 +14,7 @@ from sqlalchemy.orm import selectinload from oamc.api.v1.serialisierung import ergebnis_zeile from oamc.config import get_settings from oamc.frontend.templating import templates -from oamc.meisterschaft.berechnung import berechne_stand +from oamc.meisterschaft.berechnung import berechne_stand, wertungen_fuer from oamc.models import Fahrer, Meisterschaft, Start, Turnier, Veranstaltung from oamc.security.deps import DbDep @@ -217,17 +217,31 @@ def meisterschaft_uebersicht(request: Request, db: DbDep): @router.get("/meisterschaft/{meisterschaft_id}") -def meisterschaft_detail(request: Request, db: DbDep, meisterschaft_id: int): - if db.get(Meisterschaft, meisterschaft_id) is None: +def meisterschaft_detail( + request: Request, db: DbDep, meisterschaft_id: int, wertung: str | None = None +): + m = db.get(Meisterschaft, meisterschaft_id) + if m is None: raise HTTPException(404, "Meisterschaft nicht gefunden") - bericht = berechne_stand(db, meisterschaft_id) + alle = wertungen_fuer(db, meisterschaft_id) + aktiv = next((w for w in alle if w.code.lower() == (wertung or "").lower()), alle[0]) + bericht = berechne_stand( + db, meisterschaft_id, kategorie=aktiv.kategorie, klasse_id=aktiv.klasse_id + ) + fahrer_ids = [z.fahrer_id for z in bericht.zeilen] fahrer_map = { - f.id: f for f in db.scalars(select(Fahrer).where(Fahrer.id.in_([z.fahrer_id for z in bericht.zeilen]))) + f.id: f for f in db.scalars(select(Fahrer).where(Fahrer.id.in_(fahrer_ids))) } return templates.TemplateResponse( request, "public/meisterschaft_detail.html", - {"bericht": bericht, "fahrer_map": fahrer_map}, + { + "meisterschaft": m, + "wertungen": alle, + "aktive_wertung": aktiv, + "bericht": bericht, + "fahrer_map": fahrer_map, + }, ) diff --git a/src/oamc/importer/__init__.py b/src/oamc/importer/__init__.py index fafc78e..5440da5 100644 --- a/src/oamc/importer/__init__.py +++ b/src/oamc/importer/__init__.py @@ -3,6 +3,7 @@ from oamc.importer.ingest import ( finde_klasse, finde_verein, + meisterschaft_der_saison, verarbeite_ergebnisse, verarbeite_starts, ) @@ -10,6 +11,7 @@ from oamc.importer.ingest import ( __all__ = [ "finde_klasse", "finde_verein", + "meisterschaft_der_saison", "verarbeite_ergebnisse", "verarbeite_starts", ] diff --git a/src/oamc/importer/ingest.py b/src/oamc/importer/ingest.py index 2219985..78893bc 100644 --- a/src/oamc/importer/ingest.py +++ b/src/oamc/importer/ingest.py @@ -15,10 +15,13 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session from oamc.matching import MatchStatus, match_oder_neu, normalisiere +from oamc.meisterschaft.berechnung import PLATZHALTER_PUNKTETABELLE from oamc.models import ( Ergebnis, ImportJob, Klasse, + Meisterschaft, + Saison, Start, Turnier, Verein, @@ -63,6 +66,30 @@ def _saison_fuer_turnier(db: Session, turnier: Turnier) -> int: return turnier.veranstaltung.datum.year +def meisterschaft_der_saison(db: Session, jahr: int, *, anlegen: bool = True) -> Meisterschaft | None: + """Die (einzige) Meisterschaft einer Saison — bei Bedarf anlegen. + + Modell: pro Saison genau eine Meisterschaft; die zaehlenden Turniere haengen + daran (``turnier.meisterschaft_id``).""" + + m = db.scalars(select(Meisterschaft).where(Meisterschaft.saison_jahr == jahr)).first() + if m is not None or not anlegen: + return m + if db.get(Saison, jahr) is None: + return None # ohne Saison-Stammdatensatz keine Meisterschaft + m = Meisterschaft( + saison_jahr=jahr, + bezeichnung=f"HTH-Meisterschaft {jahr}", + regelwerk={ + "punkte": {str(k): v for k, v in PLATZHALTER_PUNKTETABELLE.items()}, + "_hinweis": "O-1 offen: PLATZHALTER-Punktetabelle, nicht offiziell!", + }, + ) + db.add(m) + db.flush() + return m + + def _idempotenz_treffer(db: Session, schluessel: str | None) -> ImportJob | None: if not schluessel: return None diff --git a/src/oamc/meisterschaft/__init__.py b/src/oamc/meisterschaft/__init__.py index f30a330..8f4ec74 100644 --- a/src/oamc/meisterschaft/__init__.py +++ b/src/oamc/meisterschaft/__init__.py @@ -1,23 +1,33 @@ """Meisterschafts-/Pokalwertung (HTH) — AP 3. -BLOCKIERT durch offene Frage O-1: die echte Punktetabelle (Platz -> Punkte) ist -noch nicht bekannt. Bis dahin arbeitet dieses Modul mit -``PLATZHALTER_PUNKTETABELLE`` (klar als solche markiert) bzw. mit der Tabelle aus -``meisterschaft.regelwerk['punkte']``. +Eine Meisterschaft pro Saison; darin mehrere Wertungen (Erwachsene-Gesamt + je +Jugendklasse). BLOCKIERT durch offene Frage O-1: die echte Punktetabelle +(Platz -> Punkte) ist noch nicht bekannt — bis dahin +``PLATZHALTER_PUNKTETABELLE`` bzw. ``meisterschaft.regelwerk['punkte']``. """ from oamc.meisterschaft.berechnung import ( PLATZHALTER_PUNKTETABELLE, + MeisterschaftsBericht, StandZeile, + Wertung, + berechne_alle_wertungen, berechne_stand, endlauf_qualifikation, + gewertete_laeufe, punkte_fuer_platz, + wertungen_fuer, ) __all__ = [ "PLATZHALTER_PUNKTETABELLE", + "MeisterschaftsBericht", "StandZeile", + "Wertung", "berechne_stand", + "berechne_alle_wertungen", + "wertungen_fuer", "endlauf_qualifikation", + "gewertete_laeufe", "punkte_fuer_platz", ] diff --git a/src/oamc/meisterschaft/berechnung.py b/src/oamc/meisterschaft/berechnung.py index fa7f248..52e3099 100644 --- a/src/oamc/meisterschaft/berechnung.py +++ b/src/oamc/meisterschaft/berechnung.py @@ -1,14 +1,18 @@ """Meisterschaftsstand, Streichresultate, Tie-Break, Endlaufqualifikation. -Regeln (Plan §3.4): -* Erwachsene: ein Gesamtklassement A + S (klassenuebergreifend). -* Jugend: Pokalwertung klassenweise J 1 - J 5. -* Streichresultate nach Tabelle in ``saison.streichresultate_tabelle`` +Modell: **eine Meisterschaft pro Saison**, dazu gehoeren die Turniere mit +``turnier.meisterschaft_id``. Innerhalb dieser einen Meisterschaft werden mehrere +**Wertungen** gerechnet (Plan §3.4): + +* Erwachsene: ein Gesamtklassement A + S (``kategorie=S``, ``klasse_id=None``) +* Jugend: Pokalwertung je Klasse J 1 - J 5 (``kategorie=J``, ``klasse_id`` gesetzt) + +Weitere Regeln: +* Streichresultate nach ``saison.streichresultate_tabelle`` ("Turniere in der Saison" -> "davon gewertet"). -* Nur Laeufe mit ``turnier.zaehlt_meisterschaft``. * Tie-Break bei Punktgleichheit: Majoritaet der Siege. -* Endlauf: die besten 10 Erwachsenen bzw. 10 Jugendlichen — die Anwendung - *schlaegt vor*, entscheidet nicht (Bereichsleiter). +* Endlauf: die besten 10 je Wertung — die Anwendung *schlaegt vor*, entscheidet + nicht (Bereichsleiter). Die Punktetabelle ist offene Frage **O-1**. ``PLATZHALTER_PUNKTETABELLE`` ist NICHT die echte Tabelle und erzeugt bei Verwendung einen Hinweis im Bericht. @@ -50,9 +54,8 @@ def punkte_fuer_platz(platz: int | None, tabelle: dict[int, int] | None = None) tab = tabelle or PLATZHALTER_PUNKTETABELLE if platz in tab: return Decimal(tab[platz]) - # unterhalb der Tabelle: kleinster definierter Wert bzw. Platzhalter if tabelle: - return Decimal(min(tabelle.values())) if tabelle else Decimal(0) + return Decimal(min(tabelle.values())) return Decimal(PLATZHALTER_PUNKTE_AB_15) @@ -61,18 +64,26 @@ def gewertete_laeufe(anzahl_turniere: int, tabelle: dict[str, int] | None) -> in if not tabelle: return anzahl_turniere - # exakter Eintrag? key = str(anzahl_turniere) if key in tabelle: return int(tabelle[key]) - # sonst: naechstkleinerer definierter Schluessel - kleinere = sorted((int(k) for k in tabelle), reverse=True) - for k in kleinere: + for k in sorted((int(k) for k in tabelle), reverse=True): if k <= anzahl_turniere: return int(tabelle[str(k)]) return anzahl_turniere +@dataclass +class Wertung: + """Eine Teilwertung innerhalb der Jahresmeisterschaft.""" + + code: str # "erwachsene" | "J 1" ... "J 5" + bezeichnung: str + kategorie: Kategorie + klasse_id: int | None + endlauf_plaetze: int = 10 + + @dataclass class StandZeile: fahrer_id: int @@ -87,14 +98,53 @@ class StandZeile: class MeisterschaftsBericht: meisterschaft_id: int saison_jahr: int + wertung: str # code der Wertung kategorie: str + klasse_id: int | None laeufe_gesamt: int laeufe_gewertet: int zeilen: list[StandZeile] hinweise: list[str] = field(default_factory=list) -def _relevante_starts(db: Session, m: Meisterschaft, turnier_ids: list[int]) -> list[Start]: +def wertungen_fuer(db: Session, meisterschaft_id: int) -> list[Wertung]: + """Die Wertungen der Jahresmeisterschaft: Erwachsene-Gesamt + je Jugendklasse + der Saison.""" + + m = db.get(Meisterschaft, meisterschaft_id) + if m is None: + raise ValueError(f"Meisterschaft {meisterschaft_id} nicht gefunden") + wertungen = [ + Wertung( + code="erwachsene", + bezeichnung="Erwachsene (A + S, klassenuebergreifend)", + kategorie=Kategorie.STAMM, + klasse_id=None, + ) + ] + jugend = db.scalars( + select(Klasse) + .where(Klasse.saison_jahr == m.saison_jahr, Klasse.kategorie == Kategorie.JUGEND) + .order_by(Klasse.sortierung) + ).all() + for k in jugend: + wertungen.append( + Wertung( + code=k.code, + bezeichnung=f"Jugend {k.code}", + kategorie=Kategorie.JUGEND, + klasse_id=k.id, + ) + ) + return wertungen + + +def _relevante_starts( + db: Session, + turnier_ids: list[int], + kategorie: Kategorie, + klasse_id: int | None, +) -> list[Start]: stmt = ( select(Start) .join(Ergebnis, Ergebnis.start_id == Start.id) @@ -105,9 +155,9 @@ def _relevante_starts(db: Session, m: Meisterschaft, turnier_ids: list[int]) -> Start.status == StartStatus.GESTARTET, ) ) - if m.kategorie == Kategorie.JUGEND: - if m.klasse_id is not None: - stmt = stmt.where(Start.klasse_id == m.klasse_id) + if kategorie == Kategorie.JUGEND: + if klasse_id is not None: + stmt = stmt.where(Start.klasse_id == klasse_id) else: stmt = stmt.where(Klasse.kategorie == Kategorie.JUGEND) else: @@ -116,7 +166,15 @@ def _relevante_starts(db: Session, m: Meisterschaft, turnier_ids: list[int]) -> return list(db.scalars(stmt)) -def berechne_stand(db: Session, meisterschaft_id: int) -> MeisterschaftsBericht: +def berechne_stand( + db: Session, + meisterschaft_id: int, + *, + kategorie: Kategorie = Kategorie.STAMM, + klasse_id: int | None = None, +) -> MeisterschaftsBericht: + """Stand EINER Wertung der Jahresmeisterschaft.""" + m = db.get(Meisterschaft, meisterschaft_id) if m is None: raise ValueError(f"Meisterschaft {meisterschaft_id} nicht gefunden") @@ -134,29 +192,27 @@ def berechne_stand(db: Session, meisterschaft_id: int) -> MeisterschaftsBericht: if regelwerk.get("_hinweis"): hinweise.append(str(regelwerk["_hinweis"])) + # Turniere dieser Jahresmeisterschaft, deren Veranstaltung (vor-)final ist turniere = list( db.scalars( select(Turnier) .join(Veranstaltung, Veranstaltung.id == Turnier.veranstaltung_id) .where( - Veranstaltung.datum.is_not(None), - Turnier.zaehlt_meisterschaft.is_(True), + Turnier.meisterschaft_id == meisterschaft_id, Veranstaltung.status.in_(["vorlaeufig", "final"]), ) .order_by(Veranstaltung.datum, Turnier.id) ) ) - # nur Turniere der Saison der Meisterschaft - turniere = [t for t in turniere if t.veranstaltung.datum.year == m.saison_jahr] turnier_ids = [t.id for t in turniere] roh: dict[int, dict[int, Decimal]] = {} # fahrer_id -> {turnier_id: punkte} siege: dict[int, int] = {} if turnier_ids: - for s in _relevante_starts(db, m, turnier_ids): + for s in _relevante_starts(db, turnier_ids, kategorie, klasse_id): p = punkte_fuer_platz(s.ergebnis.platzierung, punkte_tab) bucket = roh.setdefault(s.fahrer_id, {}) - # Mehrfachstart in derselben Kategorie am selben Tag: bestes Ergebnis zaehlt + # Mehrfachstart am selben Tag: bestes Ergebnis zaehlt bucket[s.turnier_id] = max(bucket.get(s.turnier_id, Decimal(0)), p) if s.ergebnis.platzierung == 1: siege[s.fahrer_id] = siege.get(s.fahrer_id, 0) + 1 @@ -185,7 +241,9 @@ def berechne_stand(db: Session, meisterschaft_id: int) -> MeisterschaftsBericht: return MeisterschaftsBericht( meisterschaft_id=meisterschaft_id, saison_jahr=m.saison_jahr, - kategorie=m.kategorie.value, + wertung="erwachsene" if kategorie != Kategorie.JUGEND else _klassencode(db, klasse_id), + kategorie=kategorie.value, + klasse_id=klasse_id, laeufe_gesamt=len(turnier_ids), laeufe_gewertet=n_gewertet, zeilen=zeilen, @@ -193,6 +251,24 @@ def berechne_stand(db: Session, meisterschaft_id: int) -> MeisterschaftsBericht: ) +def _klassencode(db: Session, klasse_id: int | None) -> str: + if klasse_id is None: + return "jugend-gesamt" + k = db.get(Klasse, klasse_id) + return k.code if k else str(klasse_id) + + +def berechne_alle_wertungen(db: Session, meisterschaft_id: int) -> list[MeisterschaftsBericht]: + berichte = [] + for w in wertungen_fuer(db, meisterschaft_id): + berichte.append( + berechne_stand( + db, meisterschaft_id, kategorie=w.kategorie, klasse_id=w.klasse_id + ) + ) + return berichte + + @dataclass class QualiVorschlag: fahrer_id: int @@ -203,17 +279,23 @@ class QualiVorschlag: def endlauf_qualifikation( - db: Session, meisterschaft_id: int, plaetze: int = 10 + db: Session, + meisterschaft_id: int, + *, + kategorie: Kategorie = Kategorie.STAMM, + klasse_id: int | None = None, + plaetze: int = 10, ) -> list[QualiVorschlag]: - """Vorschlag Top-N fuer den Endlauf. Bei Punktgleichheit an der Grenze wird - ``tie_break_relevant`` gesetzt — die endgueltige Festlegung trifft der - Bereichsleiter (Plan §3.4).""" + """Vorschlag Top-N fuer den Endlauf EINER Wertung. Bei Punktgleichheit an der + Grenze wird ``tie_break_relevant`` gesetzt — die endgueltige Festlegung trifft + der Bereichsleiter (Plan §3.4).""" - bericht = berechne_stand(db, meisterschaft_id) - zeilen = bericht.zeilen[: plaetze + 5] # etwas Puffer fuer die Grenzbetrachtung + bericht = berechne_stand(db, meisterschaft_id, kategorie=kategorie, klasse_id=klasse_id) + grenz_punkte = ( + bericht.zeilen[plaetze - 1].punkte_gesamt if len(bericht.zeilen) >= plaetze else None + ) vorschlaege: list[QualiVorschlag] = [] - grenz_punkte = bericht.zeilen[plaetze - 1].punkte_gesamt if len(bericht.zeilen) >= plaetze else None - for z in zeilen: + for z in bericht.zeilen[: plaetze + 5]: vorschlaege.append( QualiVorschlag( fahrer_id=z.fahrer_id, diff --git a/src/oamc/models/meisterschaft.py b/src/oamc/models/meisterschaft.py index 08fa438..bae341b 100644 --- a/src/oamc/models/meisterschaft.py +++ b/src/oamc/models/meisterschaft.py @@ -1,9 +1,18 @@ """Meisterschafts-/Pokalwertung (HTH) — Plan §3.4. +Modell (Vorgabe des Auftraggebers): **pro Saison genau EINE Meisterschaft**. Zu +dieser Meisterschaft gehoeren die Turniere, die in die Jahreswertung zaehlen +(``turnier.meisterschaft_id``). Innerhalb der einen Meisterschaft gibt es mehrere +**Wertungen**: + * Erwachsene: ein Gesamtklassement A + S (klassenuebergreifend) * Jugend: Pokalwertung klassenweise J 1 - J 5 -* Streichresultate nach Tabelle in ``saison.streichresultate_tabelle`` -* Tie-Break: Majoritaet der Siege + +Diese Wertungen sind keine eigenen Tabellen, sondern (Kategorie, Klasse)-Sichten +auf die eine Meisterschaft — abgebildet als Spalten in ``meisterschaft_stand``. + +* Streichresultate nach ``saison.streichresultate_tabelle`` +* Tie-Break bei Punktgleichheit: Majoritaet der Siege * Die Anwendung *schlaegt* die Endlaufqualifikation *vor*, entscheidet nicht. """ @@ -11,7 +20,7 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, func +from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, UniqueConstraint, func from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -20,39 +29,57 @@ from oamc.models.enums import Kategorie class Meisterschaft(Base): + """Genau eine pro Saison. Container fuer die zaehlenden Turniere und das + Regelwerk (Punktetabelle, Streichregel-Override, Endlauf-Parameter).""" + __tablename__ = "meisterschaft" + __table_args__ = (UniqueConstraint("saison_jahr", name="meisterschaft_saison_eindeutig"),) id: Mapped[int] = mapped_column(Integer, primary_key=True) - saison_jahr: Mapped[int] = mapped_column(ForeignKey("saison.jahr"), nullable=False, index=True) - bezeichnung: Mapped[str] = mapped_column(String(200), nullable=False) - kategorie: Mapped[Kategorie] = mapped_column( - sa_enum(Kategorie, "meisterschaft_kategorie"), nullable=False + saison_jahr: Mapped[int] = mapped_column( + ForeignKey("saison.jahr"), nullable=False, index=True, unique=True ) - # nur bei Jugend gesetzt (klassenweise J1..J5); bei Erwachsenen NULL - klasse_id: Mapped[int | None] = mapped_column(ForeignKey("klasse.id")) - # Punktetabelle (Platz -> Punkte), Streichregel-Override, Endlauf-Parameter. + bezeichnung: Mapped[str] = mapped_column(String(200), nullable=False) + # Punktetabelle (Platz -> Punkte), Streich-Override, Endlauf-Parameter. # Solange O-1 offen ist, steht hier die (klar markierte) Platzhalter-Tabelle. regelwerk: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + turniere: Mapped[list[Turnier]] = relationship( # noqa: F821 + back_populates="meisterschaft" + ) staende: Mapped[list[MeisterschaftStand]] = relationship( back_populates="meisterschaft", cascade="all, delete-orphan" ) class MeisterschaftStand(Base): - """Materialisiert — nach jedem Turnier neu berechnet.""" + """Materialisiert — nach jedem Turnier neu berechnet, je **Wertung** + (Kategorie + optional Klasse) innerhalb der einen Meisterschaft.""" __tablename__ = "meisterschaft_stand" + __table_args__ = ( + UniqueConstraint( + "meisterschaft_id", + "kategorie", + "klasse_id", + "fahrer_id", + name="meisterschaft_stand_eindeutig", + ), + ) id: Mapped[int] = mapped_column(Integer, primary_key=True) meisterschaft_id: Mapped[int] = mapped_column( ForeignKey("meisterschaft.id", ondelete="CASCADE"), nullable=False, index=True ) + # Wertung: Erwachsene-Gesamt (kategorie=S, klasse_id=NULL) oder Jugend je Klasse + kategorie: Mapped[Kategorie] = mapped_column( + sa_enum(Kategorie, "meisterschaft_stand_kategorie"), nullable=False + ) + klasse_id: Mapped[int | None] = mapped_column(ForeignKey("klasse.id")) + fahrer_id: Mapped[int] = mapped_column(ForeignKey("fahrer.id"), nullable=False, index=True) punkte_gesamt: Mapped[float] = mapped_column(Numeric(9, 2), nullable=False, default=0) - # {turnier_id: punkte} punkte_je_lauf: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) - # Liste der turnier_ids, die als Streichresultat NICHT zaehlen gestrichene_laeufe: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) anzahl_siege: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # Tie-Break platz: Mapped[int | None] = mapped_column(Integer) diff --git a/src/oamc/models/veranstaltung.py b/src/oamc/models/veranstaltung.py index ab8d860..c0d2de8 100644 --- a/src/oamc/models/veranstaltung.py +++ b/src/oamc/models/veranstaltung.py @@ -57,8 +57,11 @@ class Turnier(Base, TimestampMixin): ) name: Mapped[str] = mapped_column(String(120), nullable=False) # "Reinheim" / "Hainstadt" veranstalter_verein_id: Mapped[int | None] = mapped_column(ForeignKey("verein.id")) - # Nicht jede Veranstaltung zaehlt zur Meisterschaft ("o. M.", Plan §3.4). - zaehlt_meisterschaft: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + # Pro Saison gibt es EINE Meisterschaft; ein Turnier gehoert zu ihr, wenn es + # zaehlt. NULL = "o. M." (ohne Meisterschaft, Plan §3.4). + meisterschaft_id: Mapped[int | None] = mapped_column( + ForeignKey("meisterschaft.id", ondelete="SET NULL"), index=True + ) # Genau ein Turnier kann als "aktiv" markiert sein -> Geraete-API-Endpunkte # /turniere/aktiv/*. Partieller Unique-Index in der Migration. ist_aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) @@ -69,6 +72,9 @@ class Turnier(Base, TimestampMixin): ) veranstaltung: Mapped[Veranstaltung] = relationship(back_populates="turniere") + meisterschaft: Mapped[Meisterschaft | None] = relationship( # noqa: F821 + back_populates="turniere" + ) wertungsklassen: Mapped[list[Wertungsklasse]] = relationship( back_populates="turnier", cascade="all, delete-orphan", order_by="Wertungsklasse.sortierung" ) @@ -76,6 +82,11 @@ class Turnier(Base, TimestampMixin): back_populates="turnier", cascade="all, delete-orphan" ) + @property + def zaehlt_meisterschaft(self) -> bool: + """Kompatibilitaets-Sicht: zaehlt zur Jahresmeisterschaft?""" + return self.meisterschaft_id is not None + class Wertungsklasse(Base): """Pro Turnier zusammengefasste Klassen (Plan §3.2): z. B. "S 5, S 9, S 3, S 6, S 7". diff --git a/src/oamc/seeds/saison_2026.py b/src/oamc/seeds/saison_2026.py index 56ad5f9..0fe3dad 100644 --- a/src/oamc/seeds/saison_2026.py +++ b/src/oamc/seeds/saison_2026.py @@ -123,24 +123,15 @@ def seed_saison_2026(db: Session) -> dict[str, int]: "_hinweis": "O-1 offen: PLATZHALTER-Punktetabelle, nicht offiziell!", } + # Genau EINE Meisterschaft pro Saison. Die Turniere werden ihr spaeter je + # Turnier zugeordnet (turnier.meisterschaft_id); die Teilwertungen (Erwachsene, + # Jugend J 1-J 5) sind Sichten darauf, keine eigenen Zeilen. _, neu = _get_or_create( - db, Meisterschaft, saison_jahr=2026, bezeichnung="HTH Erwachsene 2026", - defaults={"kategorie": Kategorie.STAMM, "regelwerk": platzhalter_regelwerk}, + db, + Meisterschaft, + saison_jahr=2026, + defaults={"bezeichnung": "HTH-Meisterschaft 2026", "regelwerk": platzhalter_regelwerk}, ) zaehler["meisterschaft"] += int(neu) - jugendklassen = db.scalars( - select(Klasse).where(Klasse.saison_jahr == 2026, Klasse.kategorie == Kategorie.JUGEND) - ).all() - for k in jugendklassen: - _, neu = _get_or_create( - db, Meisterschaft, saison_jahr=2026, bezeichnung=f"HTH Jugend {k.code} 2026", - defaults={ - "kategorie": Kategorie.JUGEND, - "klasse_id": k.id, - "regelwerk": platzhalter_regelwerk, - }, - ) - zaehler["meisterschaft"] += int(neu) - return zaehler diff --git a/src/oamc/templates/public/meisterschaft_detail.html b/src/oamc/templates/public/meisterschaft_detail.html index e153967..75ac380 100644 --- a/src/oamc/templates/public/meisterschaft_detail.html +++ b/src/oamc/templates/public/meisterschaft_detail.html @@ -1,8 +1,17 @@ {% extends "base.html" %} -{% block title %}Meisterschaftsstand · {{ projekt }}{% endblock %} +{% block title %}{{ meisterschaft.bezeichnung }} · {{ projekt }}{% endblock %} {% block content %} -

Meisterschaftsstand

-

Saison {{ bericht.saison_jahr }} · Kategorie {{ bericht.kategorie }} · +

{{ meisterschaft.bezeichnung }}

+ + + +

{{ aktive_wertung.bezeichnung }}

+

Saison {{ bericht.saison_jahr }} · {{ bericht.laeufe_gewertet }} von {{ bericht.laeufe_gesamt }} Läufen gewertet (Rest = Streichresultate)

diff --git a/src/oamc/templates/public/meisterschaft_liste.html b/src/oamc/templates/public/meisterschaft_liste.html index 2971421..270ed5f 100644 --- a/src/oamc/templates/public/meisterschaft_liste.html +++ b/src/oamc/templates/public/meisterschaft_liste.html @@ -5,7 +5,7 @@
    {% for m in meisterschaften %}
  • {{ m.bezeichnung }} - {{ m.kategorie.value }}
  • + Saison {{ m.saison_jahr }} {% else %}
  • Keine Meisterschaft angelegt.
  • {% endfor %} diff --git a/tests/factories.py b/tests/factories.py index da9367c..159e2ea 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -10,6 +10,7 @@ from oamc.models import ( Ergebnis, Fahrer, Klasse, + Meisterschaft, Start, Turnier, Veranstaltung, @@ -18,6 +19,11 @@ from oamc.models import ( from oamc.models.enums import StartStatus, TurnierStatus, VeranstaltungStatus +def meisterschaft(db, jahr=2026): + """Die eine Meisterschaft der Saison (aus dem Seed).""" + return db.scalars(select(Meisterschaft).where(Meisterschaft.saison_jahr == jahr)).one() + + def klasse(db, code="J 3"): return db.scalars(select(Klasse).where(Klasse.code == code, Klasse.saison_jahr == 2026)).one() @@ -33,11 +39,13 @@ def veranstaltung(db, *, datum=date(2026, 5, 31), titel="Testturnier", status=Ve return v -def turnier(db, v=None, *, name="Reinheim", zaehlt_meisterschaft=True, aktiv=False, +def turnier(db, v=None, *, name="Reinheim", meisterschaft=None, aktiv=False, status=TurnierStatus.LAUFEND, flush=True): + """``meisterschaft``: Meisterschaft-Objekt (zaehlt zur Jahreswertung) oder None.""" v = v or veranstaltung(db) t = Turnier( - veranstaltung_id=v.id, name=name, zaehlt_meisterschaft=zaehlt_meisterschaft, + veranstaltung_id=v.id, name=name, + meisterschaft_id=meisterschaft.id if meisterschaft is not None else None, ist_aktiv=aktiv, status=status, ) db.add(t) diff --git a/tests/test_api_ingest.py b/tests/test_api_ingest.py index 7ab89bc..4a7bfcf 100644 --- a/tests/test_api_ingest.py +++ b/tests/test_api_ingest.py @@ -102,3 +102,36 @@ def test_finalisieren_setzt_status_final(client): r = client.post(f"/api/v1/turniere/{tid}/finalisieren", headers=HEAD) assert r.status_code == 200 assert client.db.get(Turnier, tid).status.value == "final" + + +def test_turnier_wird_an_jahresmeisterschaft_gehaengt(client): + db = client.db + seed_saison_2026(db) + v = f.veranstaltung(db) + db.flush() + r = client.post("/api/v1/turniere", headers=HEAD, json={ + "veranstaltung_id": v.id, "name": "Reinheim", "zaehlt_meisterschaft": True, + }) + assert r.status_code == 201, r.text + m = f.meisterschaft(db) + assert r.json()["meisterschaft_id"] == m.id + + # o. M. -> keine Verknuepfung + r2 = client.post("/api/v1/turniere", headers=HEAD, json={ + "veranstaltung_id": v.id, "name": "Pokal o. M.", "zaehlt_meisterschaft": False, + }) + assert r2.json()["meisterschaft_id"] is None + + +def test_meisterschaft_stand_liefert_alle_wertungen(client): + db = client.db + seed_saison_2026(db) + m = f.meisterschaft(db) + r = client.get(f"/api/v1/meisterschaften/{m.id}/stand") + assert r.status_code == 200 + codes = {w["wertung"] for w in r.json()["wertungen"]} + assert "erwachsene" in codes + # eine einzelne Wertung gezielt abrufen + r2 = client.get(f"/api/v1/meisterschaften/{m.id}/stand", params={"wertung": "J 3"}) + assert r2.status_code == 200 + assert r2.json()["wertung"] == "J 3" diff --git a/tests/test_meisterschaft.py b/tests/test_meisterschaft.py index 5f0c2f9..3b93b95 100644 --- a/tests/test_meisterschaft.py +++ b/tests/test_meisterschaft.py @@ -1,18 +1,16 @@ -"""Meisterschaftsberechnung (AP 3) — mit PLATZHALTER-Punktetabelle (O-1 offen).""" +"""Meisterschaftsberechnung (AP 3) — eine Meisterschaft pro Saison, mehrere +Wertungen darin. PLATZHALTER-Punktetabelle (O-1 offen).""" from datetime import date from decimal import Decimal -from sqlalchemy import select - from oamc.meisterschaft.berechnung import ( - PLATZHALTER_PUNKTETABELLE, berechne_stand, gewertete_laeufe, punkte_fuer_platz, + wertungen_fuer, ) -from oamc.models import Meisterschaft -from oamc.models.enums import VeranstaltungStatus +from oamc.models.enums import Kategorie, VeranstaltungStatus from tests import factories as f @@ -30,10 +28,19 @@ def test_streichresultate_tabelle(): assert gewertete_laeufe(3, tab) == 3 # unterhalb der Tabelle: kein Streichresultat -def _lauf(db, k, ergebnisse, *, datum, zaehlt=True): +def test_eine_meisterschaft_pro_saison_mit_wertungen(seed_2026): + db = seed_2026 + m = f.meisterschaft(db) + wertungen = wertungen_fuer(db, m.id) + codes = {w.code for w in wertungen} + assert "erwachsene" in codes + assert {"J 1", "J 2", "J 3", "J 4", "J 5"} <= codes + + +def _lauf(db, m, k, ergebnisse, *, datum, zaehlt=True): """ergebnisse = [(fahrer, platz), ...]""" v = f.veranstaltung(db, datum=datum, status=VeranstaltungStatus.FINAL) - t = f.turnier(db, v, zaehlt_meisterschaft=zaehlt) + t = f.turnier(db, v, meisterschaft=m if zaehlt else None) for i, (fahrer, platz) in enumerate(ergebnisse, start=1): s = f.start(db, t, fahrer, k, startnummer=i) e = f.ergebnis(db, s, zeit=Decimal("100"), fehler=0) @@ -44,48 +51,61 @@ def _lauf(db, k, ergebnisse, *, datum, zaehlt=True): def test_stand_summiert_punkte_und_zaehlt_siege(seed_2026): db = seed_2026 + m = f.meisterschaft(db) k = f.klasse(db, "J 3") a = f.fahrer(db, "Sieger", "Sina", 2014) b = f.fahrer(db, "Zweite", "Zoe", 2014) - _lauf(db, k, [(a, 1), (b, 2)], datum=date(2026, 4, 1)) - _lauf(db, k, [(a, 1), (b, 2)], datum=date(2026, 5, 1)) - m = db.scalars( - select(Meisterschaft).where(Meisterschaft.bezeichnung == "HTH Jugend J 3 2026") - ).one() - bericht = berechne_stand(db, m.id) + _lauf(db, m, k, [(a, 1), (b, 2)], datum=date(2026, 4, 1)) + _lauf(db, m, k, [(a, 1), (b, 2)], datum=date(2026, 5, 1)) + bericht = berechne_stand(db, m.id, kategorie=Kategorie.JUGEND, klasse_id=k.id) zeilen = {z.fahrer_id: z for z in bericht.zeilen} assert zeilen[a.id].platz == 1 assert zeilen[a.id].punkte_gesamt == Decimal(50) # 2x Platz 1 = 2x25 assert zeilen[a.id].anzahl_siege == 2 assert bericht.hinweise # O-1 Platzhalter-Warnung + assert bericht.wertung == "J 3" + + +def test_wertungen_sind_getrennt(seed_2026): + db = seed_2026 + m = f.meisterschaft(db) + j3, j4 = f.klasse(db, "J 3"), f.klasse(db, "J 4") + kind_j3 = f.fahrer(db, "Drei", "D", 2014) + kind_j4 = f.fahrer(db, "Vier", "V", 2012) + v = f.veranstaltung(db, status=VeranstaltungStatus.FINAL) + t = f.turnier(db, v, meisterschaft=m) + for i, (fahrer, kl) in enumerate([(kind_j3, j3), (kind_j4, j4)], start=1): + s = f.start(db, t, fahrer, kl, startnummer=i) + e = f.ergebnis(db, s, zeit=Decimal("100"), fehler=0) + e.platzierung = 1 + db.flush() + stand_j3 = berechne_stand(db, m.id, kategorie=Kategorie.JUGEND, klasse_id=j3.id) + stand_j4 = berechne_stand(db, m.id, kategorie=Kategorie.JUGEND, klasse_id=j4.id) + assert [z.fahrer_id for z in stand_j3.zeilen] == [kind_j3.id] + assert [z.fahrer_id for z in stand_j4.zeilen] == [kind_j4.id] def test_tie_break_majoritaet_der_siege(seed_2026): db = seed_2026 + m = f.meisterschaft(db) k = f.klasse(db, "J 3") a = f.fahrer(db, "Aaa", "A", 2014) b = f.fahrer(db, "Bbb", "B", 2014) - # beide sammeln gleich viele Punkte, aber A hat mehr Siege - _lauf(db, k, [(a, 1), (b, 6)], datum=date(2026, 4, 1)) # A:25 B:10 - _lauf(db, k, [(b, 1), (a, 6)], datum=date(2026, 5, 1)) # B:25 A:10 - _lauf(db, k, [(a, 1), (b, 1)], datum=date(2026, 6, 1)) # beide 25, beide Sieg - _lauf(db, k, [(a, 1), (b, 4)], datum=date(2026, 6, 15)) # A:25 (Sieg) B:13 - m = db.scalars( - select(Meisterschaft).where(Meisterschaft.bezeichnung == "HTH Jugend J 3 2026") - ).one() - bericht = berechne_stand(db, m.id) + _lauf(db, m, k, [(a, 1), (b, 6)], datum=date(2026, 4, 1)) # A:25 B:10 + _lauf(db, m, k, [(b, 1), (a, 6)], datum=date(2026, 5, 1)) # B:25 A:10 + _lauf(db, m, k, [(a, 1), (b, 1)], datum=date(2026, 6, 1)) # beide 25, beide Sieg + _lauf(db, m, k, [(a, 1), (b, 4)], datum=date(2026, 6, 15)) # A:25 (Sieg) B:13 + bericht = berechne_stand(db, m.id, kategorie=Kategorie.JUGEND, klasse_id=k.id) assert bericht.zeilen[0].fahrer_id == a.id assert bericht.zeilen[0].anzahl_siege > bericht.zeilen[1].anzahl_siege def test_laeufe_ohne_meisterschaft_zaehlen_nicht(seed_2026): db = seed_2026 + m = f.meisterschaft(db) k = f.klasse(db, "J 3") a = f.fahrer(db, "Solo", "S", 2014) - _lauf(db, k, [(a, 1)], datum=date(2026, 4, 1), zaehlt=False) - m = db.scalars( - select(Meisterschaft).where(Meisterschaft.bezeichnung == "HTH Jugend J 3 2026") - ).one() - bericht = berechne_stand(db, m.id) + _lauf(db, m, k, [(a, 1)], datum=date(2026, 4, 1), zaehlt=False) + bericht = berechne_stand(db, m.id, kategorie=Kategorie.JUGEND, klasse_id=k.id) assert bericht.laeufe_gesamt == 0 assert bericht.zeilen == [] diff --git a/tests/test_seed.py b/tests/test_seed.py index 5585e07..1ca5007 100644 --- a/tests/test_seed.py +++ b/tests/test_seed.py @@ -30,15 +30,19 @@ def test_seed_saison_streichresultate(seed_2026): assert s.streichresultate_tabelle["4"] == 2 -def test_seed_meisterschaften(seed_2026): +def test_seed_meisterschaft(seed_2026): db = seed_2026 ms = db.scalars(select(Meisterschaft).where(Meisterschaft.saison_jahr == 2026)).all() - bezeichnungen = {m.bezeichnung for m in ms} - assert "HTH Erwachsene 2026" in bezeichnungen - assert "HTH Jugend J 3 2026" in bezeichnungen + # genau EINE Meisterschaft pro Saison + assert len(ms) == 1 + assert ms[0].bezeichnung == "HTH-Meisterschaft 2026" # O-1: Platzhalter-Punktetabelle ist markiert - erw = next(m for m in ms if m.bezeichnung == "HTH Erwachsene 2026") - assert "_hinweis" in erw.regelwerk + assert "_hinweis" in ms[0].regelwerk + + from oamc.meisterschaft.berechnung import wertungen_fuer + + codes = {w.code for w in wertungen_fuer(db, ms[0].id)} + assert "erwachsene" in codes and "J 3" in codes def test_seed_vereins_kurznamen(seed_2026):