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 <jahr>"
* Tests angepasst/ergaenzt (61 gruen)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
linus
2026-09-05 19:52:25 +02:00
co-authored by Claude Sonnet 5
parent 1df633bdec
commit c33aaa1a4b
19 changed files with 513 additions and 199 deletions
+10 -2
View File
@@ -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)
+33
View File
@@ -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"
+48 -28
View File
@@ -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 == []
+10 -6
View File
@@ -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):