Files
OAMC-Auswertung/migrations/versions/df5e2dd5a864_initiales_schema.py
T
linusandClaude Sonnet 5 03f6fdb0d9 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>
2026-09-05 18:49:00 +02:00

301 lines
19 KiB
Python

"""Initiales Schema
Revision ID: df5e2dd5a864
Revises:
Create Date: 2026-09-05 18:08:45.999851+02:00
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = 'df5e2dd5a864'
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.create_table('benutzer',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('benutzername', sa.String(length=80), nullable=False),
sa.Column('anzeigename', sa.String(length=120), nullable=False),
sa.Column('passwort_hash', sa.String(length=255), nullable=False),
sa.Column('rolle', sa.Enum('admin', 'auswerter', 'lesend', name='benutzer_rolle'), nullable=False),
sa.Column('aktiv', sa.Boolean(), nullable=False),
sa.Column('letzter_login_am', sa.DateTime(timezone=True), nullable=True),
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.PrimaryKeyConstraint('id', name=op.f('pk_benutzer')),
sa.UniqueConstraint('benutzername', name=op.f('uq_benutzer_benutzername'))
)
op.create_table('geraet',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('bezeichnung', sa.String(length=120), nullable=False),
sa.Column('typ', sa.Enum('lichtschranke', 'anzeige', name='geraet_typ'), nullable=False),
sa.Column('token_hash', sa.String(length=255), nullable=False),
sa.Column('aktiv', sa.Boolean(), nullable=False),
sa.Column('letzte_meldung_am', sa.DateTime(timezone=True), nullable=True),
sa.Column('firmware_version', sa.String(length=60), nullable=True),
sa.Column('queue_laenge_gemeldet', sa.Integer(), nullable=True),
sa.Column('notiz', sa.Text(), nullable=True),
sa.Column('konfiguration', postgresql.JSONB(astext_type=sa.Text()), 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.PrimaryKeyConstraint('id', name=op.f('pk_geraet')),
sa.UniqueConstraint('bezeichnung', name=op.f('uq_geraet_bezeichnung'))
)
op.create_table('import_job',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('quelle', sa.String(length=120), nullable=False),
sa.Column('idempotenz_schluessel', sa.String(length=200), nullable=True),
sa.Column('dateiname', sa.String(length=300), nullable=True),
sa.Column('roh_payload', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('ergebnis_bericht', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('status', sa.Enum('angenommen', 'vorschau', 'fehler', 'teilweise', name='import_status'), nullable=False),
sa.Column('fehler', sa.Text(), nullable=True),
sa.Column('angelegt_von', sa.String(length=120), nullable=True),
sa.Column('angelegt_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_import_job'))
)
op.create_index(op.f('ix_import_job_idempotenz_schluessel'), 'import_job', ['idempotenz_schluessel'], unique=False)
op.create_table('saison',
sa.Column('jahr', sa.Integer(), autoincrement=False, nullable=False),
sa.Column('turnierordnung_version', sa.String(length=60), nullable=True),
sa.Column('streichresultate_tabelle', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('nenngeld_cent', sa.Integer(), nullable=False),
sa.Column('aktiv', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('jahr', name=op.f('pk_saison'))
)
op.create_table('verein',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('kurzname', sa.String(length=60), nullable=True),
sa.Column('adac_gau', sa.String(length=120), nullable=True),
sa.Column('aktiv', sa.Boolean(), nullable=False),
sa.Column('notiz', sa.Text(), nullable=True),
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.PrimaryKeyConstraint('id', name=op.f('pk_verein')),
sa.UniqueConstraint('name', name=op.f('uq_verein_name'))
)
op.create_table('fahrer',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('nachname', sa.String(length=120), nullable=False),
sa.Column('vorname', sa.String(length=120), nullable=False),
sa.Column('geburtsjahr', sa.Integer(), nullable=True),
sa.Column('geburtsdatum', sa.Date(), nullable=True),
sa.Column('geschlecht', sa.Enum('M', 'W', 'D', name='geschlecht'), nullable=True),
sa.Column('verein_id', sa.Integer(), nullable=True),
sa.Column('adac_mitgliedsnr', sa.String(length=40), nullable=True),
sa.Column('jugendausweis_gueltig_bis', sa.Date(), nullable=True),
sa.Column('anzeige_anonymisiert', sa.Boolean(), nullable=False),
sa.Column('aktiv', sa.Boolean(), nullable=False),
sa.Column('notiz', sa.Text(), nullable=True),
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(['verein_id'], ['verein.id'], name=op.f('fk_fahrer_verein_id_verein')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_fahrer'))
)
op.create_index(op.f('ix_fahrer_geburtsjahr'), 'fahrer', ['geburtsjahr'], 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),
sa.Column('code', sa.String(length=10), nullable=False),
sa.Column('kategorie', sa.Enum('J', 'S', 'A', name='kategorie'), nullable=False),
sa.Column('bezeichnung', sa.String(length=200), nullable=False),
sa.Column('jahrgaenge', postgresql.ARRAY(sa.Integer()), nullable=True),
sa.Column('ccm_max', sa.Integer(), nullable=True),
sa.Column('kw_max', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('fahrzeugtyp', sa.String(length=200), nullable=True),
sa.Column('mindestalter_am_veranstaltungstag', sa.Integer(), nullable=True),
sa.Column('sortierung', sa.Integer(), nullable=False),
sa.Column('notiz', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['saison_jahr'], ['saison.jahr'], name=op.f('fk_klasse_saison_jahr_saison'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_klasse')),
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('veranstaltung',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('datum', sa.Date(), nullable=False),
sa.Column('titel', sa.String(length=300), nullable=False),
sa.Column('ort', sa.String(length=200), nullable=True),
sa.Column('ausrichter_verein_id', sa.Integer(), nullable=True),
sa.Column('ist_doppelturnier', sa.Boolean(), nullable=False),
sa.Column('veranstaltungsleiter', sa.String(length=200), nullable=True),
sa.Column('turnierleiter', sa.String(length=200), nullable=True),
sa.Column('status', sa.Enum('geplant', 'laufend', 'vorlaeufig', 'final', name='veranstaltung_status'), nullable=False),
sa.Column('notiz', sa.Text(), nullable=True),
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(['ausrichter_verein_id'], ['verein.id'], name=op.f('fk_veranstaltung_ausrichter_verein_id_verein')),
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('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),
sa.Column('gestrichene_laeufe', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('anzahl_siege', sa.Integer(), nullable=False),
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(['meisterschaft_id'], ['meisterschaft.id'], name=op.f('fk_meisterschaft_stand_meisterschaft_id_meisterschaft'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_meisterschaft_stand'))
)
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('wertungsklasse',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('turnier_id', sa.Integer(), nullable=False),
sa.Column('bezeichnung', sa.String(length=200), nullable=False),
sa.Column('klasse_ids', postgresql.ARRAY(sa.Integer()), nullable=False),
sa.Column('sortierung', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['turnier_id'], ['turnier.id'], name=op.f('fk_wertungsklasse_turnier_id_turnier'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_wertungsklasse'))
)
op.create_index(op.f('ix_wertungsklasse_turnier_id'), 'wertungsklasse', ['turnier_id'], unique=False)
op.create_table('start',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('turnier_id', sa.Integer(), nullable=False),
sa.Column('fahrer_id', sa.Integer(), nullable=False),
sa.Column('klasse_id', sa.Integer(), nullable=False),
sa.Column('wertungsklasse_id', sa.Integer(), nullable=True),
sa.Column('startnummer', sa.Integer(), nullable=False),
sa.Column('fahrzeug', sa.String(length=200), nullable=True),
sa.Column('nenngeld_bezahlt', sa.Boolean(), nullable=False),
sa.Column('status', sa.Enum('genannt', 'gestartet', 'DNF', 'DNS', 'DSQ', name='start_status'), nullable=False),
sa.Column('nennreihenfolge', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['fahrer_id'], ['fahrer.id'], name=op.f('fk_start_fahrer_id_fahrer')),
sa.ForeignKeyConstraint(['klasse_id'], ['klasse.id'], name=op.f('fk_start_klasse_id_klasse')),
sa.ForeignKeyConstraint(['turnier_id'], ['turnier.id'], name=op.f('fk_start_turnier_id_turnier'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['wertungsklasse_id'], ['wertungsklasse.id'], name=op.f('fk_start_wertungsklasse_id_wertungsklasse'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_start')),
sa.UniqueConstraint('turnier_id', 'startnummer', name='start_turnier_startnummer')
)
op.create_index(op.f('ix_start_fahrer_id'), 'start', ['fahrer_id'], unique=False)
op.create_index(op.f('ix_start_turnier_id'), 'start', ['turnier_id'], unique=False)
op.create_index(op.f('ix_start_wertungsklasse_id'), 'start', ['wertungsklasse_id'], unique=False)
op.create_table('ergebnis',
sa.Column('start_id', sa.Integer(), nullable=False),
sa.Column('summe_zeit', sa.Numeric(precision=7, scale=2), nullable=True),
sa.Column('summe_fehler', sa.Integer(), nullable=True),
sa.Column('gesamt', sa.Numeric(precision=9, scale=2), nullable=True),
sa.Column('platzierung', sa.Integer(), nullable=True),
sa.Column('zeit_vollstaendig', sa.Boolean(), nullable=False),
sa.Column('fehler_vollstaendig', sa.Boolean(), nullable=False),
sa.Column('erfasst_von', sa.String(length=120), nullable=True),
sa.Column('erfasst_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('korrigiert_am', sa.DateTime(timezone=True), nullable=True),
sa.Column('korrektur_grund', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['start_id'], ['start.id'], name=op.f('fk_ergebnis_start_id_start'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('start_id', name=op.f('pk_ergebnis'))
)
op.create_table('zeitmessung',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('geraet_id', sa.Integer(), nullable=False),
sa.Column('turnier_id', sa.Integer(), nullable=False),
sa.Column('start_id', sa.Integer(), nullable=True),
sa.Column('startnummer_gemeldet', sa.Integer(), nullable=True),
sa.Column('messpunkt', sa.String(length=80), nullable=False),
sa.Column('dauer_sekunden', sa.Numeric(precision=7, scale=2), nullable=False),
sa.Column('gemessen_am', sa.DateTime(timezone=True), nullable=False),
sa.Column('monotonic_ns', sa.BigInteger(), nullable=True),
sa.Column('empfangen_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('nachgeliefert', sa.Boolean(), nullable=False),
sa.Column('status', sa.Enum('gueltig', 'verworfen', 'unzugeordnet', 'unplausibel', name='zeitmessung_status'), nullable=False),
sa.Column('verworfen_grund', sa.Text(), nullable=True),
sa.Column('roh', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.ForeignKeyConstraint(['geraet_id'], ['geraet.id'], name=op.f('fk_zeitmessung_geraet_id_geraet')),
sa.ForeignKeyConstraint(['start_id'], ['start.id'], name=op.f('fk_zeitmessung_start_id_start'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['turnier_id'], ['turnier.id'], name=op.f('fk_zeitmessung_turnier_id_turnier'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_zeitmessung'))
)
op.create_index('ix_zeitmessung_start', 'zeitmessung', ['start_id'], unique=False)
op.create_index('ix_zeitmessung_turnier_status', 'zeitmessung', ['turnier_id', 'status'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_zeitmessung_turnier_status', table_name='zeitmessung')
op.drop_index('ix_zeitmessung_start', table_name='zeitmessung')
op.drop_table('zeitmessung')
op.drop_table('ergebnis')
op.drop_index(op.f('ix_start_wertungsklasse_id'), table_name='start')
op.drop_index(op.f('ix_start_turnier_id'), table_name='start')
op.drop_index(op.f('ix_start_fahrer_id'), table_name='start')
op.drop_table('start')
op.drop_index(op.f('ix_wertungsklasse_turnier_id'), table_name='wertungsklasse')
op.drop_table('wertungsklasse')
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_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')
op.drop_table('saison')
op.drop_index(op.f('ix_import_job_idempotenz_schluessel'), table_name='import_job')
op.drop_table('import_job')
op.drop_table('geraet')
op.drop_table('benutzer')
# ### end Alembic commands ###