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:
+1
-1
@@ -40,6 +40,6 @@ debian/debhelper-build-stamp
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
# Claude Code lokale (persoenliche) Einstellungen
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
|||||||
+44
-45
@@ -1,8 +1,8 @@
|
|||||||
"""Initiales Schema
|
"""Initiales Schema
|
||||||
|
|
||||||
Revision ID: df5e2dd5a864
|
Revision ID: d7d7d8a2cf90
|
||||||
Revises:
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,17 +12,15 @@ from alembic import op
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
revision: str = 'df5e2dd5a864'
|
revision: str = 'd7d7d8a2cf90'
|
||||||
down_revision: str | None = None
|
down_revision: str | None = None
|
||||||
branch_labels: str | Sequence[str] | None = None
|
branch_labels: str | Sequence[str] | None = None
|
||||||
depends_on: str | Sequence[str] | None = None
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> 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! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||||||
op.create_table('benutzer',
|
op.create_table('benutzer',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('benutzername', sa.String(length=80), 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'))
|
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_geburtsjahr'), 'fahrer', ['geburtsjahr'], unique=False)
|
||||||
|
op.create_index(op.f('ix_fahrer_verein_id'), 'fahrer', ['verein_id'], unique=False)
|
||||||
op.execute(
|
op.execute(
|
||||||
"CREATE INDEX ix_fahrer_name_trgm ON fahrer USING gin "
|
"CREATE INDEX ix_fahrer_name_trgm ON fahrer USING gin "
|
||||||
"((lower(nachname || ' ' || vorname)) gin_trgm_ops)"
|
"((lower(nachname || ' ' || vorname)) gin_trgm_ops)"
|
||||||
)
|
)
|
||||||
op.create_index(op.f('ix_fahrer_verein_id'), 'fahrer', ['verein_id'], unique=False)
|
|
||||||
op.create_table('klasse',
|
op.create_table('klasse',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('saison_jahr', 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')
|
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_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',
|
op.create_table('veranstaltung',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('datum', sa.Date(), nullable=False),
|
sa.Column('datum', sa.Date(), nullable=False),
|
||||||
@@ -145,40 +153,11 @@ def upgrade() -> None:
|
|||||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_veranstaltung'))
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_veranstaltung'))
|
||||||
)
|
)
|
||||||
op.create_index(op.f('ix_veranstaltung_datum'), 'veranstaltung', ['datum'], unique=False)
|
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',
|
op.create_table('meisterschaft_stand',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('meisterschaft_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('fahrer_id', sa.Integer(), nullable=False),
|
||||||
sa.Column('punkte_gesamt', sa.Numeric(precision=9, scale=2), 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('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('platz', sa.Integer(), nullable=True),
|
||||||
sa.Column('berechnet_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
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(['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.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_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_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',
|
op.create_table('wertungsklasse',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
sa.Column('turnier_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_table('start')
|
||||||
op.drop_index(op.f('ix_wertungsklasse_turnier_id'), table_name='wertungsklasse')
|
op.drop_index(op.f('ix_wertungsklasse_turnier_id'), table_name='wertungsklasse')
|
||||||
op.drop_table('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_meisterschaft_id'), table_name='meisterschaft_stand')
|
||||||
op.drop_index(op.f('ix_meisterschaft_stand_fahrer_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_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_index(op.f('ix_veranstaltung_datum'), table_name='veranstaltung')
|
||||||
op.drop_table('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_index(op.f('ix_klasse_saison_jahr'), table_name='klasse')
|
||||||
op.drop_table('klasse')
|
op.drop_table('klasse')
|
||||||
op.drop_index(op.f('ix_fahrer_verein_id'), table_name='fahrer')
|
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_index(op.f('ix_fahrer_geburtsjahr'), table_name='fahrer')
|
||||||
op.drop_table('fahrer')
|
op.drop_table('fahrer')
|
||||||
op.drop_table('verein')
|
op.drop_table('verein')
|
||||||
+26
-17
@@ -13,7 +13,7 @@ from pydantic import BaseModel, Field
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from oamc.matching.fahrer import fahrer_zusammenfuehren
|
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 (
|
from oamc.models import (
|
||||||
Geraet,
|
Geraet,
|
||||||
ImportJob,
|
ImportJob,
|
||||||
@@ -21,7 +21,7 @@ from oamc.models import (
|
|||||||
Start,
|
Start,
|
||||||
Zeitmessung,
|
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.deps import Admin, Auswerter, DbDep
|
||||||
from oamc.security.tokens import erzeuge_geraete_token
|
from oamc.security.tokens import erzeuge_geraete_token
|
||||||
from oamc.wertung.service import turnier_auswerten
|
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")
|
@router.post("/meisterschaften/{meisterschaft_id}/berechnen")
|
||||||
def meisterschaft_berechnen(db: DbDep, _auswerter: Auswerter, meisterschaft_id: int):
|
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(
|
db.query(MeisterschaftStand).filter(
|
||||||
MeisterschaftStand.meisterschaft_id == meisterschaft_id
|
MeisterschaftStand.meisterschaft_id == meisterschaft_id
|
||||||
).delete()
|
).delete()
|
||||||
for z in bericht.zeilen:
|
gesamt = 0
|
||||||
db.add(
|
hinweise: set[str] = set()
|
||||||
MeisterschaftStand(
|
for b in berichte:
|
||||||
meisterschaft_id=meisterschaft_id,
|
hinweise.update(b.hinweise)
|
||||||
fahrer_id=z.fahrer_id,
|
for z in b.zeilen:
|
||||||
punkte_gesamt=z.punkte_gesamt,
|
gesamt += 1
|
||||||
punkte_je_lauf={str(k): float(v) for k, v in z.punkte_je_lauf.items()},
|
db.add(
|
||||||
gestrichene_laeufe=z.gestrichene_laeufe,
|
MeisterschaftStand(
|
||||||
anzahl_siege=z.anzahl_siege,
|
meisterschaft_id=meisterschaft_id,
|
||||||
platz=z.platz,
|
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 {
|
return {
|
||||||
"meisterschaft_id": meisterschaft_id,
|
"meisterschaft_id": meisterschaft_id,
|
||||||
"zeilen": len(bericht.zeilen),
|
"wertungen": len(berichte),
|
||||||
"hinweise": bericht.hinweise,
|
"zeilen": gesamt,
|
||||||
|
"hinweise": sorted(hinweise),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -48,22 +48,33 @@ def veranstaltung_anlegen(db: DbDep, _key: IngestKey, daten: VeranstaltungCreate
|
|||||||
|
|
||||||
@router.post("/turniere", status_code=201)
|
@router.post("/turniere", status_code=201)
|
||||||
def turnier_anlegen(db: DbDep, _key: IngestKey, daten: TurnierCreate):
|
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")
|
raise HTTPException(404, "Veranstaltung nicht gefunden")
|
||||||
verein = None
|
verein = None
|
||||||
if daten.veranstalter_verein:
|
if daten.veranstalter_verein:
|
||||||
from oamc.importer.ingest import finde_verein
|
from oamc.importer.ingest import finde_verein
|
||||||
|
|
||||||
verein = finde_verein(db, daten.veranstalter_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(
|
t = Turnier(
|
||||||
veranstaltung_id=daten.veranstaltung_id,
|
veranstaltung_id=daten.veranstaltung_id,
|
||||||
name=daten.name,
|
name=daten.name,
|
||||||
veranstalter_verein_id=verein.id if verein else None,
|
veranstalter_verein_id=verein.id if verein else None,
|
||||||
zaehlt_meisterschaft=daten.zaehlt_meisterschaft,
|
meisterschaft_id=meisterschaft_id,
|
||||||
)
|
)
|
||||||
db.add(t)
|
db.add(t)
|
||||||
db.flush()
|
db.flush()
|
||||||
return {"id": t.id}
|
return {"id": t.id, "meisterschaft_id": meisterschaft_id}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/turniere/{turnier_id}/starts", response_model=ImportBericht)
|
@router.post("/turniere/{turnier_id}/starts", response_model=ImportBericht)
|
||||||
|
|||||||
+76
-19
@@ -16,7 +16,12 @@ from oamc.api.v1.serialisierung import (
|
|||||||
turnier_kurz,
|
turnier_kurz,
|
||||||
veranstaltung_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 (
|
from oamc.models import (
|
||||||
Fahrer,
|
Fahrer,
|
||||||
Meisterschaft,
|
Meisterschaft,
|
||||||
@@ -161,27 +166,34 @@ def fahrer_historie(db: DbDep, fahrer_id: int):
|
|||||||
|
|
||||||
@router.get("/meisterschaften")
|
@router.get("/meisterschaften")
|
||||||
def liste_meisterschaften(db: DbDep, saison: int | None = None):
|
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:
|
if saison:
|
||||||
stmt = stmt.where(Meisterschaft.saison_jahr == saison)
|
stmt = stmt.where(Meisterschaft.saison_jahr == saison)
|
||||||
return [
|
out = []
|
||||||
{
|
for m in db.scalars(stmt):
|
||||||
"id": m.id,
|
out.append(
|
||||||
"bezeichnung": m.bezeichnung,
|
{
|
||||||
"saison": m.saison_jahr,
|
"id": m.id,
|
||||||
"kategorie": m.kategorie.value,
|
"bezeichnung": m.bezeichnung,
|
||||||
}
|
"saison": m.saison_jahr,
|
||||||
for m in db.scalars(stmt)
|
"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 _wertung_json(db, bericht) -> dict:
|
||||||
def meisterschaft_stand(db: DbDep, meisterschaft_id: int):
|
|
||||||
bericht = berechne_stand(db, meisterschaft_id)
|
|
||||||
return {
|
return {
|
||||||
"meisterschaft_id": bericht.meisterschaft_id,
|
"wertung": bericht.wertung,
|
||||||
"saison": bericht.saison_jahr,
|
|
||||||
"kategorie": bericht.kategorie,
|
"kategorie": bericht.kategorie,
|
||||||
|
"klasse_id": bericht.klasse_id,
|
||||||
"laeufe_gesamt": bericht.laeufe_gesamt,
|
"laeufe_gesamt": bericht.laeufe_gesamt,
|
||||||
"laeufe_gewertet": bericht.laeufe_gewertet,
|
"laeufe_gewertet": bericht.laeufe_gewertet,
|
||||||
"hinweise": bericht.hinweise,
|
"hinweise": bericht.hinweise,
|
||||||
@@ -198,11 +210,56 @@ def meisterschaft_stand(db: DbDep, meisterschaft_id: int):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/meisterschaften/{meisterschaft_id}/endlauf-qualifikation")
|
@router.get("/meisterschaften/{meisterschaft_id}/stand")
|
||||||
def meisterschaft_quali(db: DbDep, meisterschaft_id: int, plaetze: int = 10):
|
def meisterschaft_stand(
|
||||||
vorschlaege = endlauf_qualifikation(db, meisterschaft_id, plaetze)
|
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 {
|
return {
|
||||||
"meisterschaft_id": meisterschaft_id,
|
"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).",
|
"hinweis": "Vorschlag — die endgueltige Festlegung trifft der Bereichsleiter (Plan §3.4).",
|
||||||
"plaetze": plaetze,
|
"plaetze": plaetze,
|
||||||
"vorschlaege": [
|
"vorschlaege": [
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
from oamc.api.v1.serialisierung import ergebnis_zeile
|
from oamc.api.v1.serialisierung import ergebnis_zeile
|
||||||
from oamc.config import get_settings
|
from oamc.config import get_settings
|
||||||
from oamc.frontend.templating import templates
|
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.models import Fahrer, Meisterschaft, Start, Turnier, Veranstaltung
|
||||||
from oamc.security.deps import DbDep
|
from oamc.security.deps import DbDep
|
||||||
|
|
||||||
@@ -217,17 +217,31 @@ def meisterschaft_uebersicht(request: Request, db: DbDep):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/meisterschaft/{meisterschaft_id}")
|
@router.get("/meisterschaft/{meisterschaft_id}")
|
||||||
def meisterschaft_detail(request: Request, db: DbDep, meisterschaft_id: int):
|
def meisterschaft_detail(
|
||||||
if db.get(Meisterschaft, meisterschaft_id) is None:
|
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")
|
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 = {
|
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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"public/meisterschaft_detail.html",
|
"public/meisterschaft_detail.html",
|
||||||
{"bericht": bericht, "fahrer_map": fahrer_map},
|
{
|
||||||
|
"meisterschaft": m,
|
||||||
|
"wertungen": alle,
|
||||||
|
"aktive_wertung": aktiv,
|
||||||
|
"bericht": bericht,
|
||||||
|
"fahrer_map": fahrer_map,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from oamc.importer.ingest import (
|
from oamc.importer.ingest import (
|
||||||
finde_klasse,
|
finde_klasse,
|
||||||
finde_verein,
|
finde_verein,
|
||||||
|
meisterschaft_der_saison,
|
||||||
verarbeite_ergebnisse,
|
verarbeite_ergebnisse,
|
||||||
verarbeite_starts,
|
verarbeite_starts,
|
||||||
)
|
)
|
||||||
@@ -10,6 +11,7 @@ from oamc.importer.ingest import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"finde_klasse",
|
"finde_klasse",
|
||||||
"finde_verein",
|
"finde_verein",
|
||||||
|
"meisterschaft_der_saison",
|
||||||
"verarbeite_ergebnisse",
|
"verarbeite_ergebnisse",
|
||||||
"verarbeite_starts",
|
"verarbeite_starts",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,10 +15,13 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from oamc.matching import MatchStatus, match_oder_neu, normalisiere
|
from oamc.matching import MatchStatus, match_oder_neu, normalisiere
|
||||||
|
from oamc.meisterschaft.berechnung import PLATZHALTER_PUNKTETABELLE
|
||||||
from oamc.models import (
|
from oamc.models import (
|
||||||
Ergebnis,
|
Ergebnis,
|
||||||
ImportJob,
|
ImportJob,
|
||||||
Klasse,
|
Klasse,
|
||||||
|
Meisterschaft,
|
||||||
|
Saison,
|
||||||
Start,
|
Start,
|
||||||
Turnier,
|
Turnier,
|
||||||
Verein,
|
Verein,
|
||||||
@@ -63,6 +66,30 @@ def _saison_fuer_turnier(db: Session, turnier: Turnier) -> int:
|
|||||||
return turnier.veranstaltung.datum.year
|
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:
|
def _idempotenz_treffer(db: Session, schluessel: str | None) -> ImportJob | None:
|
||||||
if not schluessel:
|
if not schluessel:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
"""Meisterschafts-/Pokalwertung (HTH) — AP 3.
|
"""Meisterschafts-/Pokalwertung (HTH) — AP 3.
|
||||||
|
|
||||||
BLOCKIERT durch offene Frage O-1: die echte Punktetabelle (Platz -> Punkte) ist
|
Eine Meisterschaft pro Saison; darin mehrere Wertungen (Erwachsene-Gesamt + je
|
||||||
noch nicht bekannt. Bis dahin arbeitet dieses Modul mit
|
Jugendklasse). BLOCKIERT durch offene Frage O-1: die echte Punktetabelle
|
||||||
``PLATZHALTER_PUNKTETABELLE`` (klar als solche markiert) bzw. mit der Tabelle aus
|
(Platz -> Punkte) ist noch nicht bekannt — bis dahin
|
||||||
``meisterschaft.regelwerk['punkte']``.
|
``PLATZHALTER_PUNKTETABELLE`` bzw. ``meisterschaft.regelwerk['punkte']``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from oamc.meisterschaft.berechnung import (
|
from oamc.meisterschaft.berechnung import (
|
||||||
PLATZHALTER_PUNKTETABELLE,
|
PLATZHALTER_PUNKTETABELLE,
|
||||||
|
MeisterschaftsBericht,
|
||||||
StandZeile,
|
StandZeile,
|
||||||
|
Wertung,
|
||||||
|
berechne_alle_wertungen,
|
||||||
berechne_stand,
|
berechne_stand,
|
||||||
endlauf_qualifikation,
|
endlauf_qualifikation,
|
||||||
|
gewertete_laeufe,
|
||||||
punkte_fuer_platz,
|
punkte_fuer_platz,
|
||||||
|
wertungen_fuer,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PLATZHALTER_PUNKTETABELLE",
|
"PLATZHALTER_PUNKTETABELLE",
|
||||||
|
"MeisterschaftsBericht",
|
||||||
"StandZeile",
|
"StandZeile",
|
||||||
|
"Wertung",
|
||||||
"berechne_stand",
|
"berechne_stand",
|
||||||
|
"berechne_alle_wertungen",
|
||||||
|
"wertungen_fuer",
|
||||||
"endlauf_qualifikation",
|
"endlauf_qualifikation",
|
||||||
|
"gewertete_laeufe",
|
||||||
"punkte_fuer_platz",
|
"punkte_fuer_platz",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
"""Meisterschaftsstand, Streichresultate, Tie-Break, Endlaufqualifikation.
|
"""Meisterschaftsstand, Streichresultate, Tie-Break, Endlaufqualifikation.
|
||||||
|
|
||||||
Regeln (Plan §3.4):
|
Modell: **eine Meisterschaft pro Saison**, dazu gehoeren die Turniere mit
|
||||||
* Erwachsene: ein Gesamtklassement A + S (klassenuebergreifend).
|
``turnier.meisterschaft_id``. Innerhalb dieser einen Meisterschaft werden mehrere
|
||||||
* Jugend: Pokalwertung klassenweise J 1 - J 5.
|
**Wertungen** gerechnet (Plan §3.4):
|
||||||
* Streichresultate nach Tabelle in ``saison.streichresultate_tabelle``
|
|
||||||
|
* 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").
|
("Turniere in der Saison" -> "davon gewertet").
|
||||||
* Nur Laeufe mit ``turnier.zaehlt_meisterschaft``.
|
|
||||||
* Tie-Break bei Punktgleichheit: Majoritaet der Siege.
|
* Tie-Break bei Punktgleichheit: Majoritaet der Siege.
|
||||||
* Endlauf: die besten 10 Erwachsenen bzw. 10 Jugendlichen — die Anwendung
|
* Endlauf: die besten 10 je Wertung — die Anwendung *schlaegt vor*, entscheidet
|
||||||
*schlaegt vor*, entscheidet nicht (Bereichsleiter).
|
nicht (Bereichsleiter).
|
||||||
|
|
||||||
Die Punktetabelle ist offene Frage **O-1**. ``PLATZHALTER_PUNKTETABELLE`` ist
|
Die Punktetabelle ist offene Frage **O-1**. ``PLATZHALTER_PUNKTETABELLE`` ist
|
||||||
NICHT die echte Tabelle und erzeugt bei Verwendung einen Hinweis im Bericht.
|
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
|
tab = tabelle or PLATZHALTER_PUNKTETABELLE
|
||||||
if platz in tab:
|
if platz in tab:
|
||||||
return Decimal(tab[platz])
|
return Decimal(tab[platz])
|
||||||
# unterhalb der Tabelle: kleinster definierter Wert bzw. Platzhalter
|
|
||||||
if tabelle:
|
if tabelle:
|
||||||
return Decimal(min(tabelle.values())) if tabelle else Decimal(0)
|
return Decimal(min(tabelle.values()))
|
||||||
return Decimal(PLATZHALTER_PUNKTE_AB_15)
|
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:
|
if not tabelle:
|
||||||
return anzahl_turniere
|
return anzahl_turniere
|
||||||
# exakter Eintrag?
|
|
||||||
key = str(anzahl_turniere)
|
key = str(anzahl_turniere)
|
||||||
if key in tabelle:
|
if key in tabelle:
|
||||||
return int(tabelle[key])
|
return int(tabelle[key])
|
||||||
# sonst: naechstkleinerer definierter Schluessel
|
for k in sorted((int(k) for k in tabelle), reverse=True):
|
||||||
kleinere = sorted((int(k) for k in tabelle), reverse=True)
|
|
||||||
for k in kleinere:
|
|
||||||
if k <= anzahl_turniere:
|
if k <= anzahl_turniere:
|
||||||
return int(tabelle[str(k)])
|
return int(tabelle[str(k)])
|
||||||
return anzahl_turniere
|
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
|
@dataclass
|
||||||
class StandZeile:
|
class StandZeile:
|
||||||
fahrer_id: int
|
fahrer_id: int
|
||||||
@@ -87,14 +98,53 @@ class StandZeile:
|
|||||||
class MeisterschaftsBericht:
|
class MeisterschaftsBericht:
|
||||||
meisterschaft_id: int
|
meisterschaft_id: int
|
||||||
saison_jahr: int
|
saison_jahr: int
|
||||||
|
wertung: str # code der Wertung
|
||||||
kategorie: str
|
kategorie: str
|
||||||
|
klasse_id: int | None
|
||||||
laeufe_gesamt: int
|
laeufe_gesamt: int
|
||||||
laeufe_gewertet: int
|
laeufe_gewertet: int
|
||||||
zeilen: list[StandZeile]
|
zeilen: list[StandZeile]
|
||||||
hinweise: list[str] = field(default_factory=list)
|
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 = (
|
stmt = (
|
||||||
select(Start)
|
select(Start)
|
||||||
.join(Ergebnis, Ergebnis.start_id == Start.id)
|
.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,
|
Start.status == StartStatus.GESTARTET,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if m.kategorie == Kategorie.JUGEND:
|
if kategorie == Kategorie.JUGEND:
|
||||||
if m.klasse_id is not None:
|
if klasse_id is not None:
|
||||||
stmt = stmt.where(Start.klasse_id == m.klasse_id)
|
stmt = stmt.where(Start.klasse_id == klasse_id)
|
||||||
else:
|
else:
|
||||||
stmt = stmt.where(Klasse.kategorie == Kategorie.JUGEND)
|
stmt = stmt.where(Klasse.kategorie == Kategorie.JUGEND)
|
||||||
else:
|
else:
|
||||||
@@ -116,7 +166,15 @@ def _relevante_starts(db: Session, m: Meisterschaft, turnier_ids: list[int]) ->
|
|||||||
return list(db.scalars(stmt))
|
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)
|
m = db.get(Meisterschaft, meisterschaft_id)
|
||||||
if m is None:
|
if m is None:
|
||||||
raise ValueError(f"Meisterschaft {meisterschaft_id} nicht gefunden")
|
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"):
|
if regelwerk.get("_hinweis"):
|
||||||
hinweise.append(str(regelwerk["_hinweis"]))
|
hinweise.append(str(regelwerk["_hinweis"]))
|
||||||
|
|
||||||
|
# Turniere dieser Jahresmeisterschaft, deren Veranstaltung (vor-)final ist
|
||||||
turniere = list(
|
turniere = list(
|
||||||
db.scalars(
|
db.scalars(
|
||||||
select(Turnier)
|
select(Turnier)
|
||||||
.join(Veranstaltung, Veranstaltung.id == Turnier.veranstaltung_id)
|
.join(Veranstaltung, Veranstaltung.id == Turnier.veranstaltung_id)
|
||||||
.where(
|
.where(
|
||||||
Veranstaltung.datum.is_not(None),
|
Turnier.meisterschaft_id == meisterschaft_id,
|
||||||
Turnier.zaehlt_meisterschaft.is_(True),
|
|
||||||
Veranstaltung.status.in_(["vorlaeufig", "final"]),
|
Veranstaltung.status.in_(["vorlaeufig", "final"]),
|
||||||
)
|
)
|
||||||
.order_by(Veranstaltung.datum, Turnier.id)
|
.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]
|
turnier_ids = [t.id for t in turniere]
|
||||||
|
|
||||||
roh: dict[int, dict[int, Decimal]] = {} # fahrer_id -> {turnier_id: punkte}
|
roh: dict[int, dict[int, Decimal]] = {} # fahrer_id -> {turnier_id: punkte}
|
||||||
siege: dict[int, int] = {}
|
siege: dict[int, int] = {}
|
||||||
if turnier_ids:
|
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)
|
p = punkte_fuer_platz(s.ergebnis.platzierung, punkte_tab)
|
||||||
bucket = roh.setdefault(s.fahrer_id, {})
|
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)
|
bucket[s.turnier_id] = max(bucket.get(s.turnier_id, Decimal(0)), p)
|
||||||
if s.ergebnis.platzierung == 1:
|
if s.ergebnis.platzierung == 1:
|
||||||
siege[s.fahrer_id] = siege.get(s.fahrer_id, 0) + 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(
|
return MeisterschaftsBericht(
|
||||||
meisterschaft_id=meisterschaft_id,
|
meisterschaft_id=meisterschaft_id,
|
||||||
saison_jahr=m.saison_jahr,
|
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_gesamt=len(turnier_ids),
|
||||||
laeufe_gewertet=n_gewertet,
|
laeufe_gewertet=n_gewertet,
|
||||||
zeilen=zeilen,
|
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
|
@dataclass
|
||||||
class QualiVorschlag:
|
class QualiVorschlag:
|
||||||
fahrer_id: int
|
fahrer_id: int
|
||||||
@@ -203,17 +279,23 @@ class QualiVorschlag:
|
|||||||
|
|
||||||
|
|
||||||
def endlauf_qualifikation(
|
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]:
|
) -> list[QualiVorschlag]:
|
||||||
"""Vorschlag Top-N fuer den Endlauf. Bei Punktgleichheit an der Grenze wird
|
"""Vorschlag Top-N fuer den Endlauf EINER Wertung. Bei Punktgleichheit an der
|
||||||
``tie_break_relevant`` gesetzt — die endgueltige Festlegung trifft der
|
Grenze wird ``tie_break_relevant`` gesetzt — die endgueltige Festlegung trifft
|
||||||
Bereichsleiter (Plan §3.4)."""
|
der Bereichsleiter (Plan §3.4)."""
|
||||||
|
|
||||||
bericht = berechne_stand(db, meisterschaft_id)
|
bericht = berechne_stand(db, meisterschaft_id, kategorie=kategorie, klasse_id=klasse_id)
|
||||||
zeilen = bericht.zeilen[: plaetze + 5] # etwas Puffer fuer die Grenzbetrachtung
|
grenz_punkte = (
|
||||||
|
bericht.zeilen[plaetze - 1].punkte_gesamt if len(bericht.zeilen) >= plaetze else None
|
||||||
|
)
|
||||||
vorschlaege: list[QualiVorschlag] = []
|
vorschlaege: list[QualiVorschlag] = []
|
||||||
grenz_punkte = bericht.zeilen[plaetze - 1].punkte_gesamt if len(bericht.zeilen) >= plaetze else None
|
for z in bericht.zeilen[: plaetze + 5]:
|
||||||
for z in zeilen:
|
|
||||||
vorschlaege.append(
|
vorschlaege.append(
|
||||||
QualiVorschlag(
|
QualiVorschlag(
|
||||||
fahrer_id=z.fahrer_id,
|
fahrer_id=z.fahrer_id,
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
"""Meisterschafts-/Pokalwertung (HTH) — Plan §3.4.
|
"""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)
|
* Erwachsene: ein Gesamtklassement A + S (klassenuebergreifend)
|
||||||
* Jugend: Pokalwertung klassenweise J 1 - J 5
|
* 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.
|
* Die Anwendung *schlaegt* die Endlaufqualifikation *vor*, entscheidet nicht.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -11,7 +20,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
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.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -20,39 +29,57 @@ from oamc.models.enums import Kategorie
|
|||||||
|
|
||||||
|
|
||||||
class Meisterschaft(Base):
|
class Meisterschaft(Base):
|
||||||
|
"""Genau eine pro Saison. Container fuer die zaehlenden Turniere und das
|
||||||
|
Regelwerk (Punktetabelle, Streichregel-Override, Endlauf-Parameter)."""
|
||||||
|
|
||||||
__tablename__ = "meisterschaft"
|
__tablename__ = "meisterschaft"
|
||||||
|
__table_args__ = (UniqueConstraint("saison_jahr", name="meisterschaft_saison_eindeutig"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
saison_jahr: Mapped[int] = mapped_column(ForeignKey("saison.jahr"), nullable=False, index=True)
|
saison_jahr: Mapped[int] = mapped_column(
|
||||||
bezeichnung: Mapped[str] = mapped_column(String(200), nullable=False)
|
ForeignKey("saison.jahr"), nullable=False, index=True, unique=True
|
||||||
kategorie: Mapped[Kategorie] = mapped_column(
|
|
||||||
sa_enum(Kategorie, "meisterschaft_kategorie"), nullable=False
|
|
||||||
)
|
)
|
||||||
# nur bei Jugend gesetzt (klassenweise J1..J5); bei Erwachsenen NULL
|
bezeichnung: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
klasse_id: Mapped[int | None] = mapped_column(ForeignKey("klasse.id"))
|
# Punktetabelle (Platz -> Punkte), Streich-Override, Endlauf-Parameter.
|
||||||
# Punktetabelle (Platz -> Punkte), Streichregel-Override, Endlauf-Parameter.
|
|
||||||
# Solange O-1 offen ist, steht hier die (klar markierte) Platzhalter-Tabelle.
|
# Solange O-1 offen ist, steht hier die (klar markierte) Platzhalter-Tabelle.
|
||||||
regelwerk: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
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(
|
staende: Mapped[list[MeisterschaftStand]] = relationship(
|
||||||
back_populates="meisterschaft", cascade="all, delete-orphan"
|
back_populates="meisterschaft", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class MeisterschaftStand(Base):
|
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"
|
__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)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
meisterschaft_id: Mapped[int] = mapped_column(
|
meisterschaft_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("meisterschaft.id", ondelete="CASCADE"), nullable=False, index=True
|
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)
|
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)
|
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)
|
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)
|
gestrichene_laeufe: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
anzahl_siege: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # Tie-Break
|
anzahl_siege: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # Tie-Break
|
||||||
platz: Mapped[int | None] = mapped_column(Integer)
|
platz: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
|||||||
@@ -57,8 +57,11 @@ class Turnier(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
name: Mapped[str] = mapped_column(String(120), nullable=False) # "Reinheim" / "Hainstadt"
|
name: Mapped[str] = mapped_column(String(120), nullable=False) # "Reinheim" / "Hainstadt"
|
||||||
veranstalter_verein_id: Mapped[int | None] = mapped_column(ForeignKey("verein.id"))
|
veranstalter_verein_id: Mapped[int | None] = mapped_column(ForeignKey("verein.id"))
|
||||||
# Nicht jede Veranstaltung zaehlt zur Meisterschaft ("o. M.", Plan §3.4).
|
# Pro Saison gibt es EINE Meisterschaft; ein Turnier gehoert zu ihr, wenn es
|
||||||
zaehlt_meisterschaft: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
# 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
|
# Genau ein Turnier kann als "aktiv" markiert sein -> Geraete-API-Endpunkte
|
||||||
# /turniere/aktiv/*. Partieller Unique-Index in der Migration.
|
# /turniere/aktiv/*. Partieller Unique-Index in der Migration.
|
||||||
ist_aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
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")
|
veranstaltung: Mapped[Veranstaltung] = relationship(back_populates="turniere")
|
||||||
|
meisterschaft: Mapped[Meisterschaft | None] = relationship( # noqa: F821
|
||||||
|
back_populates="turniere"
|
||||||
|
)
|
||||||
wertungsklassen: Mapped[list[Wertungsklasse]] = relationship(
|
wertungsklassen: Mapped[list[Wertungsklasse]] = relationship(
|
||||||
back_populates="turnier", cascade="all, delete-orphan", order_by="Wertungsklasse.sortierung"
|
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"
|
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):
|
class Wertungsklasse(Base):
|
||||||
"""Pro Turnier zusammengefasste Klassen (Plan §3.2): z. B. "S 5, S 9, S 3, S 6, S 7".
|
"""Pro Turnier zusammengefasste Klassen (Plan §3.2): z. B. "S 5, S 9, S 3, S 6, S 7".
|
||||||
|
|||||||
@@ -123,24 +123,15 @@ def seed_saison_2026(db: Session) -> dict[str, int]:
|
|||||||
"_hinweis": "O-1 offen: PLATZHALTER-Punktetabelle, nicht offiziell!",
|
"_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(
|
_, neu = _get_or_create(
|
||||||
db, Meisterschaft, saison_jahr=2026, bezeichnung="HTH Erwachsene 2026",
|
db,
|
||||||
defaults={"kategorie": Kategorie.STAMM, "regelwerk": platzhalter_regelwerk},
|
Meisterschaft,
|
||||||
|
saison_jahr=2026,
|
||||||
|
defaults={"bezeichnung": "HTH-Meisterschaft 2026", "regelwerk": platzhalter_regelwerk},
|
||||||
)
|
)
|
||||||
zaehler["meisterschaft"] += int(neu)
|
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
|
return zaehler
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Meisterschaftsstand · {{ projekt }}{% endblock %}
|
{% block title %}{{ meisterschaft.bezeichnung }} · {{ projekt }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Meisterschaftsstand</h1>
|
<h1>{{ meisterschaft.bezeichnung }}</h1>
|
||||||
<p>Saison {{ bericht.saison_jahr }} · Kategorie {{ bericht.kategorie }} ·
|
|
||||||
|
<nav class="tiles">
|
||||||
|
{% for w in wertungen %}
|
||||||
|
<a href="/meisterschaft/{{ meisterschaft.id }}?wertung={{ w.code | urlencode }}"
|
||||||
|
{% if w.code == aktive_wertung.code %}style="font-weight:700"{% endif %}>{{ w.bezeichnung }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h2>{{ aktive_wertung.bezeichnung }}</h2>
|
||||||
|
<p>Saison {{ bericht.saison_jahr }} ·
|
||||||
{{ bericht.laeufe_gewertet }} von {{ bericht.laeufe_gesamt }} Läufen gewertet
|
{{ bericht.laeufe_gewertet }} von {{ bericht.laeufe_gesamt }} Läufen gewertet
|
||||||
(Rest = Streichresultate)</p>
|
(Rest = Streichresultate)</p>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<ul class="plain">
|
<ul class="plain">
|
||||||
{% for m in meisterschaften %}
|
{% for m in meisterschaften %}
|
||||||
<li><a href="/meisterschaft/{{ m.id }}">{{ m.bezeichnung }}</a>
|
<li><a href="/meisterschaft/{{ m.id }}">{{ m.bezeichnung }}</a>
|
||||||
<span class="badge">{{ m.kategorie.value }}</span></li>
|
<span class="badge">Saison {{ m.saison_jahr }}</span></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li>Keine Meisterschaft angelegt.</li>
|
<li>Keine Meisterschaft angelegt.</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
+10
-2
@@ -10,6 +10,7 @@ from oamc.models import (
|
|||||||
Ergebnis,
|
Ergebnis,
|
||||||
Fahrer,
|
Fahrer,
|
||||||
Klasse,
|
Klasse,
|
||||||
|
Meisterschaft,
|
||||||
Start,
|
Start,
|
||||||
Turnier,
|
Turnier,
|
||||||
Veranstaltung,
|
Veranstaltung,
|
||||||
@@ -18,6 +19,11 @@ from oamc.models import (
|
|||||||
from oamc.models.enums import StartStatus, TurnierStatus, VeranstaltungStatus
|
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"):
|
def klasse(db, code="J 3"):
|
||||||
return db.scalars(select(Klasse).where(Klasse.code == code, Klasse.saison_jahr == 2026)).one()
|
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
|
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):
|
status=TurnierStatus.LAUFEND, flush=True):
|
||||||
|
"""``meisterschaft``: Meisterschaft-Objekt (zaehlt zur Jahreswertung) oder None."""
|
||||||
v = v or veranstaltung(db)
|
v = v or veranstaltung(db)
|
||||||
t = Turnier(
|
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,
|
ist_aktiv=aktiv, status=status,
|
||||||
)
|
)
|
||||||
db.add(t)
|
db.add(t)
|
||||||
|
|||||||
@@ -102,3 +102,36 @@ def test_finalisieren_setzt_status_final(client):
|
|||||||
r = client.post(f"/api/v1/turniere/{tid}/finalisieren", headers=HEAD)
|
r = client.post(f"/api/v1/turniere/{tid}/finalisieren", headers=HEAD)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert client.db.get(Turnier, tid).status.value == "final"
|
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
@@ -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 datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from oamc.meisterschaft.berechnung import (
|
from oamc.meisterschaft.berechnung import (
|
||||||
PLATZHALTER_PUNKTETABELLE,
|
|
||||||
berechne_stand,
|
berechne_stand,
|
||||||
gewertete_laeufe,
|
gewertete_laeufe,
|
||||||
punkte_fuer_platz,
|
punkte_fuer_platz,
|
||||||
|
wertungen_fuer,
|
||||||
)
|
)
|
||||||
from oamc.models import Meisterschaft
|
from oamc.models.enums import Kategorie, VeranstaltungStatus
|
||||||
from oamc.models.enums import VeranstaltungStatus
|
|
||||||
from tests import factories as f
|
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
|
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), ...]"""
|
"""ergebnisse = [(fahrer, platz), ...]"""
|
||||||
v = f.veranstaltung(db, datum=datum, status=VeranstaltungStatus.FINAL)
|
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):
|
for i, (fahrer, platz) in enumerate(ergebnisse, start=1):
|
||||||
s = f.start(db, t, fahrer, k, startnummer=i)
|
s = f.start(db, t, fahrer, k, startnummer=i)
|
||||||
e = f.ergebnis(db, s, zeit=Decimal("100"), fehler=0)
|
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):
|
def test_stand_summiert_punkte_und_zaehlt_siege(seed_2026):
|
||||||
db = seed_2026
|
db = seed_2026
|
||||||
|
m = f.meisterschaft(db)
|
||||||
k = f.klasse(db, "J 3")
|
k = f.klasse(db, "J 3")
|
||||||
a = f.fahrer(db, "Sieger", "Sina", 2014)
|
a = f.fahrer(db, "Sieger", "Sina", 2014)
|
||||||
b = f.fahrer(db, "Zweite", "Zoe", 2014)
|
b = f.fahrer(db, "Zweite", "Zoe", 2014)
|
||||||
_lauf(db, k, [(a, 1), (b, 2)], datum=date(2026, 4, 1))
|
_lauf(db, m, k, [(a, 1), (b, 2)], datum=date(2026, 4, 1))
|
||||||
_lauf(db, k, [(a, 1), (b, 2)], datum=date(2026, 5, 1))
|
_lauf(db, m, k, [(a, 1), (b, 2)], datum=date(2026, 5, 1))
|
||||||
m = db.scalars(
|
bericht = berechne_stand(db, m.id, kategorie=Kategorie.JUGEND, klasse_id=k.id)
|
||||||
select(Meisterschaft).where(Meisterschaft.bezeichnung == "HTH Jugend J 3 2026")
|
|
||||||
).one()
|
|
||||||
bericht = berechne_stand(db, m.id)
|
|
||||||
zeilen = {z.fahrer_id: z for z in bericht.zeilen}
|
zeilen = {z.fahrer_id: z for z in bericht.zeilen}
|
||||||
assert zeilen[a.id].platz == 1
|
assert zeilen[a.id].platz == 1
|
||||||
assert zeilen[a.id].punkte_gesamt == Decimal(50) # 2x Platz 1 = 2x25
|
assert zeilen[a.id].punkte_gesamt == Decimal(50) # 2x Platz 1 = 2x25
|
||||||
assert zeilen[a.id].anzahl_siege == 2
|
assert zeilen[a.id].anzahl_siege == 2
|
||||||
assert bericht.hinweise # O-1 Platzhalter-Warnung
|
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):
|
def test_tie_break_majoritaet_der_siege(seed_2026):
|
||||||
db = seed_2026
|
db = seed_2026
|
||||||
|
m = f.meisterschaft(db)
|
||||||
k = f.klasse(db, "J 3")
|
k = f.klasse(db, "J 3")
|
||||||
a = f.fahrer(db, "Aaa", "A", 2014)
|
a = f.fahrer(db, "Aaa", "A", 2014)
|
||||||
b = f.fahrer(db, "Bbb", "B", 2014)
|
b = f.fahrer(db, "Bbb", "B", 2014)
|
||||||
# beide sammeln gleich viele Punkte, aber A hat mehr Siege
|
_lauf(db, m, k, [(a, 1), (b, 6)], datum=date(2026, 4, 1)) # A:25 B:10
|
||||||
_lauf(db, 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, 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, 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
|
||||||
_lauf(db, 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)
|
||||||
m = db.scalars(
|
|
||||||
select(Meisterschaft).where(Meisterschaft.bezeichnung == "HTH Jugend J 3 2026")
|
|
||||||
).one()
|
|
||||||
bericht = berechne_stand(db, m.id)
|
|
||||||
assert bericht.zeilen[0].fahrer_id == a.id
|
assert bericht.zeilen[0].fahrer_id == a.id
|
||||||
assert bericht.zeilen[0].anzahl_siege > bericht.zeilen[1].anzahl_siege
|
assert bericht.zeilen[0].anzahl_siege > bericht.zeilen[1].anzahl_siege
|
||||||
|
|
||||||
|
|
||||||
def test_laeufe_ohne_meisterschaft_zaehlen_nicht(seed_2026):
|
def test_laeufe_ohne_meisterschaft_zaehlen_nicht(seed_2026):
|
||||||
db = seed_2026
|
db = seed_2026
|
||||||
|
m = f.meisterschaft(db)
|
||||||
k = f.klasse(db, "J 3")
|
k = f.klasse(db, "J 3")
|
||||||
a = f.fahrer(db, "Solo", "S", 2014)
|
a = f.fahrer(db, "Solo", "S", 2014)
|
||||||
_lauf(db, k, [(a, 1)], datum=date(2026, 4, 1), zaehlt=False)
|
_lauf(db, m, k, [(a, 1)], datum=date(2026, 4, 1), zaehlt=False)
|
||||||
m = db.scalars(
|
bericht = berechne_stand(db, m.id, kategorie=Kategorie.JUGEND, klasse_id=k.id)
|
||||||
select(Meisterschaft).where(Meisterschaft.bezeichnung == "HTH Jugend J 3 2026")
|
|
||||||
).one()
|
|
||||||
bericht = berechne_stand(db, m.id)
|
|
||||||
assert bericht.laeufe_gesamt == 0
|
assert bericht.laeufe_gesamt == 0
|
||||||
assert bericht.zeilen == []
|
assert bericht.zeilen == []
|
||||||
|
|||||||
+10
-6
@@ -30,15 +30,19 @@ def test_seed_saison_streichresultate(seed_2026):
|
|||||||
assert s.streichresultate_tabelle["4"] == 2
|
assert s.streichresultate_tabelle["4"] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_seed_meisterschaften(seed_2026):
|
def test_seed_meisterschaft(seed_2026):
|
||||||
db = seed_2026
|
db = seed_2026
|
||||||
ms = db.scalars(select(Meisterschaft).where(Meisterschaft.saison_jahr == 2026)).all()
|
ms = db.scalars(select(Meisterschaft).where(Meisterschaft.saison_jahr == 2026)).all()
|
||||||
bezeichnungen = {m.bezeichnung for m in ms}
|
# genau EINE Meisterschaft pro Saison
|
||||||
assert "HTH Erwachsene 2026" in bezeichnungen
|
assert len(ms) == 1
|
||||||
assert "HTH Jugend J 3 2026" in bezeichnungen
|
assert ms[0].bezeichnung == "HTH-Meisterschaft 2026"
|
||||||
# O-1: Platzhalter-Punktetabelle ist markiert
|
# O-1: Platzhalter-Punktetabelle ist markiert
|
||||||
erw = next(m for m in ms if m.bezeichnung == "HTH Erwachsene 2026")
|
assert "_hinweis" in ms[0].regelwerk
|
||||||
assert "_hinweis" in erw.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):
|
def test_seed_vereins_kurznamen(seed_2026):
|
||||||
|
|||||||
Reference in New Issue
Block a user