Erstumsetzung: OAMC Turnierauswertung (AP 0–7, 9)

* Datenmodell + Alembic-Initialmigration (Plan §4), Seed Saison 2026
* Wertungs-Engine (Plan §3.3), regelbasiert je Kategorie/Saison
  - Abnahmetest gegen die Ergebnisliste 2026-05-31
* Meisterschaftsberechnung mit Streichresultaten + Tie-Break
  (PLATZHALTER-Punktetabelle, O-1 offen)
* Ingest-API (idempotent, dry_run) inkl. Excel-Leser und Fahrer-Matching/Merge
* Zeitmessungs-Endpoint (Batch, idempotent, unzugeordnet/unplausibel) + SSE-Anzeige
  - Mess-Pi-Client bewusst NICHT enthalten (docs/zeitmessung-endpoint.md)
* Frontend: öffentliche Seiten + internes Backend (Jinja2/HTMX, kein Build)
* Auslieferung: Docker (multi-arch) + docker-compose sowie Debian-Paket/APT-Repo
* 57 Tests grün gegen echte PostgreSQL-Testdatenbank

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
linus
2026-09-05 18:49:00 +02:00
co-authored by Claude Sonnet 5
commit 03f6fdb0d9
115 changed files with 8041 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
"""Start (eine Teilnahme) und Ergebnis (1:1 zum Start).
Schluessel eines Ergebnisses ist (Turnier, Start), NICHT (Turnier, Fahrer) — ein
Fahrer kann am selben Tag mehrfach starten (Plan §3.3 Mehrfachstarts).
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from oamc.models.base import Base, sa_enum
from oamc.models.enums import StartStatus
class Start(Base):
__tablename__ = "start"
__table_args__ = (
UniqueConstraint("turnier_id", "startnummer", name="start_turnier_startnummer"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
turnier_id: Mapped[int] = mapped_column(
ForeignKey("turnier.id", ondelete="CASCADE"), nullable=False, index=True
)
fahrer_id: Mapped[int] = mapped_column(ForeignKey("fahrer.id"), nullable=False, index=True)
klasse_id: Mapped[int] = mapped_column(ForeignKey("klasse.id"), nullable=False)
wertungsklasse_id: Mapped[int | None] = mapped_column(
ForeignKey("wertungsklasse.id", ondelete="SET NULL"), index=True
)
startnummer: Mapped[int] = mapped_column(Integer, nullable=False)
fahrzeug: Mapped[str | None] = mapped_column(String(200))
nenngeld_bezahlt: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
status: Mapped[StartStatus] = mapped_column(
sa_enum(StartStatus, "start_status"), nullable=False, default=StartStatus.GENANNT
)
nennreihenfolge: Mapped[int | None] = mapped_column(Integer) # nummerierte Nennungsliste
turnier: Mapped[Turnier] = relationship(back_populates="starts") # noqa: F821
fahrer: Mapped[Fahrer] = relationship(back_populates="starts") # noqa: F821
klasse: Mapped[Klasse] = relationship() # noqa: F821
wertungsklasse: Mapped[Wertungsklasse | None] = relationship( # noqa: F821
back_populates="starts"
)
ergebnis: Mapped[Ergebnis | None] = relationship(
back_populates="start", cascade="all, delete-orphan", uselist=False
)
zeitmessungen: Mapped[list[Zeitmessung]] = relationship( # noqa: F821
back_populates="start"
)
@property
def gewertet(self) -> bool:
return self.status in (StartStatus.GESTARTET,)
class Ergebnis(Base):
"""1:1 zum Start — getrennte Tabelle fuer saubere Nachtraege/Korrekturen.
Zwei Quellen (Plan §3.6): ``summe_zeit`` aus ``zeitmessung`` summiert,
``summe_fehler`` manuell erfasst. Ein "halbes" Ergebnis ist der Normalzustand
waehrend der Veranstaltung. Gewertet wird erst, wenn beide ``*_vollstaendig``.
"""
__tablename__ = "ergebnis"
start_id: Mapped[int] = mapped_column(
ForeignKey("start.id", ondelete="CASCADE"), primary_key=True
)
summe_zeit: Mapped[Decimal | None] = mapped_column(Numeric(7, 2)) # aus zeitmessung summiert
summe_fehler: Mapped[int | None] = mapped_column(Integer) # manuell erfasst
gesamt: Mapped[Decimal | None] = mapped_column(Numeric(9, 2)) # berechnet
platzierung: Mapped[int | None] = mapped_column(Integer) # berechnet
zeit_vollstaendig: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
fehler_vollstaendig: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
erfasst_von: Mapped[str | None] = mapped_column(String(120))
erfasst_am: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
korrigiert_am: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
korrektur_grund: Mapped[str | None] = mapped_column(Text)
start: Mapped[Start] = relationship(back_populates="ergebnis")
@property
def wertbar(self) -> bool:
return bool(self.zeit_vollstaendig and self.fehler_vollstaendig)