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:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python3 pdftxt.py /home/linus/.claude/projects/-home-linus-OAMC/40bc91cb-023f-4cbe-8d30-ab5118a56de2/tool-results/webfetch-1788609089573-vokmc5.pdf)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(domain:www.oamc.de)",
|
||||
"WebFetch(domain:oamc.de)",
|
||||
"Bash(python3 -c \"import pypdf; print\\('pypdf ok'\\)\")",
|
||||
"Bash(python3 -c \"import fitz; print\\('pymupdf ok'\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.venv/
|
||||
.git/
|
||||
.gitignore
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
tests/
|
||||
docs/
|
||||
debian/
|
||||
deploy/
|
||||
packaging/
|
||||
var/
|
||||
*.sqlite
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -0,0 +1,26 @@
|
||||
# OAMC Turnierauswertung — Beispiel-Konfiguration
|
||||
# Kopieren nach .env und anpassen. Alle Werte koennen auch als Umgebungsvariable
|
||||
# mit Prefix OAMC_ gesetzt werden (z. B. OAMC_DATABASE_URL).
|
||||
|
||||
# --- Datenbank ---------------------------------------------------------------
|
||||
OAMC_DATABASE_URL=postgresql+psycopg://linus:linus@localhost:5432/oamc_dev
|
||||
|
||||
# --- Sicherheit ------------------------------------------------------------
|
||||
# Signierschluessel fuer Session-/JWT-Cookies. In Produktion zwingend setzen!
|
||||
OAMC_SECRET_KEY=bitte-aendern-mindestens-32-zeichen-zufaellig
|
||||
# Kommagetrennte API-Keys fuer den Ingest (Excel-Auswertung etc.)
|
||||
OAMC_INGEST_API_KEYS=dev-ingest-key
|
||||
|
||||
# --- Betrieb ---------------------------------------------------------------
|
||||
OAMC_ENV=dev
|
||||
OAMC_LOG_LEVEL=info
|
||||
# Basis-URL, unter der die Anwendung oeffentlich erreichbar ist (fuer iCal, Links)
|
||||
OAMC_PUBLIC_BASE_URL=http://localhost:8000
|
||||
# Verzeichnis fuer Uploads (PDFs, Bilder)
|
||||
OAMC_UPLOAD_DIR=./var/uploads
|
||||
|
||||
# --- Zeitmessung / Geraete ----------------------------------------------
|
||||
# Plausibilitaetsgrenzen fuer eine Einzelmessung in Sekunden (global; je Klasse
|
||||
# spaeter aus der DB). Verstoesse werden markiert, nicht abgelehnt.
|
||||
OAMC_MESS_MIN_SEKUNDEN=15
|
||||
OAMC_MESS_MAX_SEKUNDEN=600
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
.venv/
|
||||
venv/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
|
||||
# Lokale Konfiguration & Secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.local.toml
|
||||
|
||||
# Laufzeitdaten
|
||||
/var/
|
||||
uploads/
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# Debian-Build-Artefakte
|
||||
debian/.debhelper/
|
||||
debian/oamc-turnier/
|
||||
debian/files
|
||||
debian/*.substvars
|
||||
debian/*.log
|
||||
debian/debhelper-build-stamp
|
||||
../*.deb
|
||||
../*.buildinfo
|
||||
../*.changes
|
||||
|
||||
# Editor
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
@@ -0,0 +1,138 @@
|
||||
# OAMC Turnierauswertung
|
||||
|
||||
Webanwendung zur Auswertung von **ADAC Motorrad-Turnieren** des OAMC Reinheim e.V.:
|
||||
Ergebnisse (Fehlerpunkte + Zeit → Platzierung), Fahrerdatenbank mit Historie,
|
||||
Meisterschaftswertung und ein **Endpoint für die Lichtschranken-Zeitmessung**.
|
||||
|
||||
Fachliche Grundlagen, offene Fragen und Architekturbegründung: **[`plan.md`](plan.md)**.
|
||||
|
||||
> Der Mess-Pi-Client (GPIO/Lichtschranke) ist **nicht** Teil dieses Repos — die
|
||||
> Anwendung stellt nur den Endpoint bereit. Contract:
|
||||
> [`docs/zeitmessung-endpoint.md`](docs/zeitmessung-endpoint.md).
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Schicht | Wahl |
|
||||
|---|---|
|
||||
| DB | PostgreSQL 17 (JSONB, ARRAY, `pg_trgm`) |
|
||||
| Backend | Python 3.11+ · FastAPI · SQLAlchemy 2 · Alembic · Pydantic v2 |
|
||||
| Frontend | serverseitig gerendertes Jinja2 + HTMX (kein Node, kein Build) |
|
||||
| Live | Server-Sent Events (`sse-starlette`) |
|
||||
| Auslieferung | **Docker-Image** *und* **Debian-Paket** (`.deb` / eigenes APT-Repo) |
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
src/oamc/
|
||||
models/ ORM (§4 im Plan)
|
||||
wertung/ Wertungs-Engine — der Kern (§3.3). engine.py + service.py
|
||||
meisterschaft/ Punkte, Streichresultate, Tie-Break, Endlaufquali (§3.4)
|
||||
matching/ Fahrer-Dublettenerkennung + Merge (§6.4)
|
||||
importer/ Bulk-Ingest + Excel-Leser
|
||||
geraete/ Zeitmessungs-Endpoint-Logik (§3.6)
|
||||
api/v1/ read.py · ingest.py · geraete.py · admin.py
|
||||
frontend/ public.py · intern.py + templates/ static/
|
||||
migrations/ Alembic
|
||||
tests/ pytest gegen echte PostgreSQL-Testdatenbank
|
||||
docker/ Dockerfile · nginx.conf · entrypoint.sh
|
||||
debian/ .deb-Paketierung (venv unter /opt/oamc-turnier)
|
||||
deploy/ systemd-Unit · nginx-vHost · Backup-Skript (bare metal)
|
||||
packaging/apt-repo/ reprepro-Konfiguration + Build-Skript
|
||||
```
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv && . .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
cp .env.example .env # DATABASE_URL anpassen
|
||||
|
||||
createdb oamc_dev # PostgreSQL-Rolle mit CREATEDB nötig
|
||||
alembic upgrade head
|
||||
oamc-turnier seed # Stammdaten Saison 2026
|
||||
oamc-turnier benutzer-anlegen admin --rolle admin
|
||||
oamc-turnier serve --reload # http://127.0.0.1:8000 · /docs
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
Brauchen eine erreichbare PostgreSQL-Testdatenbank (`OAMC_DATABASE_URL`, Default
|
||||
`…/oamc_test`). Das Schema wird pro Lauf frisch migriert, jeder Test läuft in einer
|
||||
zurückgerollten Transaktion.
|
||||
|
||||
```sh
|
||||
createdb oamc_test
|
||||
pytest
|
||||
```
|
||||
|
||||
Der **Abnahmetest der Wertungs-Engine** (`tests/test_wertung_regression_2026_05_31.py`)
|
||||
rechnet die im Plan §3.3 zitierten Zeilen der realen Ergebnisliste vom 31.05.2026 nach.
|
||||
|
||||
## Auslieferung 1 — Docker / „docker repo"
|
||||
|
||||
```sh
|
||||
cp .env.example .env # OAMC_SECRET_KEY setzen!
|
||||
docker compose up -d # db + app + nginx → http://localhost:8080
|
||||
docker compose exec app oamc-turnier seed
|
||||
|
||||
# Image für eine Registry bauen/pushen:
|
||||
docker build -t <registry>/oamc-turnier:0.1.0 -f docker/Dockerfile .
|
||||
docker push <registry>/oamc-turnier:0.1.0
|
||||
```
|
||||
|
||||
Multi-Arch für den Raspberry Pi 4:
|
||||
|
||||
```sh
|
||||
docker buildx build --platform linux/arm64,linux/amd64 \
|
||||
-t <registry>/oamc-turnier:0.1.0 -f docker/Dockerfile --push .
|
||||
```
|
||||
|
||||
Migrationen laufen automatisch beim Containerstart (`docker/entrypoint.sh`).
|
||||
|
||||
## Auslieferung 2 — Debian-Paket / eigenes APT-Repo
|
||||
|
||||
```sh
|
||||
sudo apt install build-essential debhelper devscripts python3-venv
|
||||
./packaging/apt-repo/build-deb.sh # → ../oamc-turnier_0.1.0_*.deb
|
||||
sudo apt install ../oamc-turnier_0.1.0_*.deb
|
||||
```
|
||||
|
||||
Das Paket bringt ein eigenes virtualenv unter `/opt/oamc-turnier/venv`, eine
|
||||
systemd-Unit und Beispielkonfiguration mit. Danach:
|
||||
|
||||
```sh
|
||||
sudoedit /etc/oamc-turnier/oamc-turnier.env # DATABASE_URL, SECRET_KEY
|
||||
sudo systemctl enable --now oamc-turnier
|
||||
sudo -u oamc /opt/oamc-turnier/venv/bin/oamc-turnier seed
|
||||
```
|
||||
|
||||
Eigenes signiertes APT-Repo aufsetzen: [`packaging/apt-repo/README.md`](packaging/apt-repo/README.md).
|
||||
|
||||
## API-Überblick (`/docs` für die vollständige OpenAPI-Doku)
|
||||
|
||||
| Bereich | Auth | Beispiele |
|
||||
|---|---|---|
|
||||
| **Read** | – | `GET /api/v1/turniere/{id}/ergebnisse`, `/api/v1/fahrer?q=`, `/api/v1/meisterschaften/{id}/stand` |
|
||||
| **Ingest** | `X-API-Key` | `POST /api/v1/turniere/{id}/ergebnisse` (idempotent, `?dry_run=true`), `POST …/import/xlsx` |
|
||||
| **Geräte** | `Authorization: Bearer <geräte-token>` | `POST /api/v1/zeitmessungen` (Batch, idempotent), SSE `GET /api/v1/turniere/aktiv/anzeige` |
|
||||
| **Admin** | interne Session | `POST /api/v1/admin/geraete`, `…/zeitmessungen/{id}/zuordnen`, `…/meisterschaften/{id}/berechnen`, `…/fahrer/merge` |
|
||||
|
||||
## Bekannte offene Punkte (aus `plan.md` §10)
|
||||
|
||||
Diese sind **im Code klar markiert** und blockieren einen offiziellen Produktivgang:
|
||||
|
||||
* **O-1** — echte Meisterschafts-Punktetabelle fehlt. Es läuft eine
|
||||
`PLATZHALTER_PUNKTETABELLE`; jeder berechnete Stand trägt einen Warnhinweis.
|
||||
* **O-2** — Zuordnung `S 1`–`S 9` / `A 1`–`A 4` → Fahrzeugtyp: Klassen sind
|
||||
angelegt, `fahrzeugtyp` bleibt `NULL`.
|
||||
* **Wertungsregeln §3.3** sind aus PDFs *abgeleitet* — vor Produktivgang gegen die
|
||||
ADAC-Turnierordnung 2026 zu verifizieren. Die Engine ist regelbasiert
|
||||
konfigurierbar (`oamc.wertung.engine.REGELN`), nicht fest verdrahtet.
|
||||
* **O-13/O-14/O-16** — Details der Zeitmessung (was wird gemessen, Zuordnung zum
|
||||
Fahrer, Genauigkeit): siehe `docs/zeitmessung-endpoint.md`.
|
||||
* **§11 Datenschutz** — Fahrerprofile sind `noindex` + `robots.txt`-gesperrt,
|
||||
Geburts*jahr* statt -datum, Opt-out-Anonymisierung (`fahrer.anzeige_anonymisiert`)
|
||||
ist umgesetzt; die Vorstandsentscheidung zur öffentlichen Darstellung von
|
||||
Jugenddaten steht noch aus.
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Alembic-Konfiguration. Die DB-URL kommt aus oamc.config (env.py), NICHT von hier.
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = src
|
||||
path_separator = os
|
||||
timezone = Europe/Berlin
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
oamc-turnier (0.1.0) unstable; urgency=medium
|
||||
|
||||
* Erste Paketierung: FastAPI-Anwendung, Datenmodell + Alembic-Migrationen,
|
||||
Wertungs-Engine (Plan §3.3), Meisterschaftsberechnung (Platzhalter-Punkte,
|
||||
O-1 offen), Ingest-API, Geraete-/Zeitmessungs-Endpoint, oeffentliches und
|
||||
internes Frontend.
|
||||
|
||||
-- OAMC Reinheim e.V. im ADAC <webmaster@oamc.de> Fri, 05 Sep 2026 18:00:00 +0200
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
Source: oamc-turnier
|
||||
Section: web
|
||||
Priority: optional
|
||||
Maintainer: OAMC Reinheim e.V. im ADAC <webmaster@oamc.de>
|
||||
Build-Depends: debhelper-compat (= 13),
|
||||
python3 (>= 3.11),
|
||||
python3-venv,
|
||||
python3-pip
|
||||
Standards-Version: 4.6.2
|
||||
Homepage: https://oamc.de
|
||||
Rules-Requires-Root: no
|
||||
|
||||
Package: oamc-turnier
|
||||
Architecture: any
|
||||
Depends: ${misc:Depends},
|
||||
python3 (>= 3.11),
|
||||
adduser,
|
||||
libpq5,
|
||||
postgresql-client
|
||||
Recommends: postgresql-17 | postgresql, nginx
|
||||
Suggests: certbot
|
||||
Description: Auswertung von ADAC Motorrad-Turnieren (OAMC Reinheim)
|
||||
Webanwendung fuer Turnierergebnisse, Zeitmessungs-Ingest, Fahrerdatenbank und
|
||||
Meisterschaftswertung. Enthaelt die FastAPI-Anwendung als eigenstaendiges
|
||||
virtualenv unter /opt/oamc-turnier sowie eine systemd-Unit.
|
||||
.
|
||||
Nach der Installation ist /etc/oamc-turnier/oamc-turnier.env auszufuellen
|
||||
(DATABASE_URL, SECRET_KEY) und der Dienst mit
|
||||
"systemctl enable --now oamc-turnier" zu starten.
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: oamc-turnier
|
||||
|
||||
Files: *
|
||||
Copyright: 2026 OAMC Reinheim e.V. im ADAC
|
||||
License: proprietary
|
||||
Interne Nutzung des OAMC Reinheim e.V. im ADAC. Weitergabe nur mit
|
||||
ausdruecklicher Zustimmung des Vereinsvorstands.
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
# /etc/oamc-turnier/oamc-turnier.env — nach der Installation ausfuellen!
|
||||
# Danach: systemctl enable --now oamc-turnier
|
||||
|
||||
# Pflicht:
|
||||
OAMC_DATABASE_URL=postgresql+psycopg://oamc:CHANGE_ME@localhost:5432/oamc
|
||||
OAMC_SECRET_KEY=CHANGE_ME_zufaellig_mind_32_zeichen
|
||||
|
||||
# Ingest-API-Keys (kommagetrennt), fuer die Excel-Auswertung:
|
||||
OAMC_INGEST_API_KEYS=CHANGE_ME_ingest_key
|
||||
|
||||
# Betrieb:
|
||||
OAMC_ENV=prod
|
||||
OAMC_PUBLIC_BASE_URL=https://mt.oamc.de
|
||||
OAMC_UPLOAD_DIR=/var/oamc/uploads
|
||||
|
||||
# Plausibilitaetsgrenzen Zeitmessung (Sekunden):
|
||||
OAMC_MESS_MIN_SEKUNDEN=15
|
||||
OAMC_MESS_MAX_SEKUNDEN=600
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
var/oamc/uploads
|
||||
etc/oamc-turnier
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
debian/env/oamc-turnier.env etc/oamc-turnier
|
||||
deploy/nginx/oamc-turnier.conf usr/share/doc/oamc-turnier/examples
|
||||
deploy/backup/pg_dump.sh usr/share/doc/oamc-turnier/examples
|
||||
docs/zeitmessung-endpoint.md usr/share/doc/oamc-turnier
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
[Unit]
|
||||
Description=OAMC Turnierauswertung (FastAPI/uvicorn)
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
User=oamc
|
||||
Group=oamc
|
||||
EnvironmentFile=/etc/oamc-turnier/oamc-turnier.env
|
||||
WorkingDirectory=/opt/oamc-turnier
|
||||
# Migrationen vor dem Start (idempotent)
|
||||
ExecStartPre=/opt/oamc-turnier/venv/bin/alembic -c /opt/oamc-turnier/alembic.ini upgrade head
|
||||
ExecStart=/opt/oamc-turnier/venv/bin/uvicorn oamc.main:app \
|
||||
--host 127.0.0.1 --port 8000 --proxy-headers --forwarded-allow-ips '*'
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
# Haertung
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/var/oamc
|
||||
StateDirectory=oamc-turnier
|
||||
RuntimeDirectory=oamc-turnier
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
# Systembenutzer fuer den Dienst
|
||||
if ! getent passwd oamc >/dev/null; then
|
||||
adduser --system --group --home /var/oamc --no-create-home \
|
||||
--gecos "OAMC Turnierauswertung" oamc
|
||||
fi
|
||||
mkdir -p /var/oamc/uploads
|
||||
chown -R oamc:oamc /var/oamc
|
||||
|
||||
# Konfig-Datei nur beim ersten Mal, mit engen Rechten
|
||||
if [ ! -e /etc/oamc-turnier/oamc-turnier.env ]; then
|
||||
echo "Hinweis: /etc/oamc-turnier/oamc-turnier.env anlegen (siehe Beispiel)."
|
||||
fi
|
||||
chown root:oamc /etc/oamc-turnier/oamc-turnier.env 2>/dev/null || true
|
||||
chmod 0640 /etc/oamc-turnier/oamc-turnier.env 2>/dev/null || true
|
||||
|
||||
echo "-----------------------------------------------------------------"
|
||||
echo " oamc-turnier installiert. Naechste Schritte:"
|
||||
echo " 1) PostgreSQL-DB + Rolle anlegen (siehe /usr/share/doc/oamc-turnier)"
|
||||
echo " 2) /etc/oamc-turnier/oamc-turnier.env ausfuellen"
|
||||
echo " 3) systemctl enable --now oamc-turnier"
|
||||
echo " 4) /opt/oamc-turnier/venv/bin/oamc-turnier seed # Stammdaten 2026"
|
||||
echo " 5) nginx: /usr/share/doc/oamc-turnier/examples/oamc-turnier.conf"
|
||||
echo "-----------------------------------------------------------------"
|
||||
;;
|
||||
esac
|
||||
|
||||
#DEBHELPER#
|
||||
exit 0
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
purge)
|
||||
rm -rf /opt/oamc-turnier/venv
|
||||
rm -rf /var/oamc/uploads
|
||||
# DB und /etc-Konfig bewusst NICHT loeschen (Datenverlust vermeiden)
|
||||
echo "Hinweis: Datenbank und /etc/oamc-turnier bleiben erhalten."
|
||||
;;
|
||||
esac
|
||||
|
||||
#DEBHELPER#
|
||||
exit 0
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/make -f
|
||||
# Baut ein in sich geschlossenes virtualenv unter /opt/oamc-turnier.
|
||||
# Bewusst ohne dh-virtualenv (nicht in Debian 13 main) — venv + pip von Hand.
|
||||
|
||||
export DH_VERBOSE = 1
|
||||
VENV = debian/oamc-turnier/opt/oamc-turnier/venv
|
||||
APPDIR = debian/oamc-turnier/opt/oamc-turnier
|
||||
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_auto_build:
|
||||
# nichts zu kompilieren
|
||||
|
||||
override_dh_auto_test:
|
||||
# Tests laufen in CI gegen eine echte DB, nicht im Paketbau
|
||||
|
||||
override_dh_auto_install:
|
||||
mkdir -p $(APPDIR)
|
||||
python3 -m venv $(VENV)
|
||||
$(VENV)/bin/pip install --no-cache-dir --upgrade pip
|
||||
$(VENV)/bin/pip install --no-cache-dir .
|
||||
# Migrationen + alembic.ini mitliefern (fuer "oamc-turnier migrate")
|
||||
cp -r migrations $(APPDIR)/migrations
|
||||
cp alembic.ini $(APPDIR)/alembic.ini
|
||||
# venv relozierbar halten: absolute Shebangs auf den Zielpfad umschreiben
|
||||
find $(VENV)/bin -type f -exec sed -i \
|
||||
"1s|^#!.*python.*|#!/opt/oamc-turnier/venv/bin/python3|" {} + || true
|
||||
# Wrapper in /usr/bin
|
||||
mkdir -p debian/oamc-turnier/usr/bin
|
||||
ln -sf /opt/oamc-turnier/venv/bin/oamc-turnier debian/oamc-turnier/usr/bin/oamc-turnier
|
||||
|
||||
override_dh_installsystemd:
|
||||
dh_installsystemd --no-start --no-enable
|
||||
|
||||
override_dh_fixperms:
|
||||
dh_fixperms
|
||||
chmod 0640 debian/oamc-turnier/etc/oamc-turnier/oamc-turnier.env || true
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
3.0 (native)
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
# Taeglicher pg_dump der OAMC-Turnierdatenbank (Plan §5.3).
|
||||
# Als Cron/Timer fuer den Benutzer 'postgres' oder 'oamc' einrichten:
|
||||
# 15 2 * * * /usr/share/doc/oamc-turnier/examples/pg_dump.sh
|
||||
set -eu
|
||||
|
||||
DB="${OAMC_DB_NAME:-oamc}"
|
||||
ZIEL="${OAMC_BACKUP_DIR:-/var/backups/oamc}"
|
||||
BEHALTEN_TAGE="${OAMC_BACKUP_KEEP_DAYS:-30}"
|
||||
STAMP="$(date +%Y-%m-%d_%H%M)"
|
||||
|
||||
mkdir -p "$ZIEL"
|
||||
DATEI="$ZIEL/oamc_${STAMP}.sql.gz"
|
||||
|
||||
pg_dump --format=plain --no-owner --dbname="$DB" | gzip -9 > "$DATEI"
|
||||
echo "Backup: $DATEI ($(du -h "$DATEI" | cut -f1))"
|
||||
|
||||
# Alte Backups aufraeumen
|
||||
find "$ZIEL" -name 'oamc_*.sql.gz' -mtime "+${BEHALTEN_TAGE}" -delete
|
||||
|
||||
# WICHTIG (Plan §5.3): woechentlich eine Vollsicherung auf ein EXTERNES Medium
|
||||
# ziehen und den Restore mindestens einmal testen:
|
||||
# gunzip -c oamc_....sql.gz | psql -d oamc_restore_test
|
||||
@@ -0,0 +1,40 @@
|
||||
# nginx vHost fuer die OAMC Turnierauswertung (bare-metal / Raspberry Pi).
|
||||
# TLS via certbot: certbot --nginx -d mt.oamc.de
|
||||
# Danach ergaenzt certbot die listen-443- und ssl_*-Zeilen automatisch.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name mt.oamc.de;
|
||||
|
||||
# certbot-Challenge
|
||||
location /.well-known/acme-challenge/ { root /var/www/html; }
|
||||
|
||||
# Rest auf HTTPS umleiten (nach dem ersten certbot-Lauf aktivieren):
|
||||
# return 301 https://$host$request_uri;
|
||||
|
||||
client_max_body_size 25m;
|
||||
|
||||
location = /favicon.ico { access_log off; log_not_found off; }
|
||||
|
||||
location /api/v1/turniere/aktiv/anzeige {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Manuelle Installation ohne .deb (git-Checkout + venv unter /opt/oamc-turnier/app).
|
||||
# sudo cp deploy/systemd/oamc-turnier.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload && sudo systemctl enable --now oamc-turnier
|
||||
[Unit]
|
||||
Description=OAMC Turnierauswertung (FastAPI/uvicorn)
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
User=oamc
|
||||
Group=oamc
|
||||
EnvironmentFile=/etc/oamc-turnier/oamc-turnier.env
|
||||
WorkingDirectory=/opt/oamc-turnier/app
|
||||
ExecStartPre=/opt/oamc-turnier/app/.venv/bin/alembic upgrade head
|
||||
ExecStart=/opt/oamc-turnier/app/.venv/bin/uvicorn oamc.main:app \
|
||||
--host 127.0.0.1 --port 8000 --proxy-headers --forwarded-allow-ips '*'
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/var/oamc
|
||||
StateDirectory=oamc-turnier
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,55 @@
|
||||
# OAMC Turnierauswertung — lokaler/kleiner Produktivbetrieb per Container.
|
||||
# docker compose up -d (Build + Start)
|
||||
# docker compose exec app oamc-turnier seed
|
||||
#
|
||||
# Fuer "docker repo": das app-Image separat bauen und pushen:
|
||||
# docker build -t <registry>/oamc-turnier:0.1.0 -f docker/Dockerfile .
|
||||
# docker push <registry>/oamc-turnier:0.1.0
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_USER: oamc
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-oamc}
|
||||
POSTGRES_DB: oamc
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U oamc"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
image: oamc-turnier:${OAMC_VERSION:-0.1.0}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
OAMC_DATABASE_URL: postgresql+psycopg://oamc:${POSTGRES_PASSWORD:-oamc}@db:5432/oamc
|
||||
OAMC_SECRET_KEY: ${OAMC_SECRET_KEY:?bitte in .env setzen}
|
||||
OAMC_INGEST_API_KEYS: ${OAMC_INGEST_API_KEYS:-dev-ingest-key}
|
||||
OAMC_ENV: ${OAMC_ENV:-prod}
|
||||
OAMC_PUBLIC_BASE_URL: ${OAMC_PUBLIC_BASE_URL:-http://localhost}
|
||||
OAMC_SEED_ON_START: ${OAMC_SEED_ON_START:-0}
|
||||
volumes:
|
||||
- uploads:/var/oamc/uploads
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
depends_on: [app]
|
||||
ports:
|
||||
- "${OAMC_HTTP_PORT:-8080}:80"
|
||||
volumes:
|
||||
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
uploads:
|
||||
@@ -0,0 +1,45 @@
|
||||
# OAMC Turnierauswertung — Anwendungs-Image
|
||||
# Multi-Arch: baut fuer linux/arm64 (Raspberry Pi 4) und linux/amd64.
|
||||
# docker buildx build --platform linux/arm64,linux/amd64 -f docker/Dockerfile .
|
||||
|
||||
FROM python:3.12-slim AS build
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_NO_CACHE_DIR=1
|
||||
WORKDIR /app
|
||||
|
||||
# WeasyPrint (PDF-Export) braucht native Bibliotheken; Build-Tools nur hier.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src ./src
|
||||
RUN python -m venv /opt/venv \
|
||||
&& /opt/venv/bin/pip install --upgrade pip \
|
||||
&& /opt/venv/bin/pip install .
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM python:3.12-slim AS runtime
|
||||
ENV PYTHONUNBUFFERED=1 PATH="/opt/venv/bin:$PATH" \
|
||||
OAMC_UPLOAD_DIR=/var/oamc/uploads
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 libpango-1.0-0 libpangoft2-1.0-0 libcairo2 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --create-home --uid 10001 oamc \
|
||||
&& mkdir -p /var/oamc/uploads && chown -R oamc:oamc /var/oamc
|
||||
|
||||
COPY --from=build /opt/venv /opt/venv
|
||||
COPY src ./src
|
||||
COPY migrations ./migrations
|
||||
COPY alembic.ini ./
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
USER oamc
|
||||
EXPOSE 8000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz').status==200 else 1)"
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["uvicorn", "oamc.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
# Wartet auf die DB, spielt Migrationen ein, optional Seed, startet dann das CMD.
|
||||
set -eu
|
||||
|
||||
echo "[entrypoint] warte auf Datenbank ..."
|
||||
python - <<'PY'
|
||||
import os, time, sys
|
||||
import psycopg
|
||||
url = os.environ.get("OAMC_DATABASE_URL", "")
|
||||
dsn = url.replace("postgresql+psycopg://", "postgresql://", 1)
|
||||
for i in range(60):
|
||||
try:
|
||||
psycopg.connect(dsn, connect_timeout=3).close()
|
||||
print("[entrypoint] DB erreichbar")
|
||||
sys.exit(0)
|
||||
except Exception as e: # noqa
|
||||
print(f"[entrypoint] ({i+1}/60) noch nicht bereit: {e}")
|
||||
time.sleep(2)
|
||||
sys.exit("[entrypoint] DB nicht erreichbar")
|
||||
PY
|
||||
|
||||
echo "[entrypoint] alembic upgrade head"
|
||||
alembic upgrade head
|
||||
|
||||
if [ "${OAMC_SEED_ON_START:-0}" = "1" ]; then
|
||||
echo "[entrypoint] Seed Saison 2026 (idempotent)"
|
||||
oamc-turnier seed || echo "[entrypoint] Seed uebersprungen/fehlgeschlagen"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,40 @@
|
||||
# nginx vor uvicorn: TLS-Terminierung (im Compose ohne TLS), statische Dateien,
|
||||
# SSE-taugliches Proxying (kein Buffering, lange Timeouts).
|
||||
|
||||
upstream oamc_app { server app:8000; }
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 25m;
|
||||
charset utf-8;
|
||||
|
||||
location /static/ {
|
||||
proxy_pass http://oamc_app;
|
||||
proxy_cache_valid 200 10m;
|
||||
expires 1h;
|
||||
}
|
||||
|
||||
# Server-Sent Events: Puffern aus, Verbindung offen halten
|
||||
location /api/v1/turniere/aktiv/anzeige {
|
||||
proxy_pass http://oamc_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://oamc_app;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
# Endpoint für die Zeitmessung (Lichtschranke)
|
||||
|
||||
> Der **Mess-Pi-Client selbst ist nicht Teil dieses Projekts** — dieses Dokument
|
||||
> beschreibt nur den **Endpoint**, den ein beliebiger Client (der zweite Raspberry
|
||||
> Pi an der Lichtschranke) bespielt. Fachlicher Hintergrund: `plan.md` §3.6 / §6.2.
|
||||
|
||||
## Authentifizierung
|
||||
|
||||
Alle Geräte-Endpunkte erwarten einen **Geräte-Token** (nicht den Auswerter-Login):
|
||||
|
||||
```
|
||||
Authorization: Bearer <geraete-token>
|
||||
```
|
||||
|
||||
Token anlegen (interne Admin-Session):
|
||||
|
||||
```
|
||||
POST /api/v1/admin/geraete
|
||||
{ "bezeichnung": "lichtschranke-ziel-01", "typ": "lichtschranke" }
|
||||
→ 201 { "id": 1, "bezeichnung": "...", "token": "oamc_dev_…" } # Token nur hier einmalig
|
||||
```
|
||||
|
||||
Token neu ausgeben: `POST /api/v1/admin/geraete/{id}/token` ·
|
||||
Gerät sperren: `POST /api/v1/admin/geraete/{id}/sperren`
|
||||
|
||||
## Ablauf auf dem Client (empfohlen)
|
||||
|
||||
1. `POST /api/v1/geraete/heartbeat` alle ~15 s → Statusanzeige + `aktives_turnier_id`
|
||||
2. `GET /api/v1/geraete/konfiguration` → Entprellzeit, Messpunkte, Plausibilitätsgrenzen
|
||||
3. `GET /api/v1/turniere/aktiv/startliste` (ETag-fähig) → Startreihenfolge für die Anzeige
|
||||
4. Pro Durchfahrt eine Messung lokal puffern, dann als **Batch** senden:
|
||||
`POST /api/v1/zeitmessungen`
|
||||
5. `GET /api/v1/turniere/aktiv/anzeige` (SSE) → Live-Stream für die Anzeige am Parcours
|
||||
|
||||
## `POST /api/v1/zeitmessungen` — Batch, idempotent
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"geraet": "lichtschranke-ziel-01", // informativ
|
||||
"messungen": [
|
||||
{
|
||||
"id": "9f2c1e7a-4d38-4b6e-9a11-5c0b2d7e8f30", // UUID VOM GERÄT → Idempotenz
|
||||
"turnier_id": 42,
|
||||
"startnummer_gemeldet": 32, // Hinweis, kein Fremdschlüssel
|
||||
"messpunkt": "durchgang-1",
|
||||
"dauer_sekunden": 159.16, // vom Gerät berechnet (monotone Zeit)
|
||||
"gemessen_am": "2026-05-31T10:14:02.482+02:00",
|
||||
"monotonic_ns": 884213771004,
|
||||
"roh": { "trigger_start_ns": 884054611004, "trigger_ziel_ns": 884213771004 }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Antwort — jede Messung wird **einzeln** quittiert
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"quittungen": [
|
||||
{ "id": "9f2c1e7a-…", "status": "angenommen", "start_id": 512, "hinweis": null }
|
||||
],
|
||||
"angenommen": 1, "duplikate": 0, "unzugeordnet": 0, "unplausibel": 0
|
||||
}
|
||||
```
|
||||
|
||||
| `status` | Bedeutung | Client-Reaktion |
|
||||
|----------------|-----------|-----------------|
|
||||
| `angenommen` | gespeichert und einem Start zugeordnet | aus lokaler Queue löschen |
|
||||
| `duplikat` | UUID war schon da (Nachlieferung nach Timeout) | aus lokaler Queue löschen |
|
||||
| `unzugeordnet` | gespeichert, aber `startnummer_gemeldet` nicht auflösbar → manuelle Zuordnung im Frontend | aus lokaler Queue löschen |
|
||||
| `unplausibel` | gespeichert, Dauer außerhalb der Grenzen → im Frontend markiert | aus lokaler Queue löschen |
|
||||
| `fehler` | z. B. `turnier_id` unbekannt | **in der Queue behalten**, später erneut |
|
||||
|
||||
**Grundregeln** (Server-seitig umgesetzt):
|
||||
|
||||
* **Idempotent über `id`** — ein doppelt gesendeter Batch erzeugt keine Dubletten.
|
||||
Ein abgebrochener Request darf gefahrlos wiederholt werden.
|
||||
* Die **Dauer kommt fertig vom Gerät** — der Server rechnet **keine** Differenz aus
|
||||
zwei Wanduhr-Zeitstempeln (NTP-Sprung auf einem Pi ohne RTC).
|
||||
* Eine Messung **ohne** zuordenbare Startnummer wird **nie verworfen**, sondern als
|
||||
`unzugeordnet` gespeichert und im internen Frontend zur Zuordnung angeboten.
|
||||
* Plausibilitätsverstöße werden **markiert**, nicht abgelehnt. Grenzen:
|
||||
`OAMC_MESS_MIN_SEKUNDEN` / `OAMC_MESS_MAX_SEKUNDEN` bzw. je Gerät in
|
||||
`geraet.konfiguration`.
|
||||
* `gemessen_am` weit in der Vergangenheit → die Messung wird als *nachgeliefert*
|
||||
markiert und trotzdem angenommen.
|
||||
|
||||
## Fehlmessung nachträglich verwerfen
|
||||
|
||||
```
|
||||
POST /api/v1/zeitmessungen/{id}/verwerfen
|
||||
{ "grund": "Zuschauer hat die Schranke ausgelöst" }
|
||||
```
|
||||
|
||||
Die Messung bleibt mit Grund gespeichert (Status `verworfen`), zählt aber nicht in
|
||||
die `summe_zeit`.
|
||||
|
||||
## Was der Server aus den Messungen macht
|
||||
|
||||
`ergebnis.summe_zeit` wird bei der Auswertung als **Summe aller gültigen,
|
||||
zugeordneten Einzelmessungen** eines Starts gebildet
|
||||
(`oamc.wertung.service.summe_zeit_fuer_start`). Aus Einzelmessungen lässt sich die
|
||||
Summe jederzeit bilden — der umgekehrte Weg existiert nicht, deshalb speichert der
|
||||
Server jede Einzelmessung.
|
||||
|
||||
## Offene Punkte (aus `plan.md` §10), die den Endpoint noch betreffen
|
||||
|
||||
* **O-13** Was misst die Lichtschranke genau (eine Gesamtdurchfahrt, mehrere
|
||||
Durchgänge, einzelne Abschnitte)? → bestimmt `messpunkt` und die Summenbildung.
|
||||
* **O-14** Woher kennt der Client die Startnummer? → bis geklärt: `startnummer_gemeldet`
|
||||
als Hinweis, Server löst gegen die Startliste auf.
|
||||
* **O-16** Geforderte Messgenauigkeit / eine oder zwei Lichtschranken.
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Alembic-Umgebung. DB-URL und Metadaten kommen aus der Anwendung."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from oamc.config import get_settings
|
||||
from oamc.models import Base
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# Von der Autogenerate-Vergleichung ausgenommen: der Trigramm-Index wird als
|
||||
# Ausdrucksindex per Roh-SQL in der Initialmigration gepflegt.
|
||||
_IGNORE_INDEXES = {"ix_fahrer_name_trgm"}
|
||||
|
||||
|
||||
def include_object(obj, name, type_, reflected, compare_to): # noqa: ANN001
|
||||
if type_ == "index" and name in _IGNORE_INDEXES:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=get_settings().database_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
include_object=include_object,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
include_object=include_object,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: str | None = ${repr(down_revision)}
|
||||
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,300 @@
|
||||
"""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 ###
|
||||
@@ -0,0 +1,35 @@
|
||||
# APT-Repository für `oamc-turnier`
|
||||
|
||||
Kleines, signiertes Ein-Paket-Repo (für den Vereins-Pi / eigenen Server). Nutzt
|
||||
`reprepro`.
|
||||
|
||||
## Einmalig: Repo anlegen
|
||||
|
||||
```sh
|
||||
sudo apt install reprepro devscripts build-essential debhelper
|
||||
export GNUPGHOME=~/.gnupg
|
||||
gpg --full-generate-key # Repo-Signierschlüssel, z. B. "OAMC APT <apt@oamc.de>"
|
||||
gpg --armor --export apt@oamc.de > packaging/apt-repo/oamc-archive-keyring.asc
|
||||
|
||||
mkdir -p /srv/apt/oamc/conf
|
||||
cp packaging/apt-repo/distributions /srv/apt/oamc/conf/distributions # SignWith anpassen
|
||||
```
|
||||
|
||||
## Paket bauen und einspielen
|
||||
|
||||
```sh
|
||||
./packaging/apt-repo/build-deb.sh # erzeugt ../oamc-turnier_*.deb
|
||||
reprepro -b /srv/apt/oamc includedeb trixie ../oamc-turnier_*.deb
|
||||
```
|
||||
|
||||
`/srv/apt/oamc` per nginx/Apache unter z. B. `https://apt.oamc.de` ausliefern.
|
||||
|
||||
## Auf dem Zielrechner
|
||||
|
||||
```sh
|
||||
curl -fsSL https://apt.oamc.de/oamc-archive-keyring.asc \
|
||||
| sudo tee /etc/apt/keyrings/oamc.asc >/dev/null
|
||||
echo "deb [signed-by=/etc/apt/keyrings/oamc.asc] https://apt.oamc.de trixie main" \
|
||||
| sudo tee /etc/apt/sources.list.d/oamc.list
|
||||
sudo apt update && sudo apt install oamc-turnier
|
||||
```
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
# Baut das .deb aus dem Projektwurzelverzeichnis.
|
||||
# Voraussetzungen: dpkg-dev, debhelper (>= 13), python3-venv, Internet (pip).
|
||||
set -eu
|
||||
|
||||
WURZEL="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
cd "$WURZEL"
|
||||
|
||||
command -v dpkg-buildpackage >/dev/null || {
|
||||
echo "dpkg-dev / debhelper fehlen: sudo apt install build-essential debhelper devscripts"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# -us -uc: unsigniert (Signatur macht reprepro beim includedeb)
|
||||
dpkg-buildpackage -b -us -uc
|
||||
|
||||
echo
|
||||
echo "Fertig. Artefakte:"
|
||||
ls -1 ../oamc-turnier_*.deb 2>/dev/null || true
|
||||
@@ -0,0 +1,8 @@
|
||||
Origin: OAMC Reinheim
|
||||
Label: OAMC Turnierauswertung
|
||||
Suite: stable
|
||||
Codename: trixie
|
||||
Architectures: arm64 amd64 source
|
||||
Components: main
|
||||
Description: OAMC Turnierauswertung - internes APT-Repo
|
||||
SignWith: apt@oamc.de
|
||||
@@ -0,0 +1,848 @@
|
||||
# Plan: OAMC Turnierauswertung — Webanwendung
|
||||
|
||||
**Stand:** 2026-09-05 · **Status:** Entwurf, noch nicht freigegeben
|
||||
**Änderung 05.09.:** Zeitmessung per Lichtschranke aufgenommen (§3.6, §5.4, §6.2, AP 5)
|
||||
|
||||
---
|
||||
|
||||
## 1. Ausgangslage
|
||||
|
||||
### 1.1 Der Verein und die Sportart
|
||||
|
||||
Der **OAMC Reinheim e.V. im ADAC** (Odenwälder Automobil- und Motorsportclub) richtet
|
||||
**ADAC Motorrad-Turniere** aus. Diese laufen nach der **ADAC-Turnierordnung** im Bereich
|
||||
des **ADAC Hessen-Thüringen (HTH)**.
|
||||
|
||||
Motorrad-Turniersport ist kein Rennsport, sondern ein Geschicklichkeitswettbewerb: Die
|
||||
Fahrer durchfahren einen Parcours mit festen Aufgaben, dabei zählen **Fehlerpunkte** und
|
||||
**Fahrzeit**.
|
||||
|
||||
Aufgaben im Parcours (laut Sportbeschreibung auf oamc.de):
|
||||
|
||||
Fahrzeugabnahme · Schätzen (im Stand und in Fahrt) · Slalom · Acht-Figur · Spurgasse ·
|
||||
Kreisel · Tor · Schrägbrett · Wippe (mit und ohne Spurbrett) · Gummiringe ·
|
||||
Dose umsetzen · Fahrgasse · Stop Haltelinie · Langsamfahrstrecke
|
||||
|
||||
### 1.2 Aktueller Ist-Zustand der Website
|
||||
|
||||
Die bestehende Seite **oamc.de** (Zweitdomain: oamc-reinheim.de) ist eine statische
|
||||
**HTML-4.01-Frameset-Website**:
|
||||
|
||||
- Aufbau: `index` → `oben.htm` (Navigation) + `hauptfenster.htm`, jede Rubrik wieder
|
||||
ein eigenes Frameset (`m/frame-m.htm` → `m1.htm` Menü + `m2.htm` Inhalt)
|
||||
- Rubriken: Verein · VÜP ADAC-Platz · Kartgruppe · **Motorradgruppe** · Fahrrad JVS ·
|
||||
Anfahrt · Download · Weblinks · Flohmarkt · BikerSafetyDay
|
||||
- **Alle Ergebnisse liegen ausschließlich als PDF** unter `/datei/` und werden auf
|
||||
`d/d-mot-turnier.htm` verlinkt — Tagesergebnisse, Meisterschaftsstände (HTH 2002–2026),
|
||||
Bundesturnier-Ergebnisse, Protokolle
|
||||
- Die Seite **`m/m-fahrer.htm` ist eine „BAUSTELLE"** — es gibt aktuell **keine
|
||||
Fahrerdatenbank**. Genau das ist die Lücke, die dieses Projekt schließt.
|
||||
- `m-ergebnisse.htm` und `m-ausschreibung.htm` verweisen nur auf die PDF-Downloadseite
|
||||
|
||||
### 1.3 Wie heute ausgewertet wird
|
||||
|
||||
Die Rahmenausschreibung 2026 nennt als Anforderung an den Veranstalter:
|
||||
> „Auswertungscomputer mit MSOffice (**Excel**) und Drucker sollte zeitgemäß vorhanden sein."
|
||||
|
||||
Es existieren Vorlagen als `.xlsx` (z. B. `MT-NENNLISTE-5-2025.xlsx`). Die Auswertung
|
||||
läuft also heute **in Excel**, das Ergebnis wird als PDF exportiert und manuell auf die
|
||||
Website gelegt. Meisterschaftsstände werden separat fortgeschrieben.
|
||||
|
||||
**Schmerzpunkte:**
|
||||
|
||||
| Problem | Auswirkung |
|
||||
|---|---|
|
||||
| Ergebnisse nur als PDF | nicht durchsuchbar, nicht verlinkbar, nicht mobil lesbar |
|
||||
| Meisterschaftsstand manuell | fehleranfällig, spät verfügbar |
|
||||
| Keine Fahrerhistorie | „Wie oft war X schon dabei?" nicht beantwortbar |
|
||||
| Fahrerstammdaten pro Turnier neu erfasst | Tippfehler, Dubletten, Namensvarianten |
|
||||
| Frameset-Website | kein Deep-Link, schlechtes SEO, unbrauchbar auf dem Handy |
|
||||
|
||||
---
|
||||
|
||||
## 2. Ziel
|
||||
|
||||
Eine Webanwendung, die
|
||||
|
||||
1. **Turnierergebnisse auswertet** — Fehlerpunkte + Zeit → Platzierung je Wertungsklasse
|
||||
2. **Alle Daten in einer Datenbank hält** statt in Excel und PDF
|
||||
3. **Zeiten per API von der Lichtschranken-Zeitmessung entgegennimmt** (zweiter Raspberry
|
||||
Pi am Parcours, siehe §3.6), in die Datenbank schreibt und **live anzeigt**
|
||||
4. **Fahrer samt Stammdaten und Historie** bereitstellt (die heutige „Baustelle")
|
||||
5. Die **Meisterschafts-/Pokalwertung** automatisch fortschreibt
|
||||
|
||||
### Nicht-Ziele (bewusst außerhalb dieses Projekts)
|
||||
|
||||
- Ablösung der kompletten oamc.de (Kart, Fahrrad, Flohmarkt, VÜP bleiben, wo sie sind)
|
||||
- Online-Nennung/Anmeldung durch Teilnehmer → **Phase 2**, siehe §9
|
||||
- Elektronische Erfassung der **Fehlerpunkte** — die bleiben beim Wertungspersonal im
|
||||
Parcours und werden manuell eingegeben (siehe §3.6)
|
||||
- Zahlungsabwicklung des Nenngelds (10 € je Start, wird vor Ort kassiert)
|
||||
|
||||
---
|
||||
|
||||
## 3. Fachliche Grundlagen
|
||||
|
||||
> ⚠️ Die folgenden Regeln wurden aus der Ergebnisliste
|
||||
> `2026-05-31-MT-REIN-HAIN.PDF` und der `MT-Rahmenausschreibung-2026.pdf`
|
||||
> **abgeleitet**. Sie sind **vor der Implementierung gegen die ADAC-Turnierordnung 2026
|
||||
> (`MT-D2025.pdf`) und mit dem Bereichsleiter zu verifizieren** — siehe §10.
|
||||
|
||||
### 3.1 Veranstaltungsstruktur
|
||||
|
||||
- Ein Renntag kann ein **Doppelturnier** sein: zwei Turniere zweier Ortsclubs laufen
|
||||
parallel auf einem Platz, z. B. „ADAC-Motorrad-Doppelturnier des MSC Hainstadt und
|
||||
des OAMC Reinheim".
|
||||
- **Jeder Fahrer wird in beiden Turnieren getrennt gewertet** — die Ergebnisliste vom
|
||||
31.05.2026 enthält für dieselben Fahrer je einen Block „Reinheim" und einen Block
|
||||
„Hainstadt" mit **eigenen Zeiten, Fehlern und Platzierungen**.
|
||||
- ⇒ Datenmodell-Konsequenz: `veranstaltung` (Renntag) : `turnier` (Wertungslauf) = 1:n
|
||||
|
||||
### 3.2 Klassen
|
||||
|
||||
**Jugendklassen** (Einteilung strikt nach Jahrgang, Ausweispflicht bei der Nennung):
|
||||
|
||||
| Klasse | Jahrgang | ~Alter | Fahrzeug |
|
||||
|---|---|---|---|
|
||||
| J 1 | 2019 / 2018 / 2017 | 7–9 | Kindermotorrad max. 110 ccm / 5,5 kW |
|
||||
| J 2 | 2016 / 2015 | 10–11 | Kindermotorrad max. 110 ccm / 5,5 kW |
|
||||
| J 3 | 2014 / 2013 | 12–13 | Mofa, Motorrad + Roller max. 125 ccm / 11 kW |
|
||||
| J 4 | 2012 / 2011 | 14–15 | Mofa, Motorrad + Roller max. 125 ccm / 11 kW |
|
||||
| J 5 | 2010 / 2009 / 2008 (bis 18. Geburtstag) | 16–18 | Mofa, Motorrad + Roller max. 125 ccm / 11 kW |
|
||||
|
||||
Für Klasse 1 muss der Teilnehmer **am Veranstaltungstag** bereits 7 Jahre alt sein.
|
||||
⇒ Die Jahrgangstabelle ist **jahresabhängig** und muss versioniert in der DB liegen,
|
||||
nicht im Code (`klassen_jahrgang` mit `saison`).
|
||||
|
||||
**Erwachsenenklassen:** `S 1`–`S 9` (Stammfahrer) und `A 1`–`A 4` (Anfänger), unterteilt
|
||||
nach Fahrzeugtyp/Hubraum. Aus der Sportbeschreibung: Mokicks und Roller bis 80 ccm ·
|
||||
bis 250 ccm · bis 650 ccm · über 650 ccm · Enduro (mehrere Hubraumklassen) ·
|
||||
Seitenwagen · **Pedelec / E-Bike** · **E-Scooter** (neu).
|
||||
|
||||
> ❗ Die genaue Zuordnung `S 1`…`S 9` / `A 1`…`A 4` → Fahrzeugtyp ist aus den
|
||||
> öffentlichen Seiten **nicht** ersichtlich. Muss erfragt werden (§10).
|
||||
|
||||
**Wertungsklasse ≠ Klasse:** In der Ergebnisliste werden Klassen zu Wertungsklassen
|
||||
zusammengefasst, wenn zu wenige Starter da sind — z. B. eine gemeinsame Wertung
|
||||
„S 5, S 9, S 3, S 6, S 7" und „A 1, A 4". Das Zusammenlegen ist eine
|
||||
**Entscheidung pro Turnier** und gehört als Datum in die DB, nicht als Regel in den Code.
|
||||
|
||||
### 3.3 Wertungslogik ← Kernstück der Anwendung
|
||||
|
||||
Spalten der Ergebnisliste:
|
||||
|
||||
`Start-Nr.` · `Voller Name` · `Regionalclub` · `Klasse` · `Geschlecht` · `Kategorie` ·
|
||||
`Summe Zeit` · `Summe Fehler` · `Gesamt` · `Platzierung`
|
||||
|
||||
**Es gibt zwei verschiedene Berechnungen für `Gesamt`:**
|
||||
|
||||
**a) Erwachsene (Kategorie S, und A-Fahrer):**
|
||||
```
|
||||
Gesamt = Summe Zeit + Summe Fehler
|
||||
Platzierung = aufsteigend nach Gesamt
|
||||
```
|
||||
Belege aus der Liste vom 31.05.2026, Wertungsklasse „S 5, S 9, S 3, S 6, S 7":
|
||||
| Fahrer | Klasse | Zeit | Fehler | Gesamt | Platz |
|
||||
|---|---|---|---|---|---|
|
||||
| Bernius Meik | S 6 | 105,43 | 0 | **105,43** | 1 |
|
||||
| Schmunk Christian | S 9 | 103,26 | 10 | **113,26** | 2 |
|
||||
| Krack Robin | S 5 | 130,33 | 0 | **130,33** | 4 |
|
||||
| Steinbrech Nicolai | S 5 | 65,71 | 1210 | **1275,71** | 11 |
|
||||
|
||||
**b) Jugend (Kategorie J):**
|
||||
```
|
||||
Gesamt = Summe Fehler ← die Zeit geht NICHT in die Wertung ein
|
||||
Platzierung = aufsteigend nach Fehler, bei Gleichstand aufsteigend nach Zeit
|
||||
```
|
||||
Das deckt sich mit der Sportbeschreibung:
|
||||
> „Bei den Turnieren kommt es in der Jugendklasse vorrangig auf die fehlerfreie
|
||||
> Durchfahrt an, da die Zeit erst bei Punktgleichheit herangezogen wird."
|
||||
|
||||
Belege, Wertungsklasse J 3:
|
||||
| Fahrer | Zeit | Fehler | Gesamt | Platz | Anmerkung |
|
||||
|---|---|---|---|---|---|
|
||||
| Freund Philipp | 136,88 | 0 | **0** | 1 | drei Fahrer mit 0 Fehlern … |
|
||||
| D'Angelo Leandro | 140,40 | 0 | **0** | 2 | … Reihenfolge über die Zeit |
|
||||
| Heil Luca | 144,46 | 0 | **0** | 3 | |
|
||||
| Mattes Flynn | 160,05 | 10 | **10** | 4 | |
|
||||
| Ratter Mitja | 148,42 | 60 | **60** | 7 | Gleichstand 60 Fehler … |
|
||||
| Kemesies Finlay | 165,39 | 60 | **60** | 8 | … 148,42 < 165,39 |
|
||||
|
||||
⇒ Die Wertungsformel ist **pro Kategorie konfigurierbar** zu implementieren, nicht
|
||||
hart zu verdrahten (`wertungsregel` als Strategie/Enum je Kategorie und Saison).
|
||||
|
||||
**Fehlerpunkte** treten in Vielfachen von 10 auf (10/20/30/…/1210). Die Zuordnung
|
||||
„welcher Fehler an welcher Aufgabe kostet wie viele Punkte" steht in der
|
||||
ADAC-Turnierordnung und ist zu beschaffen (§10).
|
||||
|
||||
**Mehrfachstarts:** Ein Fahrer kann am selben Tag **mehrfach starten** (anderes Fahrzeug,
|
||||
andere Klasse). Beispiel 31.05.2026: *Schmunk Christian* startet in S 9 **und** S 3,
|
||||
*Bernius Meik* in S 6 **und** S 7 — jeweils mit eigener Startnummer und eigenem Ergebnis.
|
||||
⇒ Der Schlüssel eines Ergebnisses ist **nicht** (Turnier, Fahrer), sondern
|
||||
(Turnier, Start), und Nenngeld fällt „für jeden Start" an.
|
||||
|
||||
### 3.4 Meisterschafts-/Pokalwertung (HTH)
|
||||
|
||||
- **Erwachsene:** ein Gesamtklassement der A- und S-Fahrer/innen (klassenübergreifend)
|
||||
- **Jugend:** Pokalwertung **klassenweise** J 1 – J 5
|
||||
|
||||
**Streichresultate** — nicht alle Läufe zählen:
|
||||
|
||||
| Turniere in der Saison | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| **davon gewertet** | 8 | 8 | 8 | 7 | 6 | 5 | 4 | 3 | 2 |
|
||||
|
||||
Nicht jede Veranstaltung zählt zur Meisterschaft — der Terminkalender markiert Läufe
|
||||
„mit Meisterschaftswertung" bzw. „o. M. = ohne Meisterschaft".
|
||||
|
||||
**Endlaufqualifikation:** die besten **10 Erwachsenen** und **10 Jugendlichen** der
|
||||
HTH-Meisterschaft (Jugend ohne Trennung Damen/Herren, Verteilung prozentual nach
|
||||
Teilnehmerzahl der 5 Jugendklassen). **Bei Punktgleichheit zählt die Majorität der
|
||||
Siege.** Endgültige Festlegung durch den Bereichsleiter ⇒ die Anwendung *schlägt vor*,
|
||||
sie *entscheidet nicht*.
|
||||
|
||||
> ❗ Die **Punktetabelle** (Platz → Meisterschaftspunkte) ist noch nicht bekannt (§10).
|
||||
|
||||
### 3.5 Weitere Regeln aus der Rahmenausschreibung 2026
|
||||
|
||||
- Nenngeld einheitlich **10 €** je Start (S, A, Jugend, Mofa/Moped, E-Bike, Pedelec, E-Scooter)
|
||||
- **Nennschluss:** Klasse S + Jugendklassen 14:00 Uhr, A-Klasse + E-Bike/Pedelec/E-Scooter 15:00 Uhr
|
||||
- Pokalläufe bei Doppelveranstaltungen beginnen generell 09:00 Uhr
|
||||
- Gültiger Jugendausweis + unterschriebener Haftungsausschluss sind bei der Nennung vorzulegen
|
||||
- Eine **nummerierte Nennungsliste** ist zu führen, die Reihenfolge nach Möglichkeit einzuhalten
|
||||
- Für A-Klassen-Fahrer und Gelegenheitsfahrer wird z. B. jede 10. Startnummer freigehalten
|
||||
- Schlussbericht + Ergebnisliste müssen nach FFM gemeldet werden — **der Veranstalter-Zuschuss
|
||||
wird erst überwiesen, wenn beide Dokumente dort sind** ⇒ Export in genau diesen Formaten
|
||||
ist kein „nice to have", sondern zahlungsrelevant
|
||||
|
||||
### 3.6 Zeitmessung per Lichtschranke *(Vorgabe des Auftraggebers)*
|
||||
|
||||
Die API wird von einem **zweiten Raspberry Pi am Parcours** bespielt. Dieser ist an eine
|
||||
**Lichtschranke** angeschlossen, misst die Fahrzeiten, sendet sie an die Anwendung und
|
||||
**zeigt die Daten des aktuellen Fahrzeugs/Starters an**.
|
||||
|
||||
Damit gibt es **zwei Datenquellen pro Ergebnis**:
|
||||
|
||||
| Wert | Quelle | Weg |
|
||||
|---|---|---|
|
||||
| `Summe Zeit` | **Lichtschranke** (Messgerät) | automatisch per API |
|
||||
| `Summe Fehler` | **Wertungspersonal im Parcours** | manuell im internen Frontend |
|
||||
|
||||
Ein Ergebnis ist also erst vollständig, wenn **beide** Seiten geliefert haben. Das
|
||||
Datenmodell muss einen Start mit Zeit, aber ohne Fehlerpunkte (und umgekehrt) sauber
|
||||
abbilden können — ein „halbes" Ergebnis ist der Normalzustand während der Veranstaltung,
|
||||
kein Fehler.
|
||||
|
||||
#### Konsequenzen für den Entwurf
|
||||
|
||||
**a) Rohmessungen statt Summen speichern.**
|
||||
Die Spalte heißt in der Ergebnisliste `Summe Zeit` — die Zeit ist also vermutlich eine
|
||||
**Summe mehrerer Messungen** (mehrere Durchgänge oder mehrere gemessene Abschnitte,
|
||||
z. B. Langsamfahrstrecke und Gesamtdurchfahrt). Die Anwendung speichert deshalb **jede
|
||||
Einzelmessung** (`zeitmessung`) und bildet die Summe selbst. Rückweg gibt es nicht:
|
||||
aus einer gespeicherten Summe lassen sich Einzelmessungen nie wieder herstellen, aus
|
||||
Einzelmessungen die Summe jederzeit. → offene Frage **O-13**
|
||||
|
||||
**b) Das Messgerät ist ein eigenständiger API-Client, kein Teil der Anwendung.**
|
||||
Es bekommt einen **Geräte-Token** (nicht den Auswerter-Login), darf nur Messungen
|
||||
schreiben und Starter-Daten lesen, und wird in der DB als `geraet` geführt. Ein Gerät
|
||||
am offenen Platz muss man notfalls einzeln sperren können.
|
||||
|
||||
**c) Netzausfall ist der Normalfall, nicht die Ausnahme.**
|
||||
Am ADAC-Platz kann das Netz wegbrechen. Der Mess-Pi **muss lokal puffern** (SQLite oder
|
||||
Datei-Queue) und nach Wiederverbindung nachliefern. Deshalb gilt zwingend:
|
||||
- jede Messung bekommt eine **UUID vom Messgerät** → Server ist idempotent, doppelt
|
||||
gesendete Messungen erzeugen keine Dubletten
|
||||
- die Messung trägt **zwei Zeitstempel**: Wanduhr (`gemessen_am`) und **monotone
|
||||
Gerätezeit** (`monotonic_ns`). Die Wanduhr eines Pi ohne Netz und ohne RTC springt
|
||||
beim NTP-Sync — die Dauer zwischen zwei Lichtschranken-Ereignissen darf davon nicht
|
||||
abhängen. **Der Pi berechnet die Dauer selbst und sendet sie**, der Server rechnet
|
||||
keine Differenz aus zwei Wanduhr-Zeitstempeln.
|
||||
- der Server nimmt Messungen **rückwirkend** an (`gemessen_am` in der Vergangenheit)
|
||||
und markiert sie als nachgeliefert
|
||||
|
||||
**d) Zuordnung Messung → Starter ist der kritische Punkt.**
|
||||
Eine Lichtschranke misst eine Zeit, sie weiß nicht, **wer** durchgefahren ist. Die
|
||||
Zuordnung muss irgendwo herkommen — Eingabe der Startnummer am Mess-Pi, eine
|
||||
Warteschlange „nächster Starter" aus der Nennliste, oder ein Transponder/RFID.
|
||||
→ offene Frage **O-14**. Bis zur Klärung wird konservativ geplant:
|
||||
- der Mess-Pi holt sich per API die **Startreihenfolge** des laufenden Turniers
|
||||
- er sendet die Messung **mit** der Startnummer, die er gerade anzeigt
|
||||
- eine Messung **ohne** zuordenbare Startnummer wird nicht verworfen, sondern als
|
||||
`zeitmessung` ohne `start_id` gespeichert und im Frontend zur manuellen Zuordnung
|
||||
angeboten. Eine gemessene Zeit ist nicht reproduzierbar — sie darf nie verloren gehen,
|
||||
nur weil die Zuordnung fehlte.
|
||||
|
||||
**e) Fehlmessungen sind Betriebsrealität.**
|
||||
Zuschauer, Helfer oder ein zurücksetzendes Motorrad lösen die Lichtschranke aus.
|
||||
Nötig sind: **Entprellung/Mindestabstand** zwischen zwei Triggern auf dem Pi, ein
|
||||
**Verwerfen-Knopf** in der Erfassung, sowie Plausibilitätsprüfung serverseitig
|
||||
(eine Fahrzeit von 3 s oder 900 s ist zu markieren, nicht stillschweigend zu übernehmen).
|
||||
Jede verworfene Messung bleibt mit Grund gespeichert.
|
||||
|
||||
**f) Die Anzeige am Parcours ist ein eigener Anwendungsfall.**
|
||||
Der Mess-Pi zeigt Starter- und Fahrzeugdaten an, zieht sie also **lesend** aus der API.
|
||||
Das braucht einen schlanken, schnellen Endpunkt und eine Live-Aktualisierung (SSE),
|
||||
und er muss **auch bei Netzausfall die zuletzt geladene Startliste weiter anzeigen**.
|
||||
|
||||
> ❗ Was genau angezeigt werden soll (Startnummer, Name, Klasse, Fahrzeug, laufende Zeit,
|
||||
> letzte Zeit?) und auf welcher Hardware (HDMI-Monitor, kleines Display, LED-Tafel) ist
|
||||
> noch offen → **O-15**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Datenmodell
|
||||
|
||||
```
|
||||
verein ──┐
|
||||
├──< fahrer ──< start >── turnier >── veranstaltung
|
||||
saison ──┤ │ │ │
|
||||
├──< klasse ───┘ │ └──< wertungsklasse
|
||||
│ ├──< ergebnis (Zeit + Fehler, berechnet)
|
||||
│ └──< zeitmessung >── geraet (Lichtschranke)
|
||||
└──< meisterschaft ──< meisterschaft_stand
|
||||
```
|
||||
|
||||
### Tabellen
|
||||
|
||||
**`verein`** — Regionalclub / Ortsclub
|
||||
`id` · `name` („OAMC Reinheim", „MSC Schotten", „PMS Kassel", „Hainstadt") ·
|
||||
`kurzname` · `adac_gau` · `aktiv`
|
||||
|
||||
**`fahrer`** — Stammdaten, **einmalig**, nicht pro Turnier
|
||||
`id` · `nachname` · `vorname` · `geburtsjahr` · `geburtsdatum` (nullable, für J1-Prüfung
|
||||
„am Veranstaltungstag 7 Jahre") · `geschlecht` (M/W/D) · `verein_id` ·
|
||||
`adac_mitgliedsnr` (nullable) · `jugendausweis_gueltig_bis` · `aktiv` · `notiz` ·
|
||||
`erstellt_am` / `geaendert_am`
|
||||
→ **Dublettenerkennung** beim Import nötig, siehe §6.3
|
||||
|
||||
**`saison`** — Jahr, macht Regeln versionierbar
|
||||
`jahr` (PK) · `turnierordnung_version` · `streichresultate_tabelle` (JSONB) ·
|
||||
`nenngeld_cent` · `aktiv`
|
||||
|
||||
**`klasse`** — Klassendefinition **pro Saison**
|
||||
`id` · `saison_jahr` · `code` („J 1", „S 6", „A 4") · `kategorie` (J/S/A) ·
|
||||
`bezeichnung` · `jahrgaenge` (int[], nur Jugend) · `ccm_max` · `kw_max` ·
|
||||
`fahrzeugtyp` · `sortierung`
|
||||
|
||||
**`veranstaltung`** — der Renntag
|
||||
`id` · `datum` · `titel` · `ort` · `ausrichter_verein_id` · `ist_doppelturnier` ·
|
||||
`veranstaltungsleiter` · `turnierleiter` · `status` (geplant/laufend/vorlaeufig/final)
|
||||
|
||||
**`turnier`** — der einzelne Wertungslauf (bei Doppelturnier zwei pro Veranstaltung)
|
||||
`id` · `veranstaltung_id` · `name` („Reinheim" / „Hainstadt") · `veranstalter_verein_id` ·
|
||||
`zaehlt_meisterschaft` (bool) · `status`
|
||||
|
||||
**`wertungsklasse`** — pro Turnier zusammengefasste Klassen
|
||||
`id` · `turnier_id` · `bezeichnung` („S 5, S 9, S 3, S 6, S 7") ·
|
||||
`klasse_ids` (int[]) · `sortierung`
|
||||
|
||||
**`start`** — eine Teilnahme; ein Fahrer kann mehrere pro Turnier haben
|
||||
`id` · `turnier_id` · `fahrer_id` · `klasse_id` · `wertungsklasse_id` · `startnummer` ·
|
||||
`fahrzeug` (Text) · `nenngeld_bezahlt` · `status` (genannt/gestartet/DNF/DNS/DSQ)
|
||||
→ UNIQUE (`turnier_id`, `startnummer`)
|
||||
|
||||
**`ergebnis`** — 1:1 zum Start, getrennt für saubere Nachträge/Korrekturen
|
||||
`start_id` (PK) · `summe_zeit` (numeric(7,2), **aus `zeitmessung` summiert**) ·
|
||||
`summe_fehler` (int, **manuell erfasst**) · `gesamt` (numeric(9,2), **berechnet**) ·
|
||||
`platzierung` (int, **berechnet**) · `zeit_vollstaendig` (bool) ·
|
||||
`fehler_vollstaendig` (bool) · `erfasst_von` · `erfasst_am` · `korrigiert_am` ·
|
||||
`korrektur_grund`
|
||||
→ Die beiden `*_vollstaendig`-Flags machen sichtbar, welche der zwei Quellen (§3.6)
|
||||
noch fehlt. Gewertet wird erst, wenn beide gesetzt sind.
|
||||
|
||||
**`geraet`** — Messgerät / Anzeige am Parcours
|
||||
`id` · `bezeichnung` („Lichtschranke Ziel", „Anzeige Start") · `typ`
|
||||
(lichtschranke/anzeige) · `token_hash` · `aktiv` · `letzte_meldung_am` ·
|
||||
`firmware_version` · `notiz`
|
||||
→ `letzte_meldung_am` ist der Heartbeat: das Frontend zeigt, ob die Messung noch lebt
|
||||
|
||||
**`zeitmessung`** — Einzelmessung, **nicht** die Summe
|
||||
`id` (UUID, **vom Gerät vergeben** → Idempotenz) · `geraet_id` · `turnier_id` ·
|
||||
`start_id` (**nullable** — unzugeordnete Messungen gehen nicht verloren) ·
|
||||
`startnummer_gemeldet` (int, was das Gerät angezeigt hat) · `messpunkt`
|
||||
(z. B. „Durchgang 1", „Langsamfahrstrecke") · `dauer_sekunden` (numeric(7,2),
|
||||
**vom Gerät berechnet**) · `gemessen_am` (timestamptz, Wanduhr) · `monotonic_ns` (bigint) ·
|
||||
`empfangen_am` · `nachgeliefert` (bool) · `status` (gueltig/verworfen/unzugeordnet) ·
|
||||
`verworfen_grund` · `roh` (JSONB, Rohereignisse der Lichtschranke)
|
||||
→ UNIQUE (`id`); Index auf (`turnier_id`, `status`), (`start_id`)
|
||||
|
||||
**`aufgabe`** / **`ergebnis_aufgabe`** — *optional, Phase 2*: Einzelwertung je Parcours-Aufgabe
|
||||
(Slalom, Wippe, Kreisel …). Die heutigen PDFs enthalten nur Summen; die Struktur
|
||||
vorzusehen kostet jetzt nichts und erlaubt später Detailauswertungen. Sobald die
|
||||
Lichtschranke mehrere Messpunkte bedient, wächst `zeitmessung.messpunkt` natürlich in
|
||||
diese Struktur hinein.
|
||||
|
||||
**`meisterschaft`** — z. B. „HTH Erwachsene 2026", „HTH Jugend J 3 2026"
|
||||
`id` · `saison_jahr` · `bezeichnung` · `kategorie` · `klasse_id` (nullable) · `regelwerk` (JSONB)
|
||||
|
||||
**`meisterschaft_stand`** — materialisiert, nach jedem Turnier neu berechnet
|
||||
`meisterschaft_id` · `fahrer_id` · `punkte_gesamt` · `punkte_je_lauf` (JSONB) ·
|
||||
`gestrichene_laeufe` (JSONB) · `anzahl_siege` (Tie-Break!) · `platz` · `berechnet_am`
|
||||
|
||||
**`import_job`** — Audit-Trail jedes API-/Datei-Imports
|
||||
`id` · `quelle` · `dateiname` · `roh_payload` (JSONB) · `status` · `fehler` ·
|
||||
`angelegt_von` · `angelegt_am`
|
||||
|
||||
**`benutzer`** / **`rolle`** — Admin, Auswerter, Lesend
|
||||
|
||||
### Kernprinzipien
|
||||
|
||||
- `gesamt` und `platzierung` werden **von der Anwendung berechnet**, nicht importiert —
|
||||
importierte Werte werden gegen die Berechnung **geprüft** und Abweichungen gemeldet
|
||||
(fängt Excel-Fehler in Altdaten und Regelmissverständnisse auf beiden Seiten)
|
||||
- Alles **saisonversioniert**: Klassen, Jahrgänge, Streichresultate, Punktetabelle.
|
||||
Ergebnisse aus 2019 müssen 2030 noch korrekt reproduzierbar sein.
|
||||
- Ergebnisse sind **append-only mit Korrektur-Historie**, kein stilles UPDATE
|
||||
|
||||
---
|
||||
|
||||
## 5. Architektur & Technologie
|
||||
|
||||
### 5.1 Vorhandene Umgebung (geprüft auf diesem Gerät)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Hardware | Raspberry Pi 4 Model B Rev 1.5, 8 GB RAM, 117 GB Speicher (94 GB frei) |
|
||||
| OS | Debian 13 (trixie), Linux 6.18 aarch64 |
|
||||
| **PostgreSQL 17.10** | installiert **und aktiv** ✅ |
|
||||
| **nginx** | installiert **und aktiv** ✅ |
|
||||
| **Python 3.13.5** + pip 25.1.1 | ✅ |
|
||||
| Node.js / npm | ❌ nicht installiert |
|
||||
| Docker | ❌ nicht installiert |
|
||||
| git | ❌ **nicht installiert** — vor Projektstart nachinstallieren |
|
||||
|
||||
### 5.2 Empfohlener Stack
|
||||
|
||||
Der Stack folgt dem, was **schon läuft** — auf einem Pi 4 zählt jedes vermiedene
|
||||
Laufzeit-Ökosystem.
|
||||
|
||||
| Schicht | Wahl | Begründung |
|
||||
|---|---|---|
|
||||
| Datenbank | **PostgreSQL 17** | läuft bereits; JSONB für Regelwerke, saubere Constraints, echte Transaktionen |
|
||||
| Backend | **Python 3.13 + FastAPI** | Python ist da; FastAPI liefert die API-Doku (OpenAPI/Swagger) automatisch mit — wichtig, weil Dritte die API bespielen sollen |
|
||||
| ORM / Migration | **SQLAlchemy 2 + Alembic** | Schemaänderungen über Jahre nachvollziehbar |
|
||||
| Validierung | **Pydantic v2** | ein Schema für API-Validierung *und* Doku |
|
||||
| Templates | **Jinja2**, serverseitig gerendert | Ergebnislisten sind Dokumente, keine App. Kein Build-Schritt, kein Node, sofort suchmaschinenlesbar |
|
||||
| Interaktivität | **HTMX** (~14 kB, per CDN oder lokal) | Filter/Sortierung/Live-Nachladen ohne SPA-Apparat |
|
||||
| Live-Updates | **SSE** (Server-Sent Events), von FastAPI direkt unterstützt | Anzeige am Parcours und Live-Ergebnisse; einseitiger Datenfluss, kein WebSocket-Zustand nötig, reconnectet von selbst |
|
||||
| CSS | handgeschrieben oder **Pico.css** | keine Build-Pipeline |
|
||||
| Excel/CSV-Import | **openpyxl** + `csv` | die Nennlisten sind `.xlsx` |
|
||||
| PDF-Export | **WeasyPrint** | Schlussbericht + Ergebnisliste müssen nach FFM |
|
||||
| Server | **uvicorn** hinter dem vorhandenen **nginx** | nginx macht TLS und liefert Statisches |
|
||||
| Prozess | **systemd-Unit** | kein Docker nötig |
|
||||
| TLS | **Let's Encrypt / certbot** | |
|
||||
|
||||
**Bewusst nicht gewählt:** React/Vue-SPA (Build-Kette auf dem Pi, kein SEO, viel Aufwand
|
||||
für Tabellenanzeige) · Docker (Overhead ohne Nutzen bei einer App) · SQLite
|
||||
(Mehrbenutzerbetrieb am Turniertag, Postgres ist schon da)
|
||||
|
||||
### 5.3 Betrieb
|
||||
|
||||
```
|
||||
╔═══ Am Parcours ═══════════════╗
|
||||
║ Pi #2 „Mess-Pi" ║
|
||||
║ Lichtschranke → GPIO ║
|
||||
║ lokale Queue (SQLite) ║──┐ POST Messungen (idempotent, gepuffert)
|
||||
║ Anzeige Starter/Fahrzeug ║<─┼─ GET Startliste + SSE Live
|
||||
╚═══════════════════════════════╝ │
|
||||
│
|
||||
Internet / LAN ──> nginx (TLS, statisch) ┘
|
||||
└→ uvicorn/FastAPI (systemd) → PostgreSQL 17 (lokal)
|
||||
→ /var/oamc/uploads (PDFs, Bilder)
|
||||
```
|
||||
|
||||
- **Backup:** täglicher `pg_dump` + wöchentliche Vollsicherung auf externes Medium.
|
||||
Auf einer SD-Karte/SSD im Pi ist das nicht optional.
|
||||
- **Offline-Fähigkeit am Turniertag** ist durch die Zeitmessung **Pflicht geworden**, nicht
|
||||
mehr optional (§3.6c). Zwei Ebenen:
|
||||
1. der **Mess-Pi** puffert lokal und liefert nach — deckt kurze Aussetzer ab
|
||||
2. bei Veranstaltungen **ohne Internet** muss der Anwendungs-Pi **mit vor Ort** und
|
||||
beide Geräte hängen an einem lokalen Netz (Router/Hotspot ohne Uplink). Die
|
||||
Synchronisation zum öffentlichen Server erfolgt danach.
|
||||
⇒ Entscheidung dazu offen, siehe **O-7**.
|
||||
- **Zeitserver:** ohne Internet gibt es kein NTP. Der Anwendungs-Pi sollte im lokalen
|
||||
Netz selbst NTP anbieten, damit die Zeitstempel zusammenpassen. Für die *Messung*
|
||||
ist das unkritisch (der Mess-Pi rechnet monoton, §3.6c), für die Protokollierung nicht.
|
||||
|
||||
### 5.4 Software auf dem Mess-Pi
|
||||
|
||||
Bewusst klein und eigenständig — das Gerät muss auch dann sinnvoll weiterlaufen, wenn
|
||||
der Server nicht erreichbar ist.
|
||||
|
||||
| Teil | Wahl |
|
||||
|---|---|
|
||||
| GPIO / Lichtschranke | `gpiozero` mit `pigpio`- oder `lgpio`-Backend; Trigger als **Interrupt**, nicht Polling |
|
||||
| Zeitmessung | `time.monotonic_ns()`, Entprellung/Mindestabstand konfigurierbar |
|
||||
| Lokale Queue | SQLite, Einträge bleiben bis vom Server quittiert |
|
||||
| Versand | HTTP + Geräte-Token, Retry mit Backoff, Batch-Nachlieferung |
|
||||
| Anzeige | Browser im Kiosk-Modus auf die Anzeige-Seite der Anwendung (§7), Daten per SSE, letzte Startliste im `localStorage` als Offline-Rückfall |
|
||||
| Betrieb | systemd-Unit, `WatchdogSec`, Autostart, read-only Root-FS erwägen (Stromausfall am Platz) |
|
||||
|
||||
> Für saubere Zeiten unter Last ist ein Interrupt-basierter Trigger Pflicht. Ob die
|
||||
> Genauigkeit eines Linux-Userspace-Prozesses reicht, hängt von der geforderten
|
||||
> Auflösung ab → **O-16**. Die Ergebnislisten führen **1/100 s** (z. B. `159,16`);
|
||||
> das ist mit `pigpio` gut erreichbar, mit Polling in Python nicht zuverlässig.
|
||||
|
||||
---
|
||||
|
||||
## 6. API
|
||||
|
||||
Zwei Richtungen: **Ingest** (Daten kommen rein) und **Read** (Daten gehen raus).
|
||||
|
||||
### 6.1 Ingest — `POST /api/v1/…` (authentifiziert, API-Key)
|
||||
|
||||
| Endpunkt | Zweck |
|
||||
|---|---|
|
||||
| `POST /api/v1/veranstaltungen` | Renntag anlegen |
|
||||
| `POST /api/v1/turniere` | Wertungslauf zum Renntag |
|
||||
| `POST /api/v1/fahrer` · `PUT /api/v1/fahrer/{id}` | Stammdaten |
|
||||
| `POST /api/v1/turniere/{id}/starts` | Nennliste (Bulk) |
|
||||
| `POST /api/v1/turniere/{id}/ergebnisse` | **Ergebnis-Bulk-Upload** ← Kernfall |
|
||||
| `POST /api/v1/import/xlsx` | Excel-Nennliste/Ergebnisliste als Datei |
|
||||
| `POST /api/v1/turniere/{id}/finalisieren` | Wertung rechnen, Meisterschaft fortschreiben |
|
||||
|
||||
Beispiel-Payload Ergebnis-Upload:
|
||||
|
||||
```json
|
||||
{
|
||||
"quelle": "excel-auswertung-v3",
|
||||
"idempotenz_schluessel": "2026-05-31-reinheim-final",
|
||||
"ergebnisse": [
|
||||
{ "startnummer": 32, "fahrer": { "nachname": "Gál-Szász", "vorname": "Péter",
|
||||
"geburtsjahr": 2015, "geschlecht": "M", "verein": "OAMC Reinheim" },
|
||||
"klasse": "J 2", "summe_zeit": 159.16, "summe_fehler": 10,
|
||||
"gesamt_erwartet": 10, "platzierung_erwartet": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Regeln für den Ingest:**
|
||||
|
||||
- **Idempotent** über `idempotenz_schluessel` — ein doppelt gesendeter Upload legt keine
|
||||
Dubletten an. Am Turniertag mit wackligem Netz ist das Pflicht, nicht Komfort.
|
||||
- **Zweistufig:** `?dry_run=true` liefert eine Validierungsvorschau (welche Fahrer sind
|
||||
neu, welche werden gematcht, welche Werte weichen ab) — erst danach wird geschrieben.
|
||||
- **`gesamt_erwartet` / `platzierung_erwartet`** sind optional; wenn geliefert, rechnet
|
||||
der Server nach und meldet Abweichungen als Warnung, statt sie zu übernehmen.
|
||||
- Jeder Aufruf landet in `import_job` mit Roh-Payload — bei Streit über eine Platzierung
|
||||
ist rekonstruierbar, was wann ankam.
|
||||
|
||||
### 6.2 Geräte-API — Zeitmessung & Anzeige *(Mess-Pi, Token-Auth)*
|
||||
|
||||
Eigener Namensraum mit eigenem Rechteprofil: schreiben **nur** Messungen, lesen **nur**
|
||||
das, was die Anzeige braucht. Kompromittierung des Geräts am offenen Platz darf keine
|
||||
Ergebnisse manipulieren können.
|
||||
|
||||
| Endpunkt | Zweck |
|
||||
|---|---|
|
||||
| `POST /api/v1/geraete/heartbeat` | „ich lebe" + Firmware/Queue-Länge → Statusanzeige im Frontend |
|
||||
| `GET /api/v1/geraete/konfiguration` | Entprellzeit, Messpunkte, aktives Turnier — zentral gepflegt statt auf dem Pi |
|
||||
| `GET /api/v1/turniere/aktiv/startliste` | Startreihenfolge für die Anzeige, ETag-fähig, offlinefest zwischenspeicherbar |
|
||||
| `GET /api/v1/turniere/aktiv/anzeige` (SSE) | Live-Stream: aktueller Starter, letzte Zeit |
|
||||
| `POST /api/v1/zeitmessungen` | **Messungen (Batch, idempotent)** ← Kernfall |
|
||||
| `POST /api/v1/zeitmessungen/{id}/verwerfen` | Fehlmessung markieren |
|
||||
|
||||
Beispiel-Payload der Zeitmessung — bewusst als **Batch**, weil der Pi nach einem
|
||||
Netzausfall mehrere Messungen auf einmal nachliefert:
|
||||
|
||||
```json
|
||||
{
|
||||
"geraet": "lichtschranke-ziel-01",
|
||||
"messungen": [
|
||||
{
|
||||
"id": "9f2c1e7a-4d38-4b6e-9a11-5c0b2d7e8f30",
|
||||
"turnier_id": 42,
|
||||
"startnummer_gemeldet": 32,
|
||||
"messpunkt": "durchgang-1",
|
||||
"dauer_sekunden": 159.16,
|
||||
"gemessen_am": "2026-05-31T10:14:02.482+02:00",
|
||||
"monotonic_ns": 884213771004,
|
||||
"roh": { "trigger_start_ns": 884054611004, "trigger_ziel_ns": 884213771004 }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Antwort quittiert **jede Messung einzeln** (`angenommen` / `duplikat` / `unzugeordnet` /
|
||||
`unplausibel`) — erst eine Quittung löscht den Eintrag aus der lokalen Queue des Pi.
|
||||
|
||||
**Festlegungen:**
|
||||
- Die **`id` vergibt das Gerät** (UUIDv4/v7). Nur so ist Nachliefern nach Timeout ohne
|
||||
Dubletten möglich — der Pi weiß nach einem abgebrochenen Request nicht, ob der Server
|
||||
ihn verarbeitet hat, und darf ihn deshalb gefahrlos wiederholen.
|
||||
- Die **Dauer kommt fertig vom Gerät**, der Server rechnet keine Differenz aus Wanduhr-
|
||||
Zeitstempeln (Begründung §3.6c).
|
||||
- `startnummer_gemeldet` ist ein **Hinweis, kein Fremdschlüssel**. Der Server löst sie
|
||||
gegen die Startliste auf; scheitert das, wird die Messung als `unzugeordnet`
|
||||
gespeichert und im Frontend zur Zuordnung angeboten — **nie verworfen**.
|
||||
- Plausibilitätsgrenzen (min/max Fahrzeit je Klasse) sind konfigurierbar; Verstöße
|
||||
werden **markiert**, nicht abgelehnt.
|
||||
- Der Geräte-Token ist **nicht** der Auswerter-Login und einzeln sperrbar.
|
||||
|
||||
### 6.3 Read — `GET /api/v1/…` (öffentlich, ohne Key)
|
||||
|
||||
```
|
||||
GET /api/v1/veranstaltungen?saison=2026
|
||||
GET /api/v1/turniere/{id}/ergebnisse?wertungsklasse=J3
|
||||
GET /api/v1/fahrer?q=bernius&verein=OAMC+Reinheim
|
||||
GET /api/v1/fahrer/{id}/historie
|
||||
GET /api/v1/meisterschaften/{id}/stand
|
||||
GET /api/v1/meisterschaften/{id}/endlauf-qualifikation
|
||||
```
|
||||
|
||||
Zusätzlich **iCal** (`/termine.ics`) für den Terminkalender und **CSV-Export** je Liste.
|
||||
|
||||
### 6.4 Fahrer-Matching beim Import
|
||||
|
||||
Der heikelste Teil des Imports. Aus den Altdaten sichtbar: Namen mit Sonderzeichen
|
||||
(*Gál-Szász*), Vereinsangaben mal als „OAMC Reinheim", mal als „Hainstadt" (Ortsname
|
||||
statt Clubname), Namensgleichheiten in Familien (*Gál-Szász Péter* / *Máté* / *János*;
|
||||
*Bernius Meik* / *Jörg*; *Krack Robin* / *Luca*; *Tinz Xenia* / *Enrique*).
|
||||
|
||||
Vorgehen:
|
||||
1. Exakter Match auf (Nachname, Vorname, Geburtsjahr) — normalisiert (Unicode NFKD,
|
||||
Kleinschreibung, Bindestriche)
|
||||
2. Unscharfer Match (Levenshtein / `pg_trgm`) → **Vorschlag**, nie automatisch
|
||||
3. Kein Match → neuer Fahrer wird angelegt, aber im Import-Report als „NEU" markiert
|
||||
4. **Merge-Funktion** im Admin, um nachträglich erkannte Dubletten zusammenzuführen
|
||||
|
||||
---
|
||||
|
||||
## 7. Seiten (Frontend)
|
||||
|
||||
**Öffentlich:**
|
||||
|
||||
| Seite | Inhalt |
|
||||
|---|---|
|
||||
| Startseite Motorrad-Turnier | nächster Termin, letzte Ergebnisse, aktueller Meisterschaftsstand |
|
||||
| **Termine** | Saisonkalender, mit/ohne Meisterschaftswertung, Ausrichter, iCal-Abo |
|
||||
| **Ergebnisse** | je Veranstaltung → Turnier → Wertungsklasse, sortierbar, mobil lesbar |
|
||||
| **Live** (Turniertag) | vorläufiger Stand, sobald Zeiten eintreffen — deutlich als *vorläufig* gekennzeichnet, solange Fehlerpunkte fehlen (§3.6) |
|
||||
| **Fahrer** | durchsuchbare Liste (löst die „BAUSTELLE" ab) |
|
||||
| **Fahrerprofil** | Stammdaten, alle Starts, Platzierungen, Bestzeiten, Saisonverlauf |
|
||||
| **Meisterschaft** | HTH-Stand Erwachsene + Jugend J 1–J 5, inkl. Streichresultat-Kennzeichnung |
|
||||
| **Endlauf-Qualifikation** | aktueller Stand der Top-10-Plätze |
|
||||
| Sportbeschreibung / Ausschreibung / Downloads | bestehende Inhalte, sauber übernommen |
|
||||
|
||||
**Intern (Login):**
|
||||
|
||||
| Seite | Inhalt |
|
||||
|---|---|
|
||||
| Nennungsannahme | Fahrer suchen/anlegen, Klasse automatisch aus Jahrgang vorschlagen, Startnummer vergeben, Nenngeld quittieren |
|
||||
| Ergebniserfassung | schnelle Eingabe Zeit/Fehler je Startnummer, tastaturoptimiert |
|
||||
| Auswertung | Wertungsklassen bilden, rechnen, vorläufig/final setzen |
|
||||
| Korrekturen | mit Begründung, mit Historie |
|
||||
| Exporte | Ergebnisliste (PDF, ADAC-Layout), Schlussbericht, Nennliste, CSV |
|
||||
| **Messungen zuordnen** | unzugeordnete/unplausible Lichtschranken-Messungen einem Start zuweisen oder mit Grund verwerfen |
|
||||
| **Geräte** | Mess-Pi-Status (Heartbeat, Queue-Länge), Token vergeben/sperren, Entprellzeit und Messpunkte konfigurieren |
|
||||
| Import-Monitor | Import-Jobs, Warnungen, Fahrer-Dubletten mergen |
|
||||
| Stammdaten | Saison, Klassen, Vereine, Punktetabelle, Benutzer |
|
||||
|
||||
**Anzeige am Parcours** (eigener, minimaler Modus für den Mess-Pi):
|
||||
|
||||
| Seite | Inhalt |
|
||||
|---|---|
|
||||
| `/anzeige` | aktueller Starter groß (Nr., Name, Klasse, Fahrzeug), laufende/letzte Zeit, nächste Starter; SSE-Update; Kiosk-tauglich; **behält bei Verbindungsverlust die letzte Startliste** und zeigt den Offline-Zustand deutlich an |
|
||||
|
||||
**Anforderungen quer:** responsiv (am Platz wird das Handy benutzt) · Deep-Links auf jede
|
||||
Ergebnisliste · druckbare Ansichten · Aushang-/Beamer-Modus (große Schrift,
|
||||
Autorefresh) · barrierearm (Kontrast, Tabellen-Semantik)
|
||||
|
||||
---
|
||||
|
||||
## 8. Arbeitspakete
|
||||
|
||||
### AP 0 — Klärung & Setup *(vor allem anderen)*
|
||||
- Offene Fragen §10 mit dem Verein / Bereichsleiter klären
|
||||
- **ADAC-Turnierordnung 2026 (`MT-D2025.pdf`) beschaffen und Wertungsregeln verifizieren**
|
||||
- Punktetabelle Meisterschaft beschaffen
|
||||
- `git` installieren, Repository anlegen, Python-venv, Projektgerüst
|
||||
- Zugriff auf die Excel-Vorlagen und ein paar echte Nennlisten
|
||||
|
||||
### AP 1 — Datenmodell & Migrationen
|
||||
- SQLAlchemy-Modelle + Alembic-Initialmigration
|
||||
- Stammdaten-Seed: Saison 2026, Klassen J 1–J 5, S/A-Klassen, Vereine
|
||||
- Unit-Tests auf Constraints (Mehrfachstart erlaubt, Startnummer eindeutig je Turnier)
|
||||
|
||||
### AP 2 — Wertungs-Engine *(der Kern — zuerst und mit Tests)*
|
||||
- Regelstrategien je Kategorie (Jugend: Fehler → Zeit; Erwachsene: Zeit + Fehler)
|
||||
- Platzierung inkl. Gleichstandsauflösung, DNF/DNS/DSQ-Behandlung
|
||||
- **Regressionstest gegen die reale Ergebnisliste vom 31.05.2026** — alle 13 Wertungs-
|
||||
blöcke müssen exakt die publizierten Platzierungen reproduzieren. Das ist der
|
||||
Abnahmetest für die Engine.
|
||||
|
||||
### AP 3 — Meisterschaftsberechnung
|
||||
- Punktevergabe, Streichresultate nach Tabelle §3.4
|
||||
- Tie-Break „Majorität der Siege"
|
||||
- Endlauf-Qualifikationsvorschlag Top 10 / Top 10 Jugend
|
||||
- Nachrechnen gegen einen veröffentlichten HTH-Jahresstand als Test
|
||||
|
||||
### AP 4 — API
|
||||
- Read-Endpunkte + OpenAPI-Doku
|
||||
- Ingest-Endpunkte, Idempotenz, `dry_run`, API-Key-Auth, Rate-Limit
|
||||
- Import-Job-Protokollierung
|
||||
- Fahrer-Matching inkl. Merge
|
||||
|
||||
### AP 5 — Zeitmessung: Geräte-API & Mess-Pi
|
||||
- Geräte-Registrierung, Token, Heartbeat, zentrale Konfiguration
|
||||
- `POST /zeitmessungen` mit Batch, Idempotenz, Einzelquittung, Plausibilitätsprüfung
|
||||
- Auflösung `startnummer_gemeldet` → Start; unzugeordnete Messungen im Frontend behandeln
|
||||
- **Client auf dem Mess-Pi:** GPIO-Trigger, Entprellung, monotone Zeitmessung, lokale
|
||||
SQLite-Queue, Retry mit Backoff, Kiosk-Anzeige mit Offline-Rückfall
|
||||
- SSE-Endpunkt und `/anzeige`-Seite
|
||||
- **Abnahmetest:** Netzstecker während der Messung ziehen — nach Wiederverbindung müssen
|
||||
alle Messungen genau einmal in der DB stehen. Ebenso: Batch doppelt senden → keine Dubletten.
|
||||
- **Kalibrierung:** gemessene Zeiten gegen eine Handstoppung/Referenz vergleichen, bevor
|
||||
produktiv damit gewertet wird
|
||||
|
||||
### AP 6 — Öffentliches Frontend
|
||||
- Layout, Navigation, responsive Tabellen
|
||||
- Termine, Ergebnisse, Fahrerliste, Fahrerprofil, Meisterschaft
|
||||
- iCal, CSV, Druckansichten
|
||||
|
||||
### AP 7 — Internes Frontend
|
||||
- Login/Rollen, Nennungsannahme, Ergebniserfassung, Auswertung, Korrekturen
|
||||
- Exporte (PDF-Ergebnisliste im ADAC-Layout, Schlussbericht)
|
||||
|
||||
### AP 8 — Datenübernahme Altbestand
|
||||
- Import der PDF-Ergebnisse ab einem zu definierenden Jahr
|
||||
(⚠️ die PDFs nutzen Subset-Fonts mit eigener Kodierung — reine Textextraktion schlägt
|
||||
bei den Meisterschafts-PDFs fehl; realistisch ist ein **halbautomatischer** Import mit
|
||||
manueller Nacharbeit, ggf. direkt aus den Excel-Originalen statt aus den PDFs)
|
||||
- Vereinheitlichung Vereinsnamen, Dublettenbereinigung Fahrer
|
||||
|
||||
### AP 9 — Deployment & Betrieb
|
||||
- systemd-Unit, nginx-vHost, TLS
|
||||
- Backup-Automatik + **getesteter Restore**
|
||||
- Monitoring, Logrotation
|
||||
- Einweisung der Auswerter, Kurzanleitung
|
||||
|
||||
### AP 10 — Pilotbetrieb
|
||||
- Eine Veranstaltung **parallel** zu Excel auswerten und Ergebnisse vergleichen
|
||||
- **Zeitmessung zunächst nur mitlaufen lassen**, nicht werten: Lichtschranken-Zeit gegen
|
||||
die manuell gestoppte Zeit stellen. Erst wenn beide über eine ganze Veranstaltung
|
||||
übereinstimmen, wird die Messung wertungsrelevant.
|
||||
- Erst nach fehlerfreiem Parallellauf umstellen
|
||||
|
||||
---
|
||||
|
||||
## 9. Ausbaustufen nach dem Kern
|
||||
|
||||
- **Online-Voranmeldung** — die Rahmenausschreibung wünscht ausdrücklich Voranmeldung
|
||||
größerer Teilnehmergruppen und eine Anmeldeliste; das ist die naheliegendste Erweiterung
|
||||
- **Aufgaben-Einzelwertung** (Tabellen aus §4 bereits vorgesehen) → „woran verliere ich Zeit?"
|
||||
- Bildarchiv mit Verknüpfung Foto ↔ Fahrer ↔ Veranstaltung
|
||||
- Automatische Meldung von Schlussbericht/Ergebnisliste an den ADAC
|
||||
- Mehrmandantenfähigkeit für andere Ortsclubs (MSC Schotten, MSC Hainstadt, PMS Kassel …)
|
||||
- Übernahme der übrigen oamc.de-Rubriken aus dem Frameset
|
||||
|
||||
---
|
||||
|
||||
## 10. Offene Fragen
|
||||
|
||||
| # | Frage | Warum blockierend |
|
||||
|---|---|---|
|
||||
| **O-1** | **Punktetabelle der Meisterschaft** (Platz → Punkte) | AP 3 nicht baubar |
|
||||
| **O-2** | Zuordnung `S 1`–`S 9` und `A 1`–`A 4` → Fahrzeugtyp/Hubraum/kW | Stammdaten-Seed |
|
||||
| **O-3** | Zählen **A-Fahrer** zur Meisterschaft? Die Rahmenausschreibung sagt „Gesamtklassement der A- und S-Fahrer/innen", die Spalte `Kategorie` ist bei A-Fahrern in der Ergebnisliste jedoch **leer** (bei S steht „S", bei Jugend „J") | AP 3 |
|
||||
| **O-4** | Gilt die Jugend-Wertung (nur Fehler) auch beim Bundesendlauf, oder dort Zeit+Fehler? | AP 2 |
|
||||
| **O-5** | Behandlung von DNF / DNS / Disqualifikation in Tages- und Jahreswertung | AP 2/3 |
|
||||
| ~~O-6~~ | ~~Wer soll die API bespielen?~~ → **beantwortet:** ein zweiter Raspberry Pi an der Lichtschranke, der Zeiten sendet und Starter-/Fahrzeugdaten anzeigt (§3.6) | erledigt |
|
||||
| **O-7** | **Ist am ADAC-Platz ein verlässliches Netz?** Wenn nein: muss der Anwendungs-Pi mit vor Ort? WLAN, LTE oder Kabel zwischen Mess-Pi und Server? | **hoch** — bestimmt die gesamte Betriebsarchitektur (§5.3) |
|
||||
| **O-8** | Ab welchem Jahr sollen Altdaten übernommen werden (PDFs reichen bis 2002 zurück)? | Aufwand AP 8 |
|
||||
| **O-9** | Wo wird gehostet — dieser Raspberry Pi, ein Server beim Verein, Webhosting? | AP 9, Erreichbarkeit |
|
||||
| **O-10** | Verhältnis zur bestehenden oamc.de: Unterseite, Subdomain (z. B. `mt.oamc.de`) oder Ablösung? | Navigation, DNS |
|
||||
| **O-11** | Wer bekommt Schreibrechte, wie viele Auswerter, gemeinsamer Account oder je Person? | AP 7 |
|
||||
| **O-12** | Liegen die Excel-Originale der Altveranstaltungen noch vor? | macht AP 8 um ein Vielfaches einfacher |
|
||||
|
||||
### Neue Fragen zur Zeitmessung
|
||||
|
||||
| # | Frage | Warum blockierend |
|
||||
|---|---|---|
|
||||
| **O-13** | Was misst die Lichtschranke genau? **Eine** Gesamtdurchfahrt, **mehrere Durchgänge** oder einzelne Abschnitte (Langsamfahrstrecke …)? Woraus setzt sich `Summe Zeit` zusammen? | bestimmt `zeitmessung.messpunkt` und die Summenbildung; AP 5 |
|
||||
| **O-14** | **Woher weiß der Mess-Pi, wer gerade fährt?** Startnummer manuell eintippen, Warteschlange „nächster Starter" aus der Nennliste, Transponder/RFID? | kritischster Punkt der Zuordnung; AP 5 |
|
||||
| **O-15** | Was genau soll die **Anzeige** zeigen und auf welcher Hardware (HDMI-Monitor, kleines Display, LED-Tafel)? Für wen — Fahrer, Publikum, Wertungspersonal? | Gestaltung `/anzeige`; AP 5 |
|
||||
| **O-16** | Geforderte **Messgenauigkeit**? Die Listen führen 1/100 s. Reicht Userspace-GPIO oder braucht es dedizierte Hardware? Ein oder zwei Lichtschranken (Start **und** Ziel)? | Hardware-Auswahl, AP 5 |
|
||||
| **O-17** | **Wie kommen die Fehlerpunkte in die Anwendung** — Zettel vom Wertungspersonal und zentrale Eingabe, oder Tablets im Parcours? | AP 7; Phase 2 könnte hier ansetzen |
|
||||
| **O-18** | Löst die Lichtschranke **automatisch** aus (Fahrzeug unterbricht Strahl) oder gibt es zusätzlich einen manuellen Start-/Stopp-Taster als Rückfall? | Ausfallkonzept, AP 5 |
|
||||
| **O-19** | Wer baut/betreut die **Hardware** (Lichtschranke, Verkabelung, Stromversorgung am Platz)? Existiert sie schon oder wird sie beschafft? | Terminplanung |
|
||||
|
||||
---
|
||||
|
||||
## 11. Datenschutz
|
||||
|
||||
**Das ist hier kein Formalpunkt.** Es werden **Namen, Geburtsjahre, Geschlecht und
|
||||
Vereinszugehörigkeit von Kindern ab 7 Jahren** verarbeitet und **öffentlich im Internet
|
||||
angezeigt**. Die heutige Praxis (Namen in öffentlichen PDFs) wird durch eine
|
||||
durchsuchbare Datenbank mit Personenprofilen **qualitativ verändert** — aus einer
|
||||
schwer auffindbaren PDF-Liste wird ein über Jahre verknüpftes Profil eines Minderjährigen.
|
||||
Das ist datenschutzrechtlich eine andere Kategorie und muss vor dem Livegang geklärt sein.
|
||||
|
||||
Zu klären und umzusetzen:
|
||||
|
||||
- **Rechtsgrundlage** je Verarbeitung; Einwilligung der Erziehungsberechtigten für die
|
||||
Veröffentlichung von Jugenddaten (idealerweise in Nennformular/Haftungsausschluss
|
||||
aufnehmen — die Formulare werden ohnehin unterschrieben)
|
||||
- **Datenminimierung öffentlich:** Geburts*jahr* statt Geburtsdatum, kein Wohnort,
|
||||
keine Kontaktdaten, keine ADAC-Mitgliedsnummer
|
||||
- **`robots.txt` / `noindex`** für Fahrerprofile — Ergebnislisten dürfen auffindbar sein,
|
||||
Personenprofile von Minderjährigen sollten es nicht sein
|
||||
- **Opt-out**: Anzeige als „Teilnehmer 12" oder Namenskürzel auf Wunsch
|
||||
- **Löschkonzept / Aufbewahrungsfristen**, Auskunfts- und Berichtigungsrecht
|
||||
- **Verzeichnis von Verarbeitungstätigkeiten**, TOMs (Backup-Verschlüsselung, TLS,
|
||||
Zugriffsrollen, Protokollierung)
|
||||
- Ggf. **Auftragsverarbeitung** mit dem Hoster
|
||||
|
||||
> Empfehlung: Vor AP 6 (öffentliches Frontend) eine Entscheidung des Vorstands
|
||||
> herbeiführen, wie Jugenddaten öffentlich dargestellt werden. Die technische Umsetzung
|
||||
> ist einfach — die Entscheidung ist es nicht, und sie nachträglich zu ändern bedeutet,
|
||||
> bereits indexierte Seiten wieder einzufangen.
|
||||
|
||||
---
|
||||
|
||||
## 12. Risiken
|
||||
|
||||
| Risiko | Auswirkung | Gegenmaßnahme |
|
||||
|---|---|---|
|
||||
| Wertungsregeln in §3.3 sind aus PDFs **abgeleitet**, nicht bestätigt | falsche Platzierungen, Vertrauensverlust | AP 0: Turnierordnung beschaffen; AP 2: Regressionstest gegen echte Listen; AP 10: Parallellauf |
|
||||
| Ausfall am Turniertag (Pi, Netz, Strom) | Veranstaltung blockiert | Excel-Fallback bereithalten, bis der Parallellauf sauber ist; Export vor Ort möglich halten |
|
||||
| **Lichtschranke fällt aus oder misst falsch** | Zeiten des laufenden Turniers fehlen — **nicht nachholbar**, die Fahrt ist vorbei | manuelle Zeitnahme als Rückfall **immer** parallel bereithalten; manuelle Nacherfassung im Frontend; Plausibilitätsprüfung serverseitig; AP 10 misst zunächst nur mit |
|
||||
| **Fehlauslösung** durch Zuschauer/Helfer/rangierendes Motorrad | falsche Zeit im Ergebnis | Entprellung auf dem Pi, Verwerfen-Funktion mit Grund, Plausibilitätsgrenzen, Vier-Augen-Prinzip vor „final" |
|
||||
| **Messung dem falschen Fahrer zugeordnet** | zwei Ergebnisse gleichzeitig falsch, schwer zu bemerken | Zuordnung (O-14) früh und sauber klären; Anzeige am Parcours macht die Zuordnung für alle sichtbar → Fehler fällt sofort auf |
|
||||
| Netzausfall zwischen Mess-Pi und Server | Messungen gehen verloren | lokale Queue + Idempotenz (§3.6c); Abnahmetest in AP 5 zieht bewusst den Netzstecker |
|
||||
| Altdaten-Import aus PDFs scheitert an Font-Kodierung | AP 8 wird teuer | Excel-Originale beschaffen (O-12); Import zeitlich begrenzen; notfalls Start ab 2026 |
|
||||
| Fahrer-Dubletten durch Namensvarianten | falsche Meisterschaftsstände | Matching + Merge-Werkzeug in AP 4, Import-Report mit „NEU"-Markierung |
|
||||
| Datenschutzthema Jugenddaten wird spät geklärt | Livegang verzögert oder Rückbau | §11 vor AP 6 entscheiden |
|
||||
| Projekt hängt an einer Person | keine Wartung | Doku, sauberes Repo, zweite eingewiesene Person |
|
||||
| Raspberry Pi: SD-Karte/Speicherausfall | Datenverlust | Backup extern + Restore testen, SSD statt SD-Karte erwägen; auf dem Mess-Pi read-only Root-FS erwägen |
|
||||
| Scope-Ausweitung (Online-Nennung, Bildarchiv …) | Kern wird nie fertig | §9 ist bewusst *nach* dem Kern; AP 10 ist der Zielpunkt |
|
||||
|
||||
---
|
||||
|
||||
## 13. Nächster Schritt
|
||||
|
||||
1. Diesen Plan im Verein durchgehen, insbesondere **§10 (offene Fragen)** und
|
||||
**§11 (Datenschutz)**
|
||||
2. **ADAC-Turnierordnung 2026 und Punktetabelle beschaffen** — ohne die stehen AP 2 und AP 3
|
||||
3. **Zeitmessung klären: O-13 (was wird gemessen), O-14 (Zuordnung zum Fahrer),
|
||||
O-16 (Genauigkeit/Hardware), O-19 (wer baut sie)** — davon hängt AP 5 komplett ab
|
||||
4. Entscheidung zu **O-7** (Netz am Platz) und **O-9/O-10** (Hosting, Domain)
|
||||
5. Danach AP 0 → AP 1 → AP 2
|
||||
|
||||
> **Reihenfolge-Hinweis:** Die Zeitmessung ist das sichtbarste, aber nicht das erste
|
||||
> Arbeitspaket. Sie liefert nur die Hälfte eines Ergebnisses (§3.6) und braucht Turnier,
|
||||
> Startliste und Wertungslogik als Unterbau. AP 1–3 zuerst, dann AP 5 — sonst hat der
|
||||
> Mess-Pi nichts, wogegen er Startnummern auflösen könnte.
|
||||
|
||||
---
|
||||
|
||||
### Quellen
|
||||
|
||||
- `www.oamc.de` — Startseite, Navigation, Rubrik Motorradgruppe (`m/m1.htm`, `m/m2.htm`,
|
||||
`m/m-sportbeschreibung.htm`), Downloadseite `d/d-mot-turnier.htm`
|
||||
- `www.oamc.de/datei/2026-05-31-MT-REIN-HAIN.PDF` — Tagesergebnis Doppelturnier
|
||||
MSC Hainstadt / OAMC Reinheim, 13 Wertungsblöcke; Grundlage für §3.3
|
||||
- `www.oamc.de/datei/MT-Rahmenausschreibung-2026.pdf` (Stand 1.2.2026) — Nenngeld,
|
||||
Nennschluss, Streichresultate, Jugend-Klasseneinteilung, Endlaufqualifikation
|
||||
- Systemprüfung auf dem Zielgerät (Raspberry Pi 4B) für §5.1
|
||||
@@ -0,0 +1,72 @@
|
||||
[project]
|
||||
name = "oamc-turnier"
|
||||
version = "0.1.0"
|
||||
description = "OAMC Reinheim — Auswertung von ADAC Motorrad-Turnieren (Ergebnisse, Zeitmessung, Meisterschaft)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "Proprietär — OAMC Reinheim e.V. im ADAC" }
|
||||
authors = [{ name = "OAMC Reinheim e.V." }]
|
||||
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"sqlalchemy>=2.0.30",
|
||||
"alembic>=1.13",
|
||||
"pydantic>=2.7",
|
||||
"pydantic-settings>=2.2",
|
||||
"psycopg[binary]>=3.2",
|
||||
"jinja2>=3.1",
|
||||
"python-multipart>=0.0.9",
|
||||
"bcrypt>=4.1",
|
||||
"pyjwt>=2.8",
|
||||
"openpyxl>=3.1",
|
||||
"python-dateutil>=2.9",
|
||||
"sse-starlette>=2.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# WeasyPrint zieht native Abhängigkeiten (pango, cairo). Auf dem Pi separat via apt.
|
||||
pdf = ["weasyprint>=62"]
|
||||
dev = [
|
||||
"pytest>=8.2",
|
||||
"pytest-asyncio>=0.23",
|
||||
"httpx>=0.27",
|
||||
"ruff>=0.5",
|
||||
"mypy>=1.10",
|
||||
"types-python-dateutil",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
oamc-turnier = "oamc.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
oamc = ["templates/**/*", "static/**/*", "seeds/data/*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
filterwarnings = [
|
||||
"error::DeprecationWarning:oamc.*",
|
||||
"ignore::DeprecationWarning:starlette.*",
|
||||
"ignore::DeprecationWarning:httpx.*",
|
||||
"ignore:Using `httpx`:UserWarning",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
plugins = ["pydantic.mypy"]
|
||||
warn_unused_ignores = true
|
||||
@@ -0,0 +1,6 @@
|
||||
"""OAMC Reinheim — Auswertung von ADAC Motorrad-Turnieren.
|
||||
|
||||
Fachliche Grundlagen und Architektur: siehe ``plan.md`` im Projektwurzelverzeichnis.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP-API (FastAPI). Zwei Richtungen: Ingest (rein) und Read (raus). Plan §6."""
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Zusammenbau der API v1."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from oamc.api.v1 import admin, geraete, ingest, read
|
||||
|
||||
api_v1 = APIRouter()
|
||||
api_v1.include_router(read.router)
|
||||
api_v1.include_router(ingest.router)
|
||||
api_v1.include_router(geraete.router)
|
||||
api_v1.include_router(geraete.turnier_router)
|
||||
api_v1.include_router(geraete.messung_router)
|
||||
api_v1.include_router(admin.router)
|
||||
|
||||
__all__ = ["api_v1"]
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Interne Admin-/Auswerter-API (Session-Auth). AP 4/5/7.
|
||||
|
||||
Geraete-Verwaltung, Zuordnung unzugeordneter Messungen, Meisterschaftsberechnung
|
||||
mit Persistierung, Fahrer-Merge, Import-Monitor.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from oamc.matching.fahrer import fahrer_zusammenfuehren
|
||||
from oamc.meisterschaft.berechnung import berechne_stand
|
||||
from oamc.models import (
|
||||
Geraet,
|
||||
ImportJob,
|
||||
MeisterschaftStand,
|
||||
Start,
|
||||
Zeitmessung,
|
||||
)
|
||||
from oamc.models.enums import GeraetTyp, ZeitmessungStatus
|
||||
from oamc.security.deps import Admin, Auswerter, DbDep
|
||||
from oamc.security.tokens import erzeuge_geraete_token
|
||||
from oamc.wertung.service import turnier_auswerten
|
||||
|
||||
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
|
||||
|
||||
|
||||
class GeraetCreate(BaseModel):
|
||||
bezeichnung: str
|
||||
typ: GeraetTyp = GeraetTyp.LICHTSCHRANKE
|
||||
konfiguration: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.post("/geraete", status_code=201)
|
||||
def geraet_anlegen(db: DbDep, _admin: Admin, daten: GeraetCreate):
|
||||
if db.scalars(select(Geraet).where(Geraet.bezeichnung == daten.bezeichnung)).first():
|
||||
raise HTTPException(409, "Bezeichnung bereits vergeben")
|
||||
roh, token_hash = erzeuge_geraete_token()
|
||||
g = Geraet(
|
||||
bezeichnung=daten.bezeichnung,
|
||||
typ=daten.typ,
|
||||
token_hash=token_hash,
|
||||
konfiguration=daten.konfiguration,
|
||||
)
|
||||
db.add(g)
|
||||
db.flush()
|
||||
# Klartext-Token wird NUR hier ein einziges Mal ausgegeben.
|
||||
return {"id": g.id, "bezeichnung": g.bezeichnung, "token": roh}
|
||||
|
||||
|
||||
@router.post("/geraete/{geraet_id}/token")
|
||||
def geraet_token_neu(db: DbDep, _admin: Admin, geraet_id: int):
|
||||
g = db.get(Geraet, geraet_id)
|
||||
if g is None:
|
||||
raise HTTPException(404, "Geraet nicht gefunden")
|
||||
roh, token_hash = erzeuge_geraete_token()
|
||||
g.token_hash = token_hash
|
||||
return {"id": g.id, "token": roh}
|
||||
|
||||
|
||||
@router.post("/geraete/{geraet_id}/sperren")
|
||||
def geraet_sperren(db: DbDep, _admin: Admin, geraet_id: int):
|
||||
g = db.get(Geraet, geraet_id)
|
||||
if g is None:
|
||||
raise HTTPException(404, "Geraet nicht gefunden")
|
||||
g.aktiv = False
|
||||
return {"id": g.id, "aktiv": g.aktiv}
|
||||
|
||||
|
||||
@router.get("/geraete")
|
||||
def geraete_liste(db: DbDep, _auswerter: Auswerter):
|
||||
jetzt = datetime.now(UTC)
|
||||
out = []
|
||||
for g in db.scalars(select(Geraet)):
|
||||
alter_s = (jetzt - g.letzte_meldung_am).total_seconds() if g.letzte_meldung_am else None
|
||||
out.append(
|
||||
{
|
||||
"id": g.id,
|
||||
"bezeichnung": g.bezeichnung,
|
||||
"typ": g.typ.value,
|
||||
"aktiv": g.aktiv,
|
||||
"letzte_meldung_am": g.letzte_meldung_am.isoformat() if g.letzte_meldung_am else None,
|
||||
"sekunden_seit_meldung": round(alter_s) if alter_s is not None else None,
|
||||
"lebt": alter_s is not None and alter_s < 60,
|
||||
"queue_laenge_gemeldet": g.queue_laenge_gemeldet,
|
||||
"firmware_version": g.firmware_version,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/zeitmessungen/offen")
|
||||
def offene_messungen(db: DbDep, _auswerter: Auswerter, turnier_id: int | None = None):
|
||||
stmt = select(Zeitmessung).where(
|
||||
Zeitmessung.status.in_([ZeitmessungStatus.UNZUGEORDNET, ZeitmessungStatus.UNPLAUSIBEL])
|
||||
)
|
||||
if turnier_id:
|
||||
stmt = stmt.where(Zeitmessung.turnier_id == turnier_id)
|
||||
return [
|
||||
{
|
||||
"id": str(m.id),
|
||||
"turnier_id": m.turnier_id,
|
||||
"startnummer_gemeldet": m.startnummer_gemeldet,
|
||||
"messpunkt": m.messpunkt,
|
||||
"dauer_sekunden": float(m.dauer_sekunden),
|
||||
"status": m.status.value,
|
||||
"gemessen_am": m.gemessen_am.isoformat(),
|
||||
}
|
||||
for m in db.scalars(stmt.order_by(Zeitmessung.gemessen_am))
|
||||
]
|
||||
|
||||
|
||||
class ZuordnenEin(BaseModel):
|
||||
startnummer: int
|
||||
|
||||
|
||||
@router.post("/zeitmessungen/{messung_id}/zuordnen")
|
||||
def messung_zuordnen(db: DbDep, _auswerter: Auswerter, messung_id: str, daten: ZuordnenEin):
|
||||
m = db.get(Zeitmessung, messung_id)
|
||||
if m is None:
|
||||
raise HTTPException(404, "Messung nicht gefunden")
|
||||
start = db.scalars(
|
||||
select(Start).where(
|
||||
Start.turnier_id == m.turnier_id, Start.startnummer == daten.startnummer
|
||||
)
|
||||
).first()
|
||||
if start is None:
|
||||
raise HTTPException(404, f"Kein Start mit Nr. {daten.startnummer} im Turnier {m.turnier_id}")
|
||||
m.start_id = start.id
|
||||
m.startnummer_gemeldet = daten.startnummer
|
||||
if m.status == ZeitmessungStatus.UNZUGEORDNET:
|
||||
m.status = ZeitmessungStatus.GUELTIG
|
||||
return {"id": str(m.id), "start_id": start.id, "status": m.status.value}
|
||||
|
||||
|
||||
class MergeEin(BaseModel):
|
||||
ziel_fahrer_id: int
|
||||
quelle_fahrer_id: int
|
||||
|
||||
|
||||
@router.post("/fahrer/merge")
|
||||
def fahrer_merge(db: DbDep, _admin: Admin, daten: MergeEin):
|
||||
try:
|
||||
anzahl = fahrer_zusammenfuehren(db, daten.ziel_fahrer_id, daten.quelle_fahrer_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
return {"umgehaengte_starts": anzahl, "ziel_fahrer_id": daten.ziel_fahrer_id}
|
||||
|
||||
|
||||
@router.get("/import-jobs")
|
||||
def import_jobs(db: DbDep, _auswerter: Auswerter, limit: int = 50):
|
||||
return [
|
||||
{
|
||||
"id": j.id,
|
||||
"quelle": j.quelle,
|
||||
"idempotenz_schluessel": j.idempotenz_schluessel,
|
||||
"status": j.status.value,
|
||||
"angelegt_am": j.angelegt_am.isoformat(),
|
||||
"warnungen": (j.ergebnis_bericht or {}).get("warnungen", []),
|
||||
}
|
||||
for j in db.scalars(
|
||||
select(ImportJob).order_by(ImportJob.angelegt_am.desc()).limit(limit)
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@router.post("/meisterschaften/{meisterschaft_id}/berechnen")
|
||||
def meisterschaft_berechnen(db: DbDep, _auswerter: Auswerter, meisterschaft_id: int):
|
||||
"""Stand neu berechnen und materialisieren (``meisterschaft_stand``)."""
|
||||
|
||||
bericht = berechne_stand(db, meisterschaft_id)
|
||||
db.query(MeisterschaftStand).filter(
|
||||
MeisterschaftStand.meisterschaft_id == meisterschaft_id
|
||||
).delete()
|
||||
for z in bericht.zeilen:
|
||||
db.add(
|
||||
MeisterschaftStand(
|
||||
meisterschaft_id=meisterschaft_id,
|
||||
fahrer_id=z.fahrer_id,
|
||||
punkte_gesamt=z.punkte_gesamt,
|
||||
punkte_je_lauf={str(k): float(v) for k, v in z.punkte_je_lauf.items()},
|
||||
gestrichene_laeufe=z.gestrichene_laeufe,
|
||||
anzahl_siege=z.anzahl_siege,
|
||||
platz=z.platz,
|
||||
)
|
||||
)
|
||||
return {
|
||||
"meisterschaft_id": meisterschaft_id,
|
||||
"zeilen": len(bericht.zeilen),
|
||||
"hinweise": bericht.hinweise,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/turniere/{turnier_id}/neu-auswerten")
|
||||
def turnier_neu_auswerten(db: DbDep, _auswerter: Auswerter, turnier_id: int):
|
||||
try:
|
||||
bericht = turnier_auswerten(db, turnier_id, zeit_aus_messungen=True, schreiben=True)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
return {
|
||||
"turnier_id": turnier_id,
|
||||
"gewertete_starts": bericht.gewertete_starts,
|
||||
"vorlaeufige_starts": bericht.vorlaeufige_starts,
|
||||
"wertungsklassen": bericht.wertungsklassen,
|
||||
"abweichungen": [a.__dict__ for a in bericht.abweichungen],
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Geraete-API — Zeitmessung & Anzeige (Mess-Pi, Bearer-Token). Plan §6.2.
|
||||
|
||||
Eigenes Rechteprofil: schreiben NUR Messungen, lesen NUR was die Anzeige braucht.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from oamc.db import SessionLocal
|
||||
from oamc.geraete.service import (
|
||||
aktives_turnier,
|
||||
geraet_konfiguration,
|
||||
messung_verwerfen,
|
||||
startliste_fuer_anzeige,
|
||||
verarbeite_batch,
|
||||
)
|
||||
from oamc.models import Zeitmessung
|
||||
from oamc.schemas.geraete import (
|
||||
BatchAntwort,
|
||||
GeraeteKonfiguration,
|
||||
Heartbeat,
|
||||
HeartbeatAntwort,
|
||||
MessungBatch,
|
||||
VerwerfenEin,
|
||||
)
|
||||
from oamc.security.deps import DbDep, GeraetDep
|
||||
|
||||
router = APIRouter(prefix="/api/v1/geraete", tags=["geraete"])
|
||||
turnier_router = APIRouter(prefix="/api/v1/turniere/aktiv", tags=["geraete"])
|
||||
messung_router = APIRouter(prefix="/api/v1/zeitmessungen", tags=["geraete"])
|
||||
|
||||
|
||||
@router.post("/heartbeat", response_model=HeartbeatAntwort)
|
||||
def heartbeat(db: DbDep, geraet: GeraetDep, daten: Heartbeat):
|
||||
geraet.letzte_meldung_am = datetime.now(UTC)
|
||||
if daten.firmware_version:
|
||||
geraet.firmware_version = daten.firmware_version
|
||||
geraet.queue_laenge_gemeldet = daten.queue_laenge
|
||||
aktiv = aktives_turnier(db)
|
||||
return HeartbeatAntwort(
|
||||
geraet_id=geraet.id,
|
||||
bezeichnung=geraet.bezeichnung,
|
||||
server_zeit=datetime.now(UTC),
|
||||
aktives_turnier_id=aktiv.id if aktiv else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/konfiguration", response_model=GeraeteKonfiguration)
|
||||
def konfiguration(db: DbDep, geraet: GeraetDep):
|
||||
return GeraeteKonfiguration(**geraet_konfiguration(db, geraet))
|
||||
|
||||
|
||||
@turnier_router.get("/startliste")
|
||||
def aktive_startliste(db: DbDep, geraet: GeraetDep, response: Response):
|
||||
aktiv = aktives_turnier(db)
|
||||
if aktiv is None:
|
||||
raise HTTPException(409, "Kein aktives Turnier gesetzt")
|
||||
daten = startliste_fuer_anzeige(db, aktiv.id)
|
||||
# ETag-faehig, offlinefest zwischenspeicherbar (Plan §6.2)
|
||||
roh = json.dumps(daten, sort_keys=True, ensure_ascii=False)
|
||||
etag = f'W/"{aktiv.id}-{len(daten)}-{abs(hash(roh)) & 0xFFFFFFFF:x}"'
|
||||
response.headers["ETag"] = etag
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
return {"turnier_id": aktiv.id, "turnier": aktiv.name, "starts": daten}
|
||||
|
||||
|
||||
@messung_router.post("", response_model=BatchAntwort)
|
||||
def messungen_batch(db: DbDep, geraet: GeraetDep, batch: MessungBatch):
|
||||
"""Messungen (Batch, idempotent) — der Kernfall. Antwort quittiert jede
|
||||
Messung einzeln (angenommen/duplikat/unzugeordnet/unplausibel)."""
|
||||
|
||||
return verarbeite_batch(db, geraet, batch)
|
||||
|
||||
|
||||
@messung_router.post("/{messung_id}/verwerfen")
|
||||
def messung_verwerfen_endpoint(
|
||||
db: DbDep, geraet: GeraetDep, messung_id: uuid.UUID, daten: VerwerfenEin
|
||||
):
|
||||
try:
|
||||
m = messung_verwerfen(db, messung_id, daten.grund)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
return {"id": str(m.id), "status": m.status.value, "grund": m.verworfen_grund}
|
||||
|
||||
|
||||
# --- SSE: Live-Anzeige am Parcours -------------------------------------
|
||||
async def _anzeige_stream(request):
|
||||
"""Sendet alle ~2 s den aktuellen Stand. Einseitiger Datenfluss, reconnectet
|
||||
von selbst (Plan §5.2). Der Client faellt bei Verbindungsverlust auf die
|
||||
zuletzt geladene Startliste zurueck (Plan §7)."""
|
||||
|
||||
letzte_signatur = None
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
aktiv = aktives_turnier(db)
|
||||
if aktiv is None:
|
||||
payload = {"aktiv": False}
|
||||
else:
|
||||
letzte = db.scalars(
|
||||
select(Zeitmessung)
|
||||
.where(Zeitmessung.turnier_id == aktiv.id)
|
||||
.order_by(Zeitmessung.empfangen_am.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
naechste = startliste_fuer_anzeige(db, aktiv.id)[:5]
|
||||
payload = {
|
||||
"aktiv": True,
|
||||
"turnier": aktiv.name,
|
||||
"letzte_messung": (
|
||||
{
|
||||
"startnummer": letzte.startnummer_gemeldet,
|
||||
"dauer_sekunden": float(letzte.dauer_sekunden),
|
||||
"status": letzte.status.value,
|
||||
"empfangen_am": letzte.empfangen_am.isoformat(),
|
||||
}
|
||||
if letzte
|
||||
else None
|
||||
),
|
||||
"naechste_starter": naechste,
|
||||
"server_zeit": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
signatur = json.dumps(payload, sort_keys=True)
|
||||
if signatur != letzte_signatur:
|
||||
letzte_signatur = signatur
|
||||
yield {"event": "anzeige", "data": json.dumps(payload, ensure_ascii=False)}
|
||||
await asyncio.sleep(2)
|
||||
|
||||
|
||||
@turnier_router.get("/anzeige")
|
||||
async def anzeige_sse(request: Request):
|
||||
return EventSourceResponse(_anzeige_stream(request))
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Ingest-API — POST /api/v1/... (X-API-Key). Plan §6.1.
|
||||
|
||||
Idempotent, zweistufig (``?dry_run=true`` -> Validierungsvorschau).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
|
||||
|
||||
from oamc.importer.ingest import verarbeite_ergebnisse, verarbeite_starts
|
||||
from oamc.importer.xlsx import lese_ergebnisliste, lese_nennliste
|
||||
from oamc.models import Turnier, Veranstaltung
|
||||
from oamc.models.enums import VeranstaltungStatus
|
||||
from oamc.schemas.ingest import (
|
||||
ErgebnisBulk,
|
||||
ImportBericht,
|
||||
StartBulk,
|
||||
TurnierCreate,
|
||||
VeranstaltungCreate,
|
||||
)
|
||||
from oamc.security.deps import DbDep, IngestKey
|
||||
from oamc.wertung.service import turnier_auswerten
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["ingest"], dependencies=[])
|
||||
|
||||
|
||||
@router.post("/veranstaltungen", status_code=201)
|
||||
def veranstaltung_anlegen(db: DbDep, _key: IngestKey, daten: VeranstaltungCreate):
|
||||
verein = None
|
||||
if daten.ausrichter_verein:
|
||||
from oamc.importer.ingest import finde_verein
|
||||
|
||||
verein = finde_verein(db, daten.ausrichter_verein)
|
||||
v = Veranstaltung(
|
||||
datum=daten.datum,
|
||||
titel=daten.titel,
|
||||
ort=daten.ort,
|
||||
ausrichter_verein_id=verein.id if verein else None,
|
||||
ist_doppelturnier=daten.ist_doppelturnier,
|
||||
veranstaltungsleiter=daten.veranstaltungsleiter,
|
||||
turnierleiter=daten.turnierleiter,
|
||||
status=VeranstaltungStatus.GEPLANT,
|
||||
)
|
||||
db.add(v)
|
||||
db.flush()
|
||||
return {"id": v.id}
|
||||
|
||||
|
||||
@router.post("/turniere", status_code=201)
|
||||
def turnier_anlegen(db: DbDep, _key: IngestKey, daten: TurnierCreate):
|
||||
if db.get(Veranstaltung, daten.veranstaltung_id) is None:
|
||||
raise HTTPException(404, "Veranstaltung nicht gefunden")
|
||||
verein = None
|
||||
if daten.veranstalter_verein:
|
||||
from oamc.importer.ingest import finde_verein
|
||||
|
||||
verein = finde_verein(db, daten.veranstalter_verein)
|
||||
t = Turnier(
|
||||
veranstaltung_id=daten.veranstaltung_id,
|
||||
name=daten.name,
|
||||
veranstalter_verein_id=verein.id if verein else None,
|
||||
zaehlt_meisterschaft=daten.zaehlt_meisterschaft,
|
||||
)
|
||||
db.add(t)
|
||||
db.flush()
|
||||
return {"id": t.id}
|
||||
|
||||
|
||||
@router.post("/turniere/{turnier_id}/starts", response_model=ImportBericht)
|
||||
def starts_bulk(
|
||||
db: DbDep,
|
||||
_key: IngestKey,
|
||||
turnier_id: int,
|
||||
payload: StartBulk,
|
||||
dry_run: bool = Query(default=False),
|
||||
):
|
||||
try:
|
||||
return verarbeite_starts(db, turnier_id, payload, dry_run=dry_run, angelegt_von="api")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/turniere/{turnier_id}/ergebnisse", response_model=ImportBericht)
|
||||
def ergebnisse_bulk(
|
||||
db: DbDep,
|
||||
_key: IngestKey,
|
||||
turnier_id: int,
|
||||
payload: ErgebnisBulk,
|
||||
dry_run: bool = Query(default=False),
|
||||
):
|
||||
"""Ergebnis-Bulk-Upload — der Kernfall (Plan §6.1)."""
|
||||
|
||||
try:
|
||||
return verarbeite_ergebnisse(
|
||||
db, turnier_id, payload, dry_run=dry_run, angelegt_von="api"
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/turniere/{turnier_id}/import/xlsx", response_model=ImportBericht)
|
||||
async def import_xlsx(
|
||||
db: DbDep,
|
||||
_key: IngestKey,
|
||||
turnier_id: int,
|
||||
art: str = Form(description='"nennliste" oder "ergebnisliste"'),
|
||||
idempotenz_schluessel: str = Form(...),
|
||||
quelle: str = Form(default="xlsx-upload"),
|
||||
blatt: str | None = Form(default=None),
|
||||
dry_run: bool = Query(default=True),
|
||||
datei: UploadFile = File(...),
|
||||
):
|
||||
"""Excel-Nennliste/Ergebnisliste als Datei (Plan §6.1).
|
||||
|
||||
Achtung: die Spaltenzuordnung (``SPALTEN_ALIAS`` in ``oamc.importer.xlsx``) ist
|
||||
an die realen ADAC-Vorlagen anzupassen (Plan O-12). Default ``dry_run=true``.
|
||||
"""
|
||||
|
||||
roh = await datei.read()
|
||||
try:
|
||||
if art == "nennliste":
|
||||
payload = lese_nennliste(
|
||||
roh, quelle=quelle, idempotenz_schluessel=idempotenz_schluessel, blatt=blatt
|
||||
)
|
||||
return verarbeite_starts(db, turnier_id, payload, dry_run=dry_run, angelegt_von="xlsx")
|
||||
if art == "ergebnisliste":
|
||||
payload = lese_ergebnisliste(
|
||||
roh, quelle=quelle, idempotenz_schluessel=idempotenz_schluessel, blatt=blatt
|
||||
)
|
||||
return verarbeite_ergebnisse(
|
||||
db, turnier_id, payload, dry_run=dry_run, angelegt_von="xlsx"
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
raise HTTPException(422, 'art muss "nennliste" oder "ergebnisliste" sein')
|
||||
|
||||
|
||||
@router.post("/turniere/{turnier_id}/finalisieren")
|
||||
def turnier_finalisieren(db: DbDep, _key: IngestKey, turnier_id: int):
|
||||
"""Wertung rechnen und Turnier auf ``final`` setzen. Die Meisterschaft wird
|
||||
ueber die Admin-API neu berechnet (siehe /api/v1/admin)."""
|
||||
|
||||
t = db.get(Turnier, turnier_id)
|
||||
if t is None:
|
||||
raise HTTPException(404, "Turnier nicht gefunden")
|
||||
bericht = turnier_auswerten(db, turnier_id, zeit_aus_messungen=True, schreiben=True)
|
||||
from oamc.models.enums import TurnierStatus
|
||||
|
||||
t.status = TurnierStatus.FINAL
|
||||
return {
|
||||
"turnier_id": turnier_id,
|
||||
"gewertete_starts": bericht.gewertete_starts,
|
||||
"vorlaeufige_starts": bericht.vorlaeufige_starts,
|
||||
"abweichungen": [a.__dict__ for a in bericht.abweichungen],
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Read-API — oeffentlich, ohne Key (Plan §6.3).
|
||||
|
||||
Datenminimierung (Plan §11): keine Geburtsdaten, keine Kontaktdaten, keine
|
||||
ADAC-Mitgliedsnummer. Nur Geburtsjahr, und das nur wo fachlich noetig.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from oamc.api.v1.serialisierung import (
|
||||
ergebnis_zeile,
|
||||
fahrer_kurz,
|
||||
turnier_kurz,
|
||||
veranstaltung_kurz,
|
||||
)
|
||||
from oamc.meisterschaft.berechnung import berechne_stand, endlauf_qualifikation
|
||||
from oamc.models import (
|
||||
Fahrer,
|
||||
Meisterschaft,
|
||||
Start,
|
||||
Turnier,
|
||||
Veranstaltung,
|
||||
Verein,
|
||||
)
|
||||
from oamc.security.deps import DbDep
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["read"])
|
||||
|
||||
|
||||
@router.get("/veranstaltungen")
|
||||
def liste_veranstaltungen(db: DbDep, saison: int | None = Query(default=None)):
|
||||
stmt = select(Veranstaltung).order_by(Veranstaltung.datum.desc())
|
||||
if saison:
|
||||
stmt = stmt.where(func.extract("year", Veranstaltung.datum) == saison)
|
||||
return [veranstaltung_kurz(v) for v in db.scalars(stmt)]
|
||||
|
||||
|
||||
@router.get("/veranstaltungen/{veranstaltung_id}")
|
||||
def eine_veranstaltung(db: DbDep, veranstaltung_id: int):
|
||||
v = db.get(Veranstaltung, veranstaltung_id)
|
||||
if v is None:
|
||||
raise HTTPException(404, "Veranstaltung nicht gefunden")
|
||||
return {**veranstaltung_kurz(v), "turniere": [turnier_kurz(t) for t in v.turniere]}
|
||||
|
||||
|
||||
@router.get("/turniere/{turnier_id}/ergebnisse")
|
||||
def turnier_ergebnisse(db: DbDep, turnier_id: int, wertungsklasse: str | None = None):
|
||||
turnier = db.get(Turnier, turnier_id)
|
||||
if turnier is None:
|
||||
raise HTTPException(404, "Turnier nicht gefunden")
|
||||
|
||||
starts = db.scalars(
|
||||
select(Start)
|
||||
.where(Start.turnier_id == turnier_id)
|
||||
.options(
|
||||
selectinload(Start.fahrer).selectinload(Fahrer.verein),
|
||||
selectinload(Start.ergebnis),
|
||||
selectinload(Start.klasse),
|
||||
selectinload(Start.wertungsklasse),
|
||||
)
|
||||
).all()
|
||||
|
||||
gruppen: dict[str, list] = {}
|
||||
for s in starts:
|
||||
wk = s.wertungsklasse.bezeichnung if s.wertungsklasse else (
|
||||
s.klasse.code if s.klasse else "ohne Wertungsklasse"
|
||||
)
|
||||
gruppen.setdefault(wk, []).append(s)
|
||||
|
||||
if wertungsklasse:
|
||||
gruppen = {k: v for k, v in gruppen.items() if k.lower() == wertungsklasse.lower()}
|
||||
|
||||
result = []
|
||||
for name, gruppe in sorted(gruppen.items()):
|
||||
gruppe.sort(key=lambda s: (s.ergebnis.platzierung if s.ergebnis and s.ergebnis.platzierung else 9999))
|
||||
result.append(
|
||||
{
|
||||
"wertungsklasse": name,
|
||||
"vorlaeufig": turnier.status.value not in ("final",),
|
||||
"zeilen": [ergebnis_zeile(s) for s in gruppe],
|
||||
}
|
||||
)
|
||||
return {"turnier": turnier_kurz(turnier), "wertungsklassen": result}
|
||||
|
||||
|
||||
@router.get("/fahrer")
|
||||
def suche_fahrer(
|
||||
db: DbDep,
|
||||
q: str | None = Query(default=None, min_length=2),
|
||||
verein: str | None = None,
|
||||
limit: int = Query(default=50, le=200),
|
||||
):
|
||||
stmt = select(Fahrer).where(Fahrer.aktiv.is_(True)).options(selectinload(Fahrer.verein))
|
||||
if q:
|
||||
muster = f"%{q.lower()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
func.lower(Fahrer.nachname).like(muster),
|
||||
func.lower(Fahrer.vorname).like(muster),
|
||||
func.lower(func.concat(Fahrer.nachname, " ", Fahrer.vorname)).like(muster),
|
||||
)
|
||||
)
|
||||
if verein:
|
||||
stmt = stmt.join(Verein, Verein.id == Fahrer.verein_id).where(
|
||||
func.lower(Verein.name).like(f"%{verein.lower()}%")
|
||||
)
|
||||
stmt = stmt.order_by(Fahrer.nachname, Fahrer.vorname).limit(limit)
|
||||
return [fahrer_kurz(f) for f in db.scalars(stmt)]
|
||||
|
||||
|
||||
@router.get("/fahrer/{fahrer_id}")
|
||||
def ein_fahrer(db: DbDep, fahrer_id: int):
|
||||
f = db.get(Fahrer, fahrer_id)
|
||||
if f is None or not f.aktiv:
|
||||
raise HTTPException(404, "Fahrer nicht gefunden")
|
||||
return fahrer_kurz(f)
|
||||
|
||||
|
||||
@router.get("/fahrer/{fahrer_id}/historie")
|
||||
def fahrer_historie(db: DbDep, fahrer_id: int):
|
||||
f = db.get(Fahrer, fahrer_id)
|
||||
if f is None:
|
||||
raise HTTPException(404, "Fahrer nicht gefunden")
|
||||
starts = db.scalars(
|
||||
select(Start)
|
||||
.where(Start.fahrer_id == fahrer_id)
|
||||
.options(
|
||||
selectinload(Start.ergebnis),
|
||||
selectinload(Start.klasse),
|
||||
selectinload(Start.turnier).selectinload(Turnier.veranstaltung),
|
||||
)
|
||||
).all()
|
||||
eintraege = []
|
||||
for s in sorted(starts, key=lambda s: s.turnier.veranstaltung.datum, reverse=True):
|
||||
e = s.ergebnis
|
||||
eintraege.append(
|
||||
{
|
||||
"datum": s.turnier.veranstaltung.datum.isoformat(),
|
||||
"veranstaltung": s.turnier.veranstaltung.titel,
|
||||
"turnier": s.turnier.name,
|
||||
"klasse": s.klasse.code if s.klasse else None,
|
||||
"startnummer": s.startnummer,
|
||||
"summe_zeit": float(e.summe_zeit) if e and e.summe_zeit is not None else None,
|
||||
"summe_fehler": e.summe_fehler if e else None,
|
||||
"gesamt": float(e.gesamt) if e and e.gesamt is not None else None,
|
||||
"platzierung": e.platzierung if e else None,
|
||||
"status": s.status.value,
|
||||
}
|
||||
)
|
||||
zeiten = [x["summe_zeit"] for x in eintraege if x["summe_zeit"]]
|
||||
return {
|
||||
"fahrer": fahrer_kurz(f),
|
||||
"anzahl_starts": len(eintraege),
|
||||
"bestzeit": min(zeiten) if zeiten else None,
|
||||
"starts": eintraege,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/meisterschaften")
|
||||
def liste_meisterschaften(db: DbDep, saison: int | None = None):
|
||||
stmt = select(Meisterschaft)
|
||||
if saison:
|
||||
stmt = stmt.where(Meisterschaft.saison_jahr == saison)
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"bezeichnung": m.bezeichnung,
|
||||
"saison": m.saison_jahr,
|
||||
"kategorie": m.kategorie.value,
|
||||
}
|
||||
for m in db.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/meisterschaften/{meisterschaft_id}/stand")
|
||||
def meisterschaft_stand(db: DbDep, meisterschaft_id: int):
|
||||
bericht = berechne_stand(db, meisterschaft_id)
|
||||
return {
|
||||
"meisterschaft_id": bericht.meisterschaft_id,
|
||||
"saison": bericht.saison_jahr,
|
||||
"kategorie": bericht.kategorie,
|
||||
"laeufe_gesamt": bericht.laeufe_gesamt,
|
||||
"laeufe_gewertet": bericht.laeufe_gewertet,
|
||||
"hinweise": bericht.hinweise,
|
||||
"zeilen": [
|
||||
{
|
||||
"platz": z.platz,
|
||||
"fahrer": fahrer_kurz(db.get(Fahrer, z.fahrer_id)),
|
||||
"punkte_gesamt": float(z.punkte_gesamt),
|
||||
"anzahl_siege": z.anzahl_siege,
|
||||
"gestrichene_laeufe": z.gestrichene_laeufe,
|
||||
}
|
||||
for z in bericht.zeilen
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/meisterschaften/{meisterschaft_id}/endlauf-qualifikation")
|
||||
def meisterschaft_quali(db: DbDep, meisterschaft_id: int, plaetze: int = 10):
|
||||
vorschlaege = endlauf_qualifikation(db, meisterschaft_id, plaetze)
|
||||
return {
|
||||
"meisterschaft_id": meisterschaft_id,
|
||||
"hinweis": "Vorschlag — die endgueltige Festlegung trifft der Bereichsleiter (Plan §3.4).",
|
||||
"plaetze": plaetze,
|
||||
"vorschlaege": [
|
||||
{
|
||||
"platz": v.platz,
|
||||
"fahrer": fahrer_kurz(db.get(Fahrer, v.fahrer_id)),
|
||||
"punkte": float(v.punkte),
|
||||
"anzahl_siege": v.anzahl_siege,
|
||||
"tie_break_relevant": v.tie_break_relevant,
|
||||
}
|
||||
for v in vorschlaege
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Gemeinsame JSON-Serialisierung fuer die Read-API und das Frontend.
|
||||
|
||||
Datenschutz (Plan §11): ``fahrer_kurz`` gibt nie Geburtsdatum,
|
||||
ADAC-Mitgliedsnummer oder Kontaktdaten aus. Anonymisierte Fahrer erscheinen als
|
||||
"Teilnehmer/in #id".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from oamc.models import Fahrer, Start, Turnier, Veranstaltung
|
||||
|
||||
|
||||
def fahrer_kurz(f: Fahrer | None) -> dict | None:
|
||||
if f is None:
|
||||
return None
|
||||
if f.anzeige_anonymisiert:
|
||||
return {"id": f.id, "name": f.anzeigename, "anonymisiert": True}
|
||||
return {
|
||||
"id": f.id,
|
||||
"name": f.voller_name,
|
||||
"nachname": f.nachname,
|
||||
"vorname": f.vorname,
|
||||
"geburtsjahr": f.geburtsjahr,
|
||||
"geschlecht": f.geschlecht.value if f.geschlecht else None,
|
||||
"verein": f.verein.name if f.verein else None,
|
||||
"anonymisiert": False,
|
||||
}
|
||||
|
||||
|
||||
def veranstaltung_kurz(v: Veranstaltung) -> dict:
|
||||
return {
|
||||
"id": v.id,
|
||||
"datum": v.datum.isoformat(),
|
||||
"titel": v.titel,
|
||||
"ort": v.ort,
|
||||
"ist_doppelturnier": v.ist_doppelturnier,
|
||||
"status": v.status.value,
|
||||
}
|
||||
|
||||
|
||||
def turnier_kurz(t: Turnier) -> dict:
|
||||
return {
|
||||
"id": t.id,
|
||||
"veranstaltung_id": t.veranstaltung_id,
|
||||
"name": t.name,
|
||||
"zaehlt_meisterschaft": t.zaehlt_meisterschaft,
|
||||
"status": t.status.value,
|
||||
"ist_aktiv": t.ist_aktiv,
|
||||
}
|
||||
|
||||
|
||||
def ergebnis_zeile(s: Start) -> dict:
|
||||
e = s.ergebnis
|
||||
return {
|
||||
"startnummer": s.startnummer,
|
||||
"fahrer": fahrer_kurz(s.fahrer),
|
||||
"klasse": s.klasse.code if s.klasse else None,
|
||||
"fahrzeug": s.fahrzeug,
|
||||
"status": s.status.value,
|
||||
"summe_zeit": float(e.summe_zeit) if e and e.summe_zeit is not None else None,
|
||||
"summe_fehler": e.summe_fehler if e else None,
|
||||
"gesamt": float(e.gesamt) if e and e.gesamt is not None else None,
|
||||
"platzierung": e.platzierung if e else None,
|
||||
"zeit_vollstaendig": bool(e and e.zeit_vollstaendig),
|
||||
"fehler_vollstaendig": bool(e and e.fehler_vollstaendig),
|
||||
"vorlaeufig": not (e and e.zeit_vollstaendig and e.fehler_vollstaendig),
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Kommandozeile: ``oamc-turnier <befehl>``.
|
||||
|
||||
Befehle:
|
||||
migrate Alembic-Migrationen bis head
|
||||
seed Stammdaten Saison 2026 (idempotent)
|
||||
benutzer-anlegen internen Benutzer anlegen
|
||||
serve uvicorn im Vordergrund (Entwicklung)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJEKT_WURZEL = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
def _migrate() -> int:
|
||||
return subprocess.call(["alembic", "upgrade", "head"], cwd=str(PROJEKT_WURZEL))
|
||||
|
||||
|
||||
def _seed() -> int:
|
||||
from oamc.db import SessionLocal
|
||||
from oamc.seeds import seed_saison_2026
|
||||
|
||||
with SessionLocal() as db:
|
||||
zaehler = seed_saison_2026(db)
|
||||
db.commit()
|
||||
print("Seed Saison 2026:", zaehler)
|
||||
return 0
|
||||
|
||||
|
||||
def _benutzer_anlegen(args: argparse.Namespace) -> int:
|
||||
from oamc.db import SessionLocal
|
||||
from oamc.models import Benutzer
|
||||
from oamc.models.enums import BenutzerRolle
|
||||
from oamc.security.passwoerter import hash_passwort
|
||||
|
||||
passwort = args.passwort or getpass.getpass("Passwort: ")
|
||||
with SessionLocal() as db:
|
||||
b = Benutzer(
|
||||
benutzername=args.benutzername,
|
||||
anzeigename=args.anzeigename or args.benutzername,
|
||||
passwort_hash=hash_passwort(passwort),
|
||||
rolle=BenutzerRolle(args.rolle),
|
||||
)
|
||||
db.add(b)
|
||||
db.commit()
|
||||
print(f"Benutzer {b.benutzername!r} ({b.rolle.value}) angelegt.")
|
||||
return 0
|
||||
|
||||
|
||||
def _serve(args: argparse.Namespace) -> int:
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"oamc.main:app", host=args.host, port=args.port, reload=args.reload, log_level="info"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="oamc-turnier")
|
||||
sub = parser.add_subparsers(dest="befehl", required=True)
|
||||
|
||||
sub.add_parser("migrate", help="Alembic-Migrationen ausfuehren")
|
||||
sub.add_parser("seed", help="Stammdaten Saison 2026 einspielen")
|
||||
|
||||
p_b = sub.add_parser("benutzer-anlegen", help="internen Benutzer anlegen")
|
||||
p_b.add_argument("benutzername")
|
||||
p_b.add_argument("--anzeigename")
|
||||
p_b.add_argument("--rolle", default="auswerter", choices=["admin", "auswerter", "lesend"])
|
||||
p_b.add_argument("--passwort", help="sonst interaktive Abfrage")
|
||||
|
||||
p_s = sub.add_parser("serve", help="uvicorn im Vordergrund")
|
||||
p_s.add_argument("--host", default="127.0.0.1")
|
||||
p_s.add_argument("--port", type=int, default=8000)
|
||||
p_s.add_argument("--reload", action="store_true")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if args.befehl == "migrate":
|
||||
return _migrate()
|
||||
if args.befehl == "seed":
|
||||
return _seed()
|
||||
if args.befehl == "benutzer-anlegen":
|
||||
return _benutzer_anlegen(args)
|
||||
if args.befehl == "serve":
|
||||
return _serve(args)
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Zentrale Konfiguration, aus Umgebung / .env geladen (Pydantic Settings).
|
||||
|
||||
Alle Felder koennen ueber Umgebungsvariablen mit Prefix ``OAMC_`` gesetzt werden,
|
||||
z. B. ``OAMC_DATABASE_URL``. Siehe ``.env.example``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="OAMC_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# --- Datenbank ---------------------------------------------------------
|
||||
database_url: str = Field(
|
||||
default="postgresql+psycopg://linus:linus@localhost:5432/oamc_dev",
|
||||
description="SQLAlchemy-URL. Muss psycopg (v3) verwenden.",
|
||||
)
|
||||
|
||||
# --- Sicherheit ------------------------------------------------------
|
||||
secret_key: str = Field(default="dev-only-insecure-key-change-me-please-32b")
|
||||
ingest_api_keys: str = Field(
|
||||
default="dev-ingest-key",
|
||||
description="Kommagetrennte API-Keys fuer die Ingest-API (POST /api/v1/...).",
|
||||
)
|
||||
access_token_minutes: int = 60 * 12
|
||||
|
||||
# --- Betrieb --------------------------------------------------------
|
||||
env: str = Field(default="dev", description="dev | prod")
|
||||
log_level: str = "info"
|
||||
public_base_url: str = "http://localhost:8000"
|
||||
upload_dir: Path = Path("./var/uploads")
|
||||
|
||||
# --- Zeitmessung ---------------------------------------------------
|
||||
mess_min_sekunden: float = 15.0
|
||||
mess_max_sekunden: float = 600.0
|
||||
|
||||
@field_validator("database_url")
|
||||
@classmethod
|
||||
def _require_psycopg(cls, v: str) -> str:
|
||||
if v.startswith("postgresql://"):
|
||||
# bequemer Alias -> auf den psycopg-3-Treiber normalisieren
|
||||
v = v.replace("postgresql://", "postgresql+psycopg://", 1)
|
||||
return v
|
||||
|
||||
@property
|
||||
def ingest_api_key_set(self) -> set[str]:
|
||||
return {k.strip() for k in self.ingest_api_keys.split(",") if k.strip()}
|
||||
|
||||
@property
|
||||
def is_prod(self) -> bool:
|
||||
return self.env.lower() in {"prod", "production"}
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Datenbank-Engine, Session-Factory und FastAPI-Dependency."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from oamc.config import get_settings
|
||||
|
||||
_settings = get_settings()
|
||||
|
||||
engine = create_engine(
|
||||
_settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
future=True,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False, future=True)
|
||||
|
||||
|
||||
def get_db() -> Iterator[Session]:
|
||||
"""FastAPI-Dependency: eine Session pro Request, sauberer Rollback bei Fehlern."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Serverseitig gerendertes Frontend (Jinja2 + HTMX). Plan §5.2, §7.
|
||||
|
||||
Ergebnislisten sind Dokumente, keine App: kein Build-Schritt, kein Node,
|
||||
suchmaschinenlesbar, Deep-Links auf jede Liste.
|
||||
"""
|
||||
|
||||
from oamc.frontend.templating import templates
|
||||
|
||||
__all__ = ["templates"]
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Interne Seiten (Login-Pflicht). AP 7."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from oamc.frontend.templating import templates
|
||||
from oamc.models import Benutzer, Ergebnis, Geraet, Start, Turnier, Veranstaltung, Zeitmessung
|
||||
from oamc.models.enums import ZeitmessungStatus
|
||||
from oamc.security.deps import BenutzerOpt, DbDep, require_auswerter
|
||||
from oamc.security.passwoerter import pruefe_passwort
|
||||
from oamc.security.tokens import jwt_erzeugen
|
||||
|
||||
router = APIRouter(prefix="/intern", tags=["intern"])
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
def login_form(request: Request):
|
||||
return templates.TemplateResponse(request, "intern/login.html", {"fehler": None})
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(request: Request, db: DbDep, benutzername: str = Form(...), passwort: str = Form(...)):
|
||||
b = db.scalars(
|
||||
select(Benutzer).where(Benutzer.benutzername == benutzername, Benutzer.aktiv.is_(True))
|
||||
).first()
|
||||
if b is None or not pruefe_passwort(passwort, b.passwort_hash):
|
||||
return templates.TemplateResponse(
|
||||
request, "intern/login.html", {"fehler": "Anmeldung fehlgeschlagen"}, status_code=401
|
||||
)
|
||||
b.letzter_login_am = datetime.now(UTC)
|
||||
token = jwt_erzeugen(b.benutzername, b.rolle.value)
|
||||
resp = RedirectResponse("/intern", status_code=status.HTTP_303_SEE_OTHER)
|
||||
resp.set_cookie(
|
||||
"oamc_session", token, httponly=True, samesite="lax", max_age=60 * 60 * 12, path="/"
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
def logout():
|
||||
resp = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
resp.delete_cookie("oamc_session", path="/")
|
||||
return resp
|
||||
|
||||
|
||||
def _benutzer_oder_login(b: Benutzer | None):
|
||||
if b is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_307_TEMPORARY_REDIRECT, headers={"Location": "/intern/login"}
|
||||
)
|
||||
return b
|
||||
|
||||
|
||||
@router.get("")
|
||||
def dashboard(request: Request, db: DbDep, b: BenutzerOpt):
|
||||
_benutzer_oder_login(b)
|
||||
veranstaltungen = db.scalars(
|
||||
select(Veranstaltung).order_by(Veranstaltung.datum.desc()).limit(10)
|
||||
).all()
|
||||
offene = db.scalar(
|
||||
select(Zeitmessung)
|
||||
.where(Zeitmessung.status.in_([ZeitmessungStatus.UNZUGEORDNET, ZeitmessungStatus.UNPLAUSIBEL]))
|
||||
.limit(1)
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"intern/dashboard.html",
|
||||
{"benutzer": b, "veranstaltungen": veranstaltungen, "hat_offene_messungen": offene is not None},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/erfassung/{turnier_id}")
|
||||
def erfassung(request: Request, db: DbDep, b: BenutzerOpt, turnier_id: int):
|
||||
_benutzer_oder_login(b)
|
||||
turnier = db.get(Turnier, turnier_id)
|
||||
if turnier is None:
|
||||
raise HTTPException(404, "Turnier nicht gefunden")
|
||||
starts = db.scalars(
|
||||
select(Start)
|
||||
.where(Start.turnier_id == turnier_id)
|
||||
.options(selectinload(Start.fahrer), selectinload(Start.ergebnis), selectinload(Start.klasse))
|
||||
.order_by(Start.startnummer)
|
||||
).all()
|
||||
return templates.TemplateResponse(
|
||||
request, "intern/erfassung.html", {"benutzer": b, "turnier": turnier, "starts": starts}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/erfassung/{turnier_id}/start/{start_id}")
|
||||
def erfassung_speichern(
|
||||
db: DbDep,
|
||||
b: BenutzerOpt,
|
||||
turnier_id: int,
|
||||
start_id: int,
|
||||
request: Request,
|
||||
summe_zeit: str = Form(default=""),
|
||||
summe_fehler: str = Form(default=""),
|
||||
zeit_fertig: bool = Form(default=False),
|
||||
fehler_fertig: bool = Form(default=False),
|
||||
):
|
||||
benutzer = _benutzer_oder_login(b)
|
||||
require_auswerter(benutzer)
|
||||
start = db.get(Start, start_id)
|
||||
if start is None or start.turnier_id != turnier_id:
|
||||
raise HTTPException(404, "Start nicht gefunden")
|
||||
erg = start.ergebnis or Ergebnis(start_id=start.id)
|
||||
if start.ergebnis is None:
|
||||
start.ergebnis = erg
|
||||
db.add(erg)
|
||||
if summe_zeit.strip():
|
||||
erg.summe_zeit = summe_zeit.replace(",", ".")
|
||||
erg.zeit_vollstaendig = zeit_fertig
|
||||
if summe_fehler.strip():
|
||||
erg.summe_fehler = int(summe_fehler)
|
||||
erg.fehler_vollstaendig = fehler_fertig
|
||||
erg.erfasst_von = benutzer.benutzername
|
||||
db.flush()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/erfassung_zeile.html",
|
||||
{"s": start, "turnier_id": turnier_id},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/messungen")
|
||||
def messungen(request: Request, db: DbDep, b: BenutzerOpt):
|
||||
_benutzer_oder_login(b)
|
||||
offen = db.scalars(
|
||||
select(Zeitmessung)
|
||||
.where(
|
||||
Zeitmessung.status.in_(
|
||||
[ZeitmessungStatus.UNZUGEORDNET, ZeitmessungStatus.UNPLAUSIBEL]
|
||||
)
|
||||
)
|
||||
.order_by(Zeitmessung.gemessen_am)
|
||||
).all()
|
||||
return templates.TemplateResponse(
|
||||
request, "intern/messungen.html", {"benutzer": b, "messungen": offen}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/geraete")
|
||||
def geraete(request: Request, db: DbDep, b: BenutzerOpt):
|
||||
_benutzer_oder_login(b)
|
||||
jetzt = datetime.now(UTC)
|
||||
liste = []
|
||||
for g in db.scalars(select(Geraet).order_by(Geraet.bezeichnung)):
|
||||
alter = (jetzt - g.letzte_meldung_am).total_seconds() if g.letzte_meldung_am else None
|
||||
liste.append({"g": g, "sekunden": round(alter) if alter is not None else None,
|
||||
"lebt": alter is not None and alter < 60})
|
||||
return templates.TemplateResponse(request, "intern/geraete.html", {"benutzer": b, "geraete": liste})
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Oeffentliche Seiten (ohne Login) und Exporte."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import PlainTextResponse, StreamingResponse
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from oamc.api.v1.serialisierung import ergebnis_zeile
|
||||
from oamc.config import get_settings
|
||||
from oamc.frontend.templating import templates
|
||||
from oamc.meisterschaft.berechnung import berechne_stand
|
||||
from oamc.models import Fahrer, Meisterschaft, Start, Turnier, Veranstaltung
|
||||
from oamc.security.deps import DbDep
|
||||
|
||||
router = APIRouter(tags=["frontend"])
|
||||
|
||||
|
||||
@router.get("/", include_in_schema=False)
|
||||
def startseite(request: Request, db: DbDep):
|
||||
heute = date.today()
|
||||
naechste = db.scalars(
|
||||
select(Veranstaltung)
|
||||
.where(Veranstaltung.datum >= heute)
|
||||
.order_by(Veranstaltung.datum)
|
||||
.limit(1)
|
||||
).first()
|
||||
letzte = db.scalars(
|
||||
select(Veranstaltung)
|
||||
.where(Veranstaltung.datum < heute)
|
||||
.order_by(Veranstaltung.datum.desc())
|
||||
.limit(5)
|
||||
).all()
|
||||
meisterschaften = db.scalars(select(Meisterschaft).order_by(Meisterschaft.id)).all()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/start.html",
|
||||
{"naechste": naechste, "letzte": letzte, "meisterschaften": meisterschaften},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/termine")
|
||||
def termine(request: Request, db: DbDep, saison: int | None = None):
|
||||
stmt = select(Veranstaltung).order_by(Veranstaltung.datum)
|
||||
if saison:
|
||||
stmt = stmt.where(func.extract("year", Veranstaltung.datum) == saison)
|
||||
return templates.TemplateResponse(
|
||||
request, "public/termine.html", {"veranstaltungen": db.scalars(stmt).all(), "saison": saison}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/termine.ics", response_class=PlainTextResponse)
|
||||
def termine_ics(db: DbDep):
|
||||
s = get_settings()
|
||||
zeilen = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//OAMC Reinheim//Turnierauswertung//DE",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"X-WR-CALNAME:OAMC Motorrad-Turniere",
|
||||
]
|
||||
for v in db.scalars(select(Veranstaltung).order_by(Veranstaltung.datum)):
|
||||
d = v.datum.strftime("%Y%m%d")
|
||||
zeilen += [
|
||||
"BEGIN:VEVENT",
|
||||
f"UID:veranstaltung-{v.id}@{s.public_base_url}",
|
||||
f"DTSTART;VALUE=DATE:{d}",
|
||||
f"SUMMARY:{v.titel}",
|
||||
f"LOCATION:{v.ort or ''}",
|
||||
f"DESCRIPTION:Status {v.status.value}"
|
||||
+ ("" if not v.ist_doppelturnier else " — Doppelturnier"),
|
||||
"END:VEVENT",
|
||||
]
|
||||
zeilen.append("END:VCALENDAR")
|
||||
return PlainTextResponse("\r\n".join(zeilen), media_type="text/calendar")
|
||||
|
||||
|
||||
@router.get("/ergebnisse")
|
||||
def ergebnis_uebersicht(request: Request, db: DbDep):
|
||||
v = db.scalars(select(Veranstaltung).order_by(Veranstaltung.datum.desc())).all()
|
||||
return templates.TemplateResponse(request, "public/ergebnisse_liste.html", {"veranstaltungen": v})
|
||||
|
||||
|
||||
@router.get("/ergebnisse/turnier/{turnier_id}.csv")
|
||||
def ergebnis_turnier_csv(db: DbDep, turnier_id: int):
|
||||
daten = _turnier_ergebnis_daten(db, turnier_id)
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf, delimiter=";")
|
||||
w.writerow(["Wertungsklasse", "Platz", "StartNr", "Name", "Verein", "Klasse", "Zeit", "Fehler", "Gesamt"])
|
||||
for wk in daten["wertungsklassen"]:
|
||||
for z in wk["zeilen"]:
|
||||
f = z["fahrer"] or {}
|
||||
w.writerow(
|
||||
[
|
||||
wk["wertungsklasse"],
|
||||
z["platzierung"] or "",
|
||||
z["startnummer"],
|
||||
f.get("name", ""),
|
||||
f.get("verein", ""),
|
||||
z["klasse"] or "",
|
||||
z["summe_zeit"] or "",
|
||||
z["summe_fehler"] if z["summe_fehler"] is not None else "",
|
||||
z["gesamt"] if z["gesamt"] is not None else "",
|
||||
]
|
||||
)
|
||||
buf.seek(0)
|
||||
name = f"ergebnis-turnier-{turnier_id}.csv"
|
||||
return StreamingResponse(
|
||||
iter([buf.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{name}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ergebnisse/turnier/{turnier_id}")
|
||||
def ergebnis_turnier(request: Request, db: DbDep, turnier_id: int):
|
||||
daten = _turnier_ergebnis_daten(db, turnier_id)
|
||||
return templates.TemplateResponse(request, "public/ergebnisse_turnier.html", daten)
|
||||
|
||||
|
||||
def _turnier_ergebnis_daten(db: DbDep, turnier_id: int) -> dict:
|
||||
turnier = db.get(Turnier, turnier_id)
|
||||
if turnier is None:
|
||||
raise HTTPException(404, "Turnier nicht gefunden")
|
||||
starts = db.scalars(
|
||||
select(Start)
|
||||
.where(Start.turnier_id == turnier_id)
|
||||
.options(
|
||||
selectinload(Start.fahrer).selectinload(Fahrer.verein),
|
||||
selectinload(Start.ergebnis),
|
||||
selectinload(Start.klasse),
|
||||
selectinload(Start.wertungsklasse),
|
||||
)
|
||||
).all()
|
||||
gruppen: dict[str, list] = {}
|
||||
for s in starts:
|
||||
key = s.wertungsklasse.bezeichnung if s.wertungsklasse else (
|
||||
s.klasse.code if s.klasse else "ohne Wertungsklasse"
|
||||
)
|
||||
gruppen.setdefault(key, []).append(s)
|
||||
wk_liste = []
|
||||
for name, gruppe in sorted(gruppen.items()):
|
||||
gruppe.sort(key=lambda s: (s.ergebnis.platzierung if s.ergebnis and s.ergebnis.platzierung else 9999))
|
||||
wk_liste.append({"wertungsklasse": name, "zeilen": [ergebnis_zeile(s) for s in gruppe]})
|
||||
return {
|
||||
"turnier": turnier,
|
||||
"veranstaltung": turnier.veranstaltung,
|
||||
"wertungsklassen": wk_liste,
|
||||
"final": turnier.status.value == "final",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/fahrer")
|
||||
def fahrer_liste(request: Request, db: DbDep, q: str | None = None):
|
||||
stmt = (
|
||||
select(Fahrer)
|
||||
.where(Fahrer.aktiv.is_(True))
|
||||
.options(selectinload(Fahrer.verein))
|
||||
.order_by(Fahrer.nachname, Fahrer.vorname)
|
||||
)
|
||||
if q and len(q) >= 2:
|
||||
m = f"%{q.lower()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
func.lower(Fahrer.nachname).like(m),
|
||||
func.lower(Fahrer.vorname).like(m),
|
||||
)
|
||||
)
|
||||
fahrer = db.scalars(stmt.limit(300)).all()
|
||||
tmpl = "partials/fahrer_tabelle.html" if request.headers.get("HX-Request") else "public/fahrer_liste.html"
|
||||
return templates.TemplateResponse(request, tmpl, {"fahrer": fahrer, "q": q or ""})
|
||||
|
||||
|
||||
@router.get("/fahrer/{fahrer_id}")
|
||||
def fahrer_profil(request: Request, db: DbDep, fahrer_id: int):
|
||||
f = db.get(Fahrer, fahrer_id)
|
||||
if f is None or not f.aktiv:
|
||||
raise HTTPException(404, "Fahrer nicht gefunden")
|
||||
starts = db.scalars(
|
||||
select(Start)
|
||||
.where(Start.fahrer_id == fahrer_id)
|
||||
.options(
|
||||
selectinload(Start.ergebnis),
|
||||
selectinload(Start.klasse),
|
||||
selectinload(Start.turnier).selectinload(Turnier.veranstaltung),
|
||||
)
|
||||
).all()
|
||||
starts.sort(key=lambda s: s.turnier.veranstaltung.datum, reverse=True)
|
||||
zeiten = [
|
||||
float(s.ergebnis.summe_zeit)
|
||||
for s in starts
|
||||
if s.ergebnis and s.ergebnis.summe_zeit is not None
|
||||
]
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/fahrer_profil.html",
|
||||
{
|
||||
"fahrer": f,
|
||||
"starts": starts,
|
||||
"bestzeit": min(zeiten) if zeiten else None,
|
||||
"anzahl": len(starts),
|
||||
# noindex fuer Personenprofile (Plan §11)
|
||||
"noindex": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/meisterschaft")
|
||||
def meisterschaft_uebersicht(request: Request, db: DbDep):
|
||||
ms = db.scalars(select(Meisterschaft).order_by(Meisterschaft.id)).all()
|
||||
return templates.TemplateResponse(request, "public/meisterschaft_liste.html", {"meisterschaften": ms})
|
||||
|
||||
|
||||
@router.get("/meisterschaft/{meisterschaft_id}")
|
||||
def meisterschaft_detail(request: Request, db: DbDep, meisterschaft_id: int):
|
||||
if db.get(Meisterschaft, meisterschaft_id) is None:
|
||||
raise HTTPException(404, "Meisterschaft nicht gefunden")
|
||||
bericht = berechne_stand(db, meisterschaft_id)
|
||||
fahrer_map = {
|
||||
f.id: f for f in db.scalars(select(Fahrer).where(Fahrer.id.in_([z.fahrer_id for z in bericht.zeilen])))
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/meisterschaft_detail.html",
|
||||
{"bericht": bericht, "fahrer_map": fahrer_map},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/anzeige")
|
||||
def anzeige(request: Request):
|
||||
"""Kiosk-Anzeige am Parcours. Holt Daten per SSE, faellt bei Verbindungsverlust
|
||||
auf die zuletzt geladene Startliste zurueck (localStorage)."""
|
||||
|
||||
return templates.TemplateResponse(request, "anzeige/anzeige.html", {})
|
||||
|
||||
|
||||
@router.get("/robots.txt", response_class=PlainTextResponse)
|
||||
def robots():
|
||||
# Ergebnislisten duerfen auffindbar sein, Personenprofile Minderjaehriger nicht.
|
||||
return "User-agent: *\nDisallow: /fahrer/\nDisallow: /intern/\n"
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Jinja2-Umgebung und Filter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates"
|
||||
|
||||
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
|
||||
|
||||
|
||||
def _zeit(wert) -> str:
|
||||
if wert is None:
|
||||
return "–"
|
||||
return f"{float(wert):.2f}".replace(".", ",")
|
||||
|
||||
|
||||
def _platz(wert) -> str:
|
||||
return "–" if wert is None else str(wert)
|
||||
|
||||
|
||||
templates.env.filters["zeit"] = _zeit
|
||||
templates.env.filters["platz"] = _platz
|
||||
templates.env.globals["projekt"] = "OAMC Turnierauswertung"
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Geraete-/Zeitmessungs-Logik (AP 5, Plan §3.6, §6.2)."""
|
||||
|
||||
from oamc.geraete.service import (
|
||||
aktives_turnier,
|
||||
geraet_konfiguration,
|
||||
messung_verwerfen,
|
||||
startliste_fuer_anzeige,
|
||||
verarbeite_batch,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"aktives_turnier",
|
||||
"geraet_konfiguration",
|
||||
"messung_verwerfen",
|
||||
"startliste_fuer_anzeige",
|
||||
"verarbeite_batch",
|
||||
]
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Verarbeitung von Zeitmessungen und Daten fuer die Anzeige am Parcours.
|
||||
|
||||
Grundsaetze (Plan §3.6):
|
||||
* Idempotent ueber die vom Geraet vergebene UUID — doppelt gesendete Messungen
|
||||
erzeugen keine Dubletten.
|
||||
* Die Dauer kommt fertig vom Geraet; der Server rechnet keine Differenz.
|
||||
* Eine Messung ohne zuordenbare Startnummer wird NICHT verworfen, sondern als
|
||||
``unzugeordnet`` gespeichert.
|
||||
* Plausibilitaetsverstoesse werden MARKIERT (``unplausibel``), nicht abgelehnt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from oamc.config import Settings, get_settings
|
||||
from oamc.models import Geraet, Start, Turnier, Zeitmessung
|
||||
from oamc.models.enums import ZeitmessungStatus
|
||||
from oamc.schemas.geraete import BatchAntwort, MessungBatch, MessungQuittung
|
||||
|
||||
|
||||
def aktives_turnier(db: Session) -> Turnier | None:
|
||||
return db.scalars(select(Turnier).where(Turnier.ist_aktiv.is_(True))).first()
|
||||
|
||||
|
||||
def geraet_konfiguration(db: Session, geraet: Geraet) -> dict:
|
||||
s = get_settings()
|
||||
aktiv = aktives_turnier(db)
|
||||
extra = geraet.konfiguration or {}
|
||||
return {
|
||||
"aktives_turnier_id": aktiv.id if aktiv else None,
|
||||
"entprellung_ms": int(extra.get("entprellung_ms", 300)),
|
||||
"messpunkte": list(extra.get("messpunkte", ["durchgang-1"])),
|
||||
"plausibilitaet_min_s": float(extra.get("plausibilitaet_min_s", s.mess_min_sekunden)),
|
||||
"plausibilitaet_max_s": float(extra.get("plausibilitaet_max_s", s.mess_max_sekunden)),
|
||||
"extra": {k: v for k, v in extra.items() if k not in {
|
||||
"entprellung_ms", "messpunkte", "plausibilitaet_min_s", "plausibilitaet_max_s"
|
||||
}},
|
||||
}
|
||||
|
||||
|
||||
def _startnummer_aufloesen(db: Session, turnier_id: int, startnummer: int | None) -> int | None:
|
||||
if startnummer is None:
|
||||
return None
|
||||
s = db.scalars(
|
||||
select(Start).where(Start.turnier_id == turnier_id, Start.startnummer == startnummer)
|
||||
).first()
|
||||
return s.id if s else None
|
||||
|
||||
|
||||
def verarbeite_batch(
|
||||
db: Session, geraet: Geraet, batch: MessungBatch, settings: Settings | None = None
|
||||
) -> BatchAntwort:
|
||||
s = settings or get_settings()
|
||||
quittungen: list[MessungQuittung] = []
|
||||
zaehler = {"angenommen": 0, "duplikate": 0, "unzugeordnet": 0, "unplausibel": 0}
|
||||
|
||||
for m in batch.messungen:
|
||||
# Idempotenz: existiert die UUID schon, wird sie nur quittiert.
|
||||
bestehend = db.get(Zeitmessung, m.id)
|
||||
if bestehend is not None:
|
||||
zaehler["duplikate"] += 1
|
||||
quittungen.append(
|
||||
MessungQuittung(
|
||||
id=m.id, status="duplikat", start_id=bestehend.start_id,
|
||||
hinweis="bereits gespeichert",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
turnier = db.get(Turnier, m.turnier_id)
|
||||
if turnier is None:
|
||||
quittungen.append(
|
||||
MessungQuittung(id=m.id, status="fehler", hinweis=f"Turnier {m.turnier_id} unbekannt")
|
||||
)
|
||||
continue
|
||||
|
||||
start_id = _startnummer_aufloesen(db, m.turnier_id, m.startnummer_gemeldet)
|
||||
dauer = Decimal(m.dauer_sekunden)
|
||||
unplausibel = not (Decimal(str(s.mess_min_sekunden)) <= dauer <= Decimal(str(s.mess_max_sekunden)))
|
||||
|
||||
if unplausibel:
|
||||
status = ZeitmessungStatus.UNPLAUSIBEL
|
||||
zaehler["unplausibel"] += 1
|
||||
hinweis = f"Dauer {dauer}s ausserhalb {s.mess_min_sekunden}-{s.mess_max_sekunden}s"
|
||||
elif start_id is None:
|
||||
status = ZeitmessungStatus.UNZUGEORDNET
|
||||
zaehler["unzugeordnet"] += 1
|
||||
hinweis = "Startnummer nicht aufloesbar — zur manuellen Zuordnung"
|
||||
else:
|
||||
status = ZeitmessungStatus.GUELTIG
|
||||
zaehler["angenommen"] += 1
|
||||
hinweis = None
|
||||
|
||||
jetzt = datetime.now(UTC)
|
||||
nachgeliefert = m.gemessen_am < jetzt.replace(microsecond=0) and (
|
||||
(jetzt - m.gemessen_am).total_seconds() > 120
|
||||
)
|
||||
db.add(
|
||||
Zeitmessung(
|
||||
id=m.id,
|
||||
geraet_id=geraet.id,
|
||||
turnier_id=m.turnier_id,
|
||||
start_id=start_id,
|
||||
startnummer_gemeldet=m.startnummer_gemeldet,
|
||||
messpunkt=m.messpunkt,
|
||||
dauer_sekunden=dauer,
|
||||
gemessen_am=m.gemessen_am,
|
||||
monotonic_ns=m.monotonic_ns,
|
||||
nachgeliefert=nachgeliefert,
|
||||
status=status,
|
||||
verworfen_grund=None,
|
||||
roh=m.roh,
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
quittungen.append(
|
||||
MessungQuittung(
|
||||
id=m.id,
|
||||
status={
|
||||
ZeitmessungStatus.GUELTIG: "angenommen",
|
||||
ZeitmessungStatus.UNZUGEORDNET: "unzugeordnet",
|
||||
ZeitmessungStatus.UNPLAUSIBEL: "unplausibel",
|
||||
}[status],
|
||||
start_id=start_id,
|
||||
hinweis=hinweis,
|
||||
)
|
||||
)
|
||||
|
||||
geraet.letzte_meldung_am = datetime.now(UTC)
|
||||
return BatchAntwort(
|
||||
quittungen=quittungen,
|
||||
angenommen=zaehler["angenommen"],
|
||||
duplikate=zaehler["duplikate"],
|
||||
unzugeordnet=zaehler["unzugeordnet"],
|
||||
unplausibel=zaehler["unplausibel"],
|
||||
)
|
||||
|
||||
|
||||
def messung_verwerfen(db: Session, messung_id, grund: str) -> Zeitmessung:
|
||||
m = db.get(Zeitmessung, messung_id)
|
||||
if m is None:
|
||||
raise ValueError("Messung nicht gefunden")
|
||||
m.status = ZeitmessungStatus.VERWORFEN
|
||||
m.verworfen_grund = grund # bleibt mit Grund gespeichert (Plan §3.6e)
|
||||
db.flush()
|
||||
return m
|
||||
|
||||
|
||||
def startliste_fuer_anzeige(db: Session, turnier_id: int) -> list[dict]:
|
||||
starts = db.scalars(
|
||||
select(Start)
|
||||
.where(Start.turnier_id == turnier_id)
|
||||
.options(selectinload(Start.fahrer), selectinload(Start.klasse))
|
||||
.order_by(Start.nennreihenfolge.nulls_last(), Start.startnummer)
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"startnummer": s.startnummer,
|
||||
"name": s.fahrer.anzeigename if s.fahrer else "?",
|
||||
"klasse": s.klasse.code if s.klasse else None,
|
||||
"fahrzeug": s.fahrzeug,
|
||||
"status": s.status.value,
|
||||
}
|
||||
for s in starts
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Import: API-Bulk-Uploads und Excel-Dateien (AP 4, AP 8)."""
|
||||
|
||||
from oamc.importer.ingest import (
|
||||
finde_klasse,
|
||||
finde_verein,
|
||||
verarbeite_ergebnisse,
|
||||
verarbeite_starts,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"finde_klasse",
|
||||
"finde_verein",
|
||||
"verarbeite_ergebnisse",
|
||||
"verarbeite_starts",
|
||||
]
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Bulk-Ingest: Nennliste (starts) und Ergebnisse.
|
||||
|
||||
Regeln (Plan §6.1):
|
||||
* Idempotent ueber ``idempotenz_schluessel`` (import_job).
|
||||
* Zweistufig: ``dry_run=True`` liefert eine Validierungsvorschau, erst danach
|
||||
wird geschrieben.
|
||||
* ``gesamt_erwartet``/``platzierung_erwartet`` werden nachgerechnet, Abweichungen
|
||||
als Warnung gemeldet — nie uebernommen.
|
||||
* Jeder Aufruf landet mit Roh-Payload in ``import_job``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from oamc.matching import MatchStatus, match_oder_neu, normalisiere
|
||||
from oamc.models import (
|
||||
Ergebnis,
|
||||
ImportJob,
|
||||
Klasse,
|
||||
Start,
|
||||
Turnier,
|
||||
Verein,
|
||||
Wertungsklasse,
|
||||
)
|
||||
from oamc.models.enums import ImportStatus, StartStatus
|
||||
from oamc.schemas.ingest import (
|
||||
AbweichungInfo,
|
||||
ErgebnisBulk,
|
||||
FahrerMatchInfo,
|
||||
ImportBericht,
|
||||
StartBulk,
|
||||
)
|
||||
from oamc.wertung.service import turnier_auswerten
|
||||
|
||||
|
||||
def finde_verein(db: Session, name: str | None) -> Verein | None:
|
||||
if not name:
|
||||
return None
|
||||
n = normalisiere(name)
|
||||
for v in db.scalars(select(Verein)).all():
|
||||
if normalisiere(v.name) == n or (v.kurzname and normalisiere(v.kurzname) == n):
|
||||
return v
|
||||
# enthaltend (z. B. "Reinheim" in "OAMC Reinheim e.V. im ADAC")
|
||||
for v in db.scalars(select(Verein)).all():
|
||||
if n and (n in normalisiere(v.name) or n in normalisiere(v.kurzname or "")):
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def finde_klasse(db: Session, code: str, saison_jahr: int) -> Klasse | None:
|
||||
if not code:
|
||||
return None
|
||||
ziel = code.strip().upper().replace(" ", " ")
|
||||
for k in db.scalars(select(Klasse).where(Klasse.saison_jahr == saison_jahr)).all():
|
||||
if k.code.upper() == ziel or k.code.upper().replace(" ", "") == ziel.replace(" ", ""):
|
||||
return k
|
||||
return None
|
||||
|
||||
|
||||
def _saison_fuer_turnier(db: Session, turnier: Turnier) -> int:
|
||||
return turnier.veranstaltung.datum.year
|
||||
|
||||
|
||||
def _idempotenz_treffer(db: Session, schluessel: str | None) -> ImportJob | None:
|
||||
if not schluessel:
|
||||
return None
|
||||
return db.scalars(
|
||||
select(ImportJob).where(
|
||||
ImportJob.idempotenz_schluessel == schluessel,
|
||||
ImportJob.status.in_([ImportStatus.ANGENOMMEN, ImportStatus.TEILWEISE]),
|
||||
)
|
||||
).first()
|
||||
|
||||
|
||||
def _wertungsklasse(db: Session, turnier_id: int, bezeichnung: str | None) -> Wertungsklasse | None:
|
||||
if not bezeichnung:
|
||||
return None
|
||||
wk = db.scalars(
|
||||
select(Wertungsklasse).where(
|
||||
Wertungsklasse.turnier_id == turnier_id,
|
||||
func.lower(Wertungsklasse.bezeichnung) == bezeichnung.strip().lower(),
|
||||
)
|
||||
).first()
|
||||
if wk is None:
|
||||
wk = Wertungsklasse(turnier_id=turnier_id, bezeichnung=bezeichnung.strip(), klasse_ids=[])
|
||||
db.add(wk)
|
||||
db.flush()
|
||||
return wk
|
||||
|
||||
|
||||
def verarbeite_starts(
|
||||
db: Session, turnier_id: int, payload: StartBulk, *, dry_run: bool, angelegt_von: str | None
|
||||
) -> ImportBericht:
|
||||
turnier = db.get(Turnier, turnier_id)
|
||||
if turnier is None:
|
||||
raise ValueError(f"Turnier {turnier_id} nicht gefunden")
|
||||
saison_jahr = _saison_fuer_turnier(db, turnier)
|
||||
|
||||
vorhandener = _idempotenz_treffer(db, payload.idempotenz_schluessel)
|
||||
if vorhandener is not None:
|
||||
return ImportBericht(
|
||||
import_job_id=vorhandener.id, dry_run=dry_run, idempotent_uebersprungen=True
|
||||
)
|
||||
|
||||
bericht = ImportBericht(dry_run=dry_run)
|
||||
for eintrag in payload.starts:
|
||||
verein = finde_verein(db, eintrag.fahrer.verein)
|
||||
match = match_oder_neu(
|
||||
db,
|
||||
nachname=eintrag.fahrer.nachname,
|
||||
vorname=eintrag.fahrer.vorname,
|
||||
geburtsjahr=eintrag.fahrer.geburtsjahr,
|
||||
geschlecht=eintrag.fahrer.geschlecht,
|
||||
verein_id=verein.id if verein else None,
|
||||
anlegen=not dry_run,
|
||||
)
|
||||
bericht.fahrer_matches.append(
|
||||
FahrerMatchInfo(
|
||||
startnummer=eintrag.startnummer,
|
||||
eingabe=f"{eintrag.fahrer.nachname} {eintrag.fahrer.vorname}",
|
||||
status=match.status.value,
|
||||
fahrer_id=match.fahrer.id if match.fahrer else None,
|
||||
vorschlaege=[v.__dict__ for v in match.vorschlaege],
|
||||
)
|
||||
)
|
||||
if match.status == MatchStatus.NEU:
|
||||
bericht.neue_fahrer += 1
|
||||
|
||||
klasse = finde_klasse(db, eintrag.klasse, saison_jahr)
|
||||
if klasse is None:
|
||||
bericht.warnungen.append(
|
||||
f"Startnr. {eintrag.startnummer}: Klasse {eintrag.klasse!r} unbekannt"
|
||||
)
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
bericht.angelegt += 1
|
||||
continue
|
||||
|
||||
wk = _wertungsklasse(db, turnier_id, eintrag.wertungsklasse)
|
||||
vorhanden = db.scalars(
|
||||
select(Start).where(
|
||||
Start.turnier_id == turnier_id, Start.startnummer == eintrag.startnummer
|
||||
)
|
||||
).first()
|
||||
if vorhanden is None:
|
||||
db.add(
|
||||
Start(
|
||||
turnier_id=turnier_id,
|
||||
fahrer_id=match.fahrer.id,
|
||||
klasse_id=klasse.id,
|
||||
wertungsklasse_id=wk.id if wk else None,
|
||||
startnummer=eintrag.startnummer,
|
||||
fahrzeug=eintrag.fahrzeug,
|
||||
nenngeld_bezahlt=eintrag.nenngeld_bezahlt,
|
||||
nennreihenfolge=eintrag.nennreihenfolge,
|
||||
status=eintrag.status,
|
||||
)
|
||||
)
|
||||
bericht.angelegt += 1
|
||||
else:
|
||||
vorhanden.fahrer_id = match.fahrer.id
|
||||
vorhanden.klasse_id = klasse.id
|
||||
vorhanden.wertungsklasse_id = wk.id if wk else vorhanden.wertungsklasse_id
|
||||
vorhanden.fahrzeug = eintrag.fahrzeug or vorhanden.fahrzeug
|
||||
bericht.aktualisiert += 1
|
||||
|
||||
if not dry_run:
|
||||
job = ImportJob(
|
||||
quelle=payload.quelle,
|
||||
idempotenz_schluessel=payload.idempotenz_schluessel,
|
||||
roh_payload=payload.model_dump(mode="json"),
|
||||
ergebnis_bericht=bericht.model_dump(mode="json"),
|
||||
status=ImportStatus.ANGENOMMEN,
|
||||
angelegt_von=angelegt_von,
|
||||
)
|
||||
db.add(job)
|
||||
db.flush()
|
||||
bericht.import_job_id = job.id
|
||||
return bericht
|
||||
|
||||
|
||||
def verarbeite_ergebnisse(
|
||||
db: Session,
|
||||
turnier_id: int,
|
||||
payload: ErgebnisBulk,
|
||||
*,
|
||||
dry_run: bool,
|
||||
angelegt_von: str | None,
|
||||
) -> ImportBericht:
|
||||
turnier = db.get(Turnier, turnier_id)
|
||||
if turnier is None:
|
||||
raise ValueError(f"Turnier {turnier_id} nicht gefunden")
|
||||
saison_jahr = _saison_fuer_turnier(db, turnier)
|
||||
|
||||
vorhandener = _idempotenz_treffer(db, payload.idempotenz_schluessel)
|
||||
if vorhandener is not None:
|
||||
return ImportBericht(
|
||||
import_job_id=vorhandener.id, dry_run=dry_run, idempotent_uebersprungen=True
|
||||
)
|
||||
|
||||
bericht = ImportBericht(dry_run=dry_run)
|
||||
|
||||
for eintrag in payload.ergebnisse:
|
||||
start = db.scalars(
|
||||
select(Start).where(
|
||||
Start.turnier_id == turnier_id, Start.startnummer == eintrag.startnummer
|
||||
)
|
||||
).first()
|
||||
|
||||
if start is None:
|
||||
if eintrag.fahrer is None or eintrag.klasse is None:
|
||||
bericht.warnungen.append(
|
||||
f"Startnr. {eintrag.startnummer}: kein Start und keine Fahrer-/Klassenangabe "
|
||||
"zum Anlegen"
|
||||
)
|
||||
continue
|
||||
verein = finde_verein(db, eintrag.fahrer.verein)
|
||||
match = match_oder_neu(
|
||||
db,
|
||||
nachname=eintrag.fahrer.nachname,
|
||||
vorname=eintrag.fahrer.vorname,
|
||||
geburtsjahr=eintrag.fahrer.geburtsjahr,
|
||||
geschlecht=eintrag.fahrer.geschlecht,
|
||||
verein_id=verein.id if verein else None,
|
||||
anlegen=not dry_run,
|
||||
)
|
||||
bericht.fahrer_matches.append(
|
||||
FahrerMatchInfo(
|
||||
startnummer=eintrag.startnummer,
|
||||
eingabe=f"{eintrag.fahrer.nachname} {eintrag.fahrer.vorname}",
|
||||
status=match.status.value,
|
||||
fahrer_id=match.fahrer.id if match.fahrer else None,
|
||||
vorschlaege=[v.__dict__ for v in match.vorschlaege],
|
||||
)
|
||||
)
|
||||
if match.status == MatchStatus.NEU:
|
||||
bericht.neue_fahrer += 1
|
||||
klasse = finde_klasse(db, eintrag.klasse, saison_jahr)
|
||||
if klasse is None:
|
||||
bericht.warnungen.append(
|
||||
f"Startnr. {eintrag.startnummer}: Klasse {eintrag.klasse!r} unbekannt"
|
||||
)
|
||||
continue
|
||||
if dry_run:
|
||||
bericht.angelegt += 1
|
||||
continue
|
||||
wk = _wertungsklasse(db, turnier_id, eintrag.wertungsklasse)
|
||||
start = Start(
|
||||
turnier_id=turnier_id,
|
||||
fahrer_id=match.fahrer.id,
|
||||
klasse_id=klasse.id,
|
||||
wertungsklasse_id=wk.id if wk else None,
|
||||
startnummer=eintrag.startnummer,
|
||||
status=StartStatus.GESTARTET,
|
||||
)
|
||||
db.add(start)
|
||||
db.flush()
|
||||
bericht.angelegt += 1
|
||||
else:
|
||||
bericht.aktualisiert += 1
|
||||
if eintrag.wertungsklasse and not dry_run:
|
||||
wk = _wertungsklasse(db, turnier_id, eintrag.wertungsklasse)
|
||||
start.wertungsklasse_id = wk.id if wk else start.wertungsklasse_id
|
||||
if start.status == StartStatus.GENANNT and not dry_run:
|
||||
start.status = StartStatus.GESTARTET
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
erg = start.ergebnis or Ergebnis(start_id=start.id)
|
||||
if start.ergebnis is None:
|
||||
start.ergebnis = erg
|
||||
db.add(erg)
|
||||
if eintrag.summe_zeit is not None:
|
||||
erg.summe_zeit = eintrag.summe_zeit
|
||||
erg.zeit_vollstaendig = eintrag.zeit_vollstaendig
|
||||
if eintrag.summe_fehler is not None:
|
||||
erg.summe_fehler = eintrag.summe_fehler
|
||||
erg.fehler_vollstaendig = eintrag.fehler_vollstaendig
|
||||
erg.erfasst_von = angelegt_von
|
||||
# Erwartungswerte fuer die Nachrechnung ablegen (werden von der Engine
|
||||
# gegen die Berechnung gestellt, nicht als Wahrheit uebernommen).
|
||||
if eintrag.gesamt_erwartet is not None:
|
||||
erg.gesamt = eintrag.gesamt_erwartet
|
||||
if eintrag.platzierung_erwartet is not None:
|
||||
erg.platzierung = eintrag.platzierung_erwartet
|
||||
|
||||
if dry_run:
|
||||
db.rollback()
|
||||
return bericht
|
||||
|
||||
# Nachrechnen + Abweichungen melden
|
||||
auswertung = turnier_auswerten(db, turnier_id, zeit_aus_messungen=False, schreiben=True)
|
||||
for a in auswertung.abweichungen:
|
||||
bericht.abweichungen.append(
|
||||
AbweichungInfo(
|
||||
startnummer=a.startnummer, feld=a.feld, erwartet=a.erwartet, berechnet=a.berechnet
|
||||
)
|
||||
)
|
||||
if auswertung.abweichungen:
|
||||
bericht.warnungen.append(
|
||||
f"{len(auswertung.abweichungen)} Abweichung(en) zwischen Import und Neuberechnung"
|
||||
)
|
||||
|
||||
job = ImportJob(
|
||||
quelle=payload.quelle,
|
||||
idempotenz_schluessel=payload.idempotenz_schluessel,
|
||||
roh_payload=payload.model_dump(mode="json"),
|
||||
ergebnis_bericht=bericht.model_dump(mode="json"),
|
||||
status=ImportStatus.ANGENOMMEN if not bericht.warnungen else ImportStatus.TEILWEISE,
|
||||
angelegt_von=angelegt_von,
|
||||
)
|
||||
db.add(job)
|
||||
db.flush()
|
||||
bericht.import_job_id = job.id
|
||||
return bericht
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Excel-Import fuer Nennlisten/Ergebnislisten (AP 4 / AP 8).
|
||||
|
||||
Die realen ``.xlsx``-Vorlagen liegen noch nicht vor (Plan O-12). Dieser Leser
|
||||
arbeitet spaltenbasiert ueber eine **konfigurierbare Kopfzeilen-Zuordnung** und
|
||||
kann sowohl als Nennliste (-> StartBulk) als auch als Ergebnisliste
|
||||
(-> ErgebnisBulk) interpretiert werden. Sobald die echten Vorlagen da sind, ist
|
||||
nur ``SPALTEN_ALIAS`` anzupassen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from oamc.schemas.ingest import (
|
||||
ErgebnisBulk,
|
||||
ErgebnisEintrag,
|
||||
FahrerRef,
|
||||
StartBulk,
|
||||
StartCreate,
|
||||
)
|
||||
|
||||
# moegliche Kopfzeilen-Bezeichnungen -> internes Feld
|
||||
SPALTEN_ALIAS: dict[str, str] = {
|
||||
"start-nr.": "startnummer",
|
||||
"startnr": "startnummer",
|
||||
"startnummer": "startnummer",
|
||||
"voller name": "voller_name",
|
||||
"name": "voller_name",
|
||||
"nachname": "nachname",
|
||||
"vorname": "vorname",
|
||||
"regionalclub": "verein",
|
||||
"verein": "verein",
|
||||
"club": "verein",
|
||||
"klasse": "klasse",
|
||||
"wertungsklasse": "wertungsklasse",
|
||||
"geschlecht": "geschlecht",
|
||||
"geburtsjahr": "geburtsjahr",
|
||||
"jahrgang": "geburtsjahr",
|
||||
"fahrzeug": "fahrzeug",
|
||||
"summe zeit": "summe_zeit",
|
||||
"zeit": "summe_zeit",
|
||||
"summe fehler": "summe_fehler",
|
||||
"fehler": "summe_fehler",
|
||||
"gesamt": "gesamt_erwartet",
|
||||
"platzierung": "platzierung_erwartet",
|
||||
"platz": "platzierung_erwartet",
|
||||
}
|
||||
|
||||
|
||||
def _num(wert) -> Decimal | None:
|
||||
if wert in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(wert).replace(",", "."))
|
||||
except InvalidOperation:
|
||||
return None
|
||||
|
||||
|
||||
def _int(wert) -> int | None:
|
||||
d = _num(wert)
|
||||
return int(d) if d is not None else None
|
||||
|
||||
|
||||
def _split_name(zeile: dict) -> tuple[str, str]:
|
||||
if zeile.get("nachname") or zeile.get("vorname"):
|
||||
return str(zeile.get("nachname", "")).strip(), str(zeile.get("vorname", "")).strip()
|
||||
voll = str(zeile.get("voller_name", "")).strip()
|
||||
# Ergebnisliste fuehrt "Nachname Vorname" (Plan §3.3)
|
||||
teile = voll.split()
|
||||
if len(teile) >= 2:
|
||||
return teile[0], " ".join(teile[1:])
|
||||
return voll, ""
|
||||
|
||||
|
||||
def _zeilen(daten: bytes, blatt: str | None):
|
||||
wb = load_workbook(io.BytesIO(daten), read_only=True, data_only=True)
|
||||
ws = wb[blatt] if blatt else wb.active
|
||||
rows = ws.iter_rows(values_only=True)
|
||||
kopf_roh = next(rows)
|
||||
kopf = [
|
||||
SPALTEN_ALIAS.get(str(c).strip().lower()) if c is not None else None for c in kopf_roh
|
||||
]
|
||||
for row in rows:
|
||||
if row is None or all(c in (None, "") for c in row):
|
||||
continue
|
||||
yield {feld: row[i] for i, feld in enumerate(kopf) if feld and i < len(row)}
|
||||
|
||||
|
||||
def lese_nennliste(daten: bytes, *, quelle: str, idempotenz_schluessel: str | None,
|
||||
blatt: str | None = None) -> StartBulk:
|
||||
starts: list[StartCreate] = []
|
||||
for z in _zeilen(daten, blatt):
|
||||
nummer = _int(z.get("startnummer"))
|
||||
if nummer is None:
|
||||
continue
|
||||
nach, vor = _split_name(z)
|
||||
starts.append(
|
||||
StartCreate(
|
||||
startnummer=nummer,
|
||||
fahrer=FahrerRef(
|
||||
nachname=nach, vorname=vor,
|
||||
geburtsjahr=_int(z.get("geburtsjahr")),
|
||||
verein=(str(z["verein"]).strip() if z.get("verein") else None),
|
||||
),
|
||||
klasse=str(z.get("klasse", "")).strip(),
|
||||
wertungsklasse=(str(z["wertungsklasse"]).strip() if z.get("wertungsklasse") else None),
|
||||
fahrzeug=(str(z["fahrzeug"]).strip() if z.get("fahrzeug") else None),
|
||||
)
|
||||
)
|
||||
return StartBulk(quelle=quelle, idempotenz_schluessel=idempotenz_schluessel, starts=starts)
|
||||
|
||||
|
||||
def lese_ergebnisliste(daten: bytes, *, quelle: str, idempotenz_schluessel: str,
|
||||
blatt: str | None = None) -> ErgebnisBulk:
|
||||
eintraege: list[ErgebnisEintrag] = []
|
||||
for z in _zeilen(daten, blatt):
|
||||
nummer = _int(z.get("startnummer"))
|
||||
if nummer is None:
|
||||
continue
|
||||
nach, vor = _split_name(z)
|
||||
zeit = _num(z.get("summe_zeit"))
|
||||
fehler = _int(z.get("summe_fehler"))
|
||||
if zeit is None and fehler is None:
|
||||
continue
|
||||
eintraege.append(
|
||||
ErgebnisEintrag(
|
||||
startnummer=nummer,
|
||||
fahrer=FahrerRef(
|
||||
nachname=nach, vorname=vor,
|
||||
geburtsjahr=_int(z.get("geburtsjahr")),
|
||||
verein=(str(z["verein"]).strip() if z.get("verein") else None),
|
||||
),
|
||||
klasse=(str(z["klasse"]).strip() if z.get("klasse") else None),
|
||||
wertungsklasse=(str(z["wertungsklasse"]).strip() if z.get("wertungsklasse") else None),
|
||||
summe_zeit=zeit,
|
||||
summe_fehler=fehler,
|
||||
gesamt_erwartet=_num(z.get("gesamt_erwartet")),
|
||||
platzierung_erwartet=_int(z.get("platzierung_erwartet")),
|
||||
)
|
||||
)
|
||||
return ErgebnisBulk(
|
||||
quelle=quelle, idempotenz_schluessel=idempotenz_schluessel, ergebnisse=eintraege
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""FastAPI-Anwendung. Wird von uvicorn hinter nginx betrieben (Plan §5.2/§5.3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from oamc import __version__
|
||||
from oamc.api.v1 import api_v1
|
||||
from oamc.config import get_settings
|
||||
from oamc.frontend import intern as frontend_intern
|
||||
from oamc.frontend import public as frontend_public
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
|
||||
_BESCHREIBUNG = """
|
||||
Auswertung von ADAC Motorrad-Turnieren des OAMC Reinheim.
|
||||
|
||||
* **Read** (`GET /api/v1/...`) — oeffentlich, ohne Key
|
||||
* **Ingest** (`POST /api/v1/...`) — Header `X-API-Key`
|
||||
* **Geraete** (`/api/v1/geraete/...`, `/api/v1/zeitmessungen`) — `Authorization: Bearer <geraete-token>`
|
||||
* **Admin** (`/api/v1/admin/...`) — interne Session
|
||||
|
||||
Fachliche Grundlagen: `plan.md`. Wertungsregeln sind aus PDFs abgeleitet und vor
|
||||
Produktivgang gegen die ADAC-Turnierordnung 2026 zu verifizieren (Plan §10).
|
||||
"""
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(
|
||||
title="OAMC Turnierauswertung",
|
||||
version=__version__,
|
||||
description=_BESCHREIBUNG,
|
||||
docs_url="/docs",
|
||||
redoc_url=None,
|
||||
)
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
app.include_router(api_v1)
|
||||
app.include_router(frontend_public.router)
|
||||
app.include_router(frontend_intern.router)
|
||||
|
||||
@app.get("/healthz", include_in_schema=False)
|
||||
def healthz() -> JSONResponse:
|
||||
return JSONResponse({"status": "ok", "version": __version__, "env": settings.env})
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Fahrer-Matching beim Import (Plan §6.4) — der heikelste Teil.
|
||||
|
||||
1. Exakter Match auf (Nachname, Vorname, Geburtsjahr), normalisiert.
|
||||
2. Unscharfer Match (pg_trgm) -> Vorschlag, nie automatisch.
|
||||
3. Kein Match -> neuer Fahrer, im Report als "NEU" markiert.
|
||||
4. Merge-Funktion fuer nachtraeglich erkannte Dubletten: ``fahrer_zusammenfuehren``.
|
||||
"""
|
||||
|
||||
from oamc.matching.fahrer import (
|
||||
MatchErgebnis,
|
||||
MatchStatus,
|
||||
fahrer_zusammenfuehren,
|
||||
finde_aehnliche,
|
||||
finde_exakt,
|
||||
match_oder_neu,
|
||||
normalisiere,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MatchErgebnis",
|
||||
"MatchStatus",
|
||||
"normalisiere",
|
||||
"finde_exakt",
|
||||
"finde_aehnliche",
|
||||
"match_oder_neu",
|
||||
"fahrer_zusammenfuehren",
|
||||
]
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Normalisierung, exakter/unscharfer Match, Merge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import func, select, text, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from oamc.models import Fahrer, Start
|
||||
|
||||
|
||||
def normalisiere(wert: str | None) -> str:
|
||||
"""Unicode NFKD, Kleinschreibung, Bindestriche/Mehrfach-Whitespace weg.
|
||||
|
||||
"Gál-Szász" -> "gal szasz". So werden Namensvarianten beim Import
|
||||
zusammengefuehrt, ohne Information im Datenbestand zu verlieren.
|
||||
"""
|
||||
|
||||
if not wert:
|
||||
return ""
|
||||
zerlegt = unicodedata.normalize("NFKD", wert)
|
||||
ohne_akzente = "".join(c for c in zerlegt if not unicodedata.combining(c))
|
||||
ersetzt = ohne_akzente.replace("-", " ").replace(".", " ")
|
||||
return " ".join(ersetzt.lower().split())
|
||||
|
||||
|
||||
class MatchStatus(str, enum.Enum):
|
||||
EXAKT = "exakt"
|
||||
UNSCHARF = "unscharf" # Vorschlag — Bestaetigung noetig
|
||||
NEU = "neu"
|
||||
|
||||
|
||||
@dataclass
|
||||
class KandidatVorschlag:
|
||||
fahrer_id: int
|
||||
voller_name: str
|
||||
geburtsjahr: int | None
|
||||
verein: str | None
|
||||
aehnlichkeit: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchErgebnis:
|
||||
status: MatchStatus
|
||||
fahrer: Fahrer | None = None
|
||||
vorschlaege: list[KandidatVorschlag] = field(default_factory=list)
|
||||
neu_angelegt: bool = False
|
||||
|
||||
|
||||
def finde_exakt(
|
||||
db: Session, nachname: str, vorname: str, geburtsjahr: int | None
|
||||
) -> Fahrer | None:
|
||||
n, v = normalisiere(nachname), normalisiere(vorname)
|
||||
# Vorfilter grob ueber die ersten Namensbuchstaben, exakter Vergleich dann in
|
||||
# Python (Normalisierung deckt Akzente/Bindestriche ab; keine DB-Extension noetig).
|
||||
praefix = normalisiere(nachname)[:3]
|
||||
kandidaten = db.scalars(
|
||||
select(Fahrer).where(func.lower(Fahrer.nachname).like(f"{praefix}%"))
|
||||
).all()
|
||||
if not kandidaten:
|
||||
kandidaten = db.scalars(select(Fahrer)).all()
|
||||
for f in kandidaten:
|
||||
if (
|
||||
normalisiere(f.nachname) == n
|
||||
and normalisiere(f.vorname) == v
|
||||
and (geburtsjahr is None or f.geburtsjahr is None or f.geburtsjahr == geburtsjahr)
|
||||
):
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def finde_aehnliche(
|
||||
db: Session,
|
||||
nachname: str,
|
||||
vorname: str,
|
||||
*,
|
||||
limit: int = 5,
|
||||
schwelle: float = 0.35,
|
||||
) -> list[KandidatVorschlag]:
|
||||
"""Trigramm-Aehnlichkeit auf ``lower(nachname || ' ' || vorname)`` (GIN-Index)."""
|
||||
|
||||
suchbegriff = f"{nachname} {vorname}".lower().strip()
|
||||
voller_name = func.lower(func.concat(Fahrer.nachname, " ", Fahrer.vorname))
|
||||
sim = func.similarity(voller_name, suchbegriff)
|
||||
stmt = (
|
||||
select(Fahrer, sim.label("sim"))
|
||||
.where(sim >= schwelle)
|
||||
.order_by(text("sim DESC"))
|
||||
.limit(limit)
|
||||
)
|
||||
treffer = db.execute(stmt).all()
|
||||
return [
|
||||
KandidatVorschlag(
|
||||
fahrer_id=f.id,
|
||||
voller_name=f.voller_name,
|
||||
geburtsjahr=f.geburtsjahr,
|
||||
verein=f.verein.name if f.verein else None,
|
||||
aehnlichkeit=round(float(sim), 3),
|
||||
)
|
||||
for f, sim in treffer
|
||||
]
|
||||
|
||||
|
||||
def match_oder_neu(
|
||||
db: Session,
|
||||
*,
|
||||
nachname: str,
|
||||
vorname: str,
|
||||
geburtsjahr: int | None = None,
|
||||
geschlecht=None,
|
||||
verein_id: int | None = None,
|
||||
anlegen: bool = True,
|
||||
) -> MatchErgebnis:
|
||||
exakt = finde_exakt(db, nachname, vorname, geburtsjahr)
|
||||
if exakt is not None:
|
||||
return MatchErgebnis(status=MatchStatus.EXAKT, fahrer=exakt)
|
||||
|
||||
vorschlaege = finde_aehnliche(db, nachname, vorname)
|
||||
if vorschlaege and not anlegen:
|
||||
return MatchErgebnis(status=MatchStatus.UNSCHARF, vorschlaege=vorschlaege)
|
||||
|
||||
if not anlegen:
|
||||
return MatchErgebnis(status=MatchStatus.NEU)
|
||||
|
||||
neu = Fahrer(
|
||||
nachname=nachname.strip(),
|
||||
vorname=vorname.strip(),
|
||||
geburtsjahr=geburtsjahr,
|
||||
geschlecht=geschlecht,
|
||||
verein_id=verein_id,
|
||||
)
|
||||
db.add(neu)
|
||||
db.flush()
|
||||
return MatchErgebnis(
|
||||
status=MatchStatus.NEU, fahrer=neu, vorschlaege=vorschlaege, neu_angelegt=True
|
||||
)
|
||||
|
||||
|
||||
def fahrer_zusammenfuehren(db: Session, ziel_id: int, quelle_id: int) -> int:
|
||||
"""Alle Starts von ``quelle_id`` auf ``ziel_id`` umhaengen, Quelle deaktivieren.
|
||||
|
||||
Rueckgabe: Anzahl umgehaengter Starts. Bewusst kein hartes DELETE — die Quelle
|
||||
bleibt als inaktiver Datensatz mit Verweis erhalten (Nachvollziehbarkeit).
|
||||
"""
|
||||
|
||||
if ziel_id == quelle_id:
|
||||
raise ValueError("Ziel und Quelle sind identisch")
|
||||
ziel = db.get(Fahrer, ziel_id)
|
||||
quelle = db.get(Fahrer, quelle_id)
|
||||
if ziel is None or quelle is None:
|
||||
raise ValueError("Ziel- oder Quell-Fahrer nicht gefunden")
|
||||
|
||||
anzahl = db.execute(
|
||||
update(Start).where(Start.fahrer_id == quelle_id).values(fahrer_id=ziel_id)
|
||||
).rowcount
|
||||
quelle.aktiv = False
|
||||
quelle.notiz = ((quelle.notiz or "") + f"\n[merge] zusammengefuehrt nach Fahrer {ziel_id}").strip()
|
||||
db.flush()
|
||||
return anzahl or 0
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Meisterschafts-/Pokalwertung (HTH) — AP 3.
|
||||
|
||||
BLOCKIERT durch offene Frage O-1: die echte Punktetabelle (Platz -> Punkte) ist
|
||||
noch nicht bekannt. Bis dahin arbeitet dieses Modul mit
|
||||
``PLATZHALTER_PUNKTETABELLE`` (klar als solche markiert) bzw. mit der Tabelle aus
|
||||
``meisterschaft.regelwerk['punkte']``.
|
||||
"""
|
||||
|
||||
from oamc.meisterschaft.berechnung import (
|
||||
PLATZHALTER_PUNKTETABELLE,
|
||||
StandZeile,
|
||||
berechne_stand,
|
||||
endlauf_qualifikation,
|
||||
punkte_fuer_platz,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PLATZHALTER_PUNKTETABELLE",
|
||||
"StandZeile",
|
||||
"berechne_stand",
|
||||
"endlauf_qualifikation",
|
||||
"punkte_fuer_platz",
|
||||
]
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Meisterschaftsstand, Streichresultate, Tie-Break, Endlaufqualifikation.
|
||||
|
||||
Regeln (Plan §3.4):
|
||||
* Erwachsene: ein Gesamtklassement A + S (klassenuebergreifend).
|
||||
* Jugend: Pokalwertung klassenweise J 1 - J 5.
|
||||
* Streichresultate nach Tabelle in ``saison.streichresultate_tabelle``
|
||||
("Turniere in der Saison" -> "davon gewertet").
|
||||
* Nur Laeufe mit ``turnier.zaehlt_meisterschaft``.
|
||||
* Tie-Break bei Punktgleichheit: Majoritaet der Siege.
|
||||
* Endlauf: die besten 10 Erwachsenen bzw. 10 Jugendlichen — die Anwendung
|
||||
*schlaegt vor*, entscheidet nicht (Bereichsleiter).
|
||||
|
||||
Die Punktetabelle ist offene Frage **O-1**. ``PLATZHALTER_PUNKTETABELLE`` ist
|
||||
NICHT die echte Tabelle und erzeugt bei Verwendung einen Hinweis im Bericht.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from oamc.models import (
|
||||
Ergebnis,
|
||||
Klasse,
|
||||
Meisterschaft,
|
||||
Saison,
|
||||
Start,
|
||||
Turnier,
|
||||
Veranstaltung,
|
||||
)
|
||||
from oamc.models.enums import Kategorie, StartStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PLATZHALTER — bis O-1 geklaert ist. Monoton fallend, Platz 1 = 25 Punkte,
|
||||
# ab Platz 15 ein Punkt. Frei erfunden, nur damit AP 3 test- und lauffaehig ist.
|
||||
# ---------------------------------------------------------------------------
|
||||
PLATZHALTER_PUNKTETABELLE: dict[int, int] = {
|
||||
1: 25, 2: 20, 3: 16, 4: 13, 5: 11, 6: 10, 7: 9, 8: 8,
|
||||
9: 7, 10: 6, 11: 5, 12: 4, 13: 3, 14: 2,
|
||||
}
|
||||
PLATZHALTER_PUNKTE_AB_15 = 1
|
||||
|
||||
|
||||
def punkte_fuer_platz(platz: int | None, tabelle: dict[int, int] | None = None) -> Decimal:
|
||||
if platz is None or platz < 1:
|
||||
return Decimal(0)
|
||||
tab = tabelle or PLATZHALTER_PUNKTETABELLE
|
||||
if platz in tab:
|
||||
return Decimal(tab[platz])
|
||||
# unterhalb der Tabelle: kleinster definierter Wert bzw. Platzhalter
|
||||
if tabelle:
|
||||
return Decimal(min(tabelle.values())) if tabelle else Decimal(0)
|
||||
return Decimal(PLATZHALTER_PUNKTE_AB_15)
|
||||
|
||||
|
||||
def gewertete_laeufe(anzahl_turniere: int, tabelle: dict[str, int] | None) -> int:
|
||||
"""Wie viele der ``anzahl_turniere`` zaehlen (Rest = Streichresultate)."""
|
||||
|
||||
if not tabelle:
|
||||
return anzahl_turniere
|
||||
# exakter Eintrag?
|
||||
key = str(anzahl_turniere)
|
||||
if key in tabelle:
|
||||
return int(tabelle[key])
|
||||
# sonst: naechstkleinerer definierter Schluessel
|
||||
kleinere = sorted((int(k) for k in tabelle), reverse=True)
|
||||
for k in kleinere:
|
||||
if k <= anzahl_turniere:
|
||||
return int(tabelle[str(k)])
|
||||
return anzahl_turniere
|
||||
|
||||
|
||||
@dataclass
|
||||
class StandZeile:
|
||||
fahrer_id: int
|
||||
punkte_gesamt: Decimal
|
||||
punkte_je_lauf: dict[int, Decimal] # turnier_id -> Punkte (nach Streichung)
|
||||
gestrichene_laeufe: list[int]
|
||||
anzahl_siege: int
|
||||
platz: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MeisterschaftsBericht:
|
||||
meisterschaft_id: int
|
||||
saison_jahr: int
|
||||
kategorie: str
|
||||
laeufe_gesamt: int
|
||||
laeufe_gewertet: int
|
||||
zeilen: list[StandZeile]
|
||||
hinweise: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _relevante_starts(db: Session, m: Meisterschaft, turnier_ids: list[int]) -> list[Start]:
|
||||
stmt = (
|
||||
select(Start)
|
||||
.join(Ergebnis, Ergebnis.start_id == Start.id)
|
||||
.join(Klasse, Klasse.id == Start.klasse_id)
|
||||
.where(
|
||||
Start.turnier_id.in_(turnier_ids),
|
||||
Ergebnis.platzierung.is_not(None),
|
||||
Start.status == StartStatus.GESTARTET,
|
||||
)
|
||||
)
|
||||
if m.kategorie == Kategorie.JUGEND:
|
||||
if m.klasse_id is not None:
|
||||
stmt = stmt.where(Start.klasse_id == m.klasse_id)
|
||||
else:
|
||||
stmt = stmt.where(Klasse.kategorie == Kategorie.JUGEND)
|
||||
else:
|
||||
# Erwachsene: A + S zusammen (O-3: zaehlen A-Fahrer mit? -> annehmen: ja)
|
||||
stmt = stmt.where(Klasse.kategorie.in_([Kategorie.STAMM, Kategorie.ANFAENGER]))
|
||||
return list(db.scalars(stmt))
|
||||
|
||||
|
||||
def berechne_stand(db: Session, meisterschaft_id: int) -> MeisterschaftsBericht:
|
||||
m = db.get(Meisterschaft, meisterschaft_id)
|
||||
if m is None:
|
||||
raise ValueError(f"Meisterschaft {meisterschaft_id} nicht gefunden")
|
||||
saison = db.get(Saison, m.saison_jahr)
|
||||
regelwerk = m.regelwerk or {}
|
||||
punkte_tab_raw = regelwerk.get("punkte")
|
||||
punkte_tab = {int(k): int(v) for k, v in punkte_tab_raw.items()} if punkte_tab_raw else None
|
||||
streich_tab = (saison.streichresultate_tabelle if saison else None) or regelwerk.get("streich")
|
||||
|
||||
hinweise: list[str] = []
|
||||
if punkte_tab is None:
|
||||
hinweise.append(
|
||||
"O-1 offen: PLATZHALTER-Punktetabelle verwendet — nicht fuer offizielle Staende!"
|
||||
)
|
||||
if regelwerk.get("_hinweis"):
|
||||
hinweise.append(str(regelwerk["_hinweis"]))
|
||||
|
||||
turniere = list(
|
||||
db.scalars(
|
||||
select(Turnier)
|
||||
.join(Veranstaltung, Veranstaltung.id == Turnier.veranstaltung_id)
|
||||
.where(
|
||||
Veranstaltung.datum.is_not(None),
|
||||
Turnier.zaehlt_meisterschaft.is_(True),
|
||||
Veranstaltung.status.in_(["vorlaeufig", "final"]),
|
||||
)
|
||||
.order_by(Veranstaltung.datum, Turnier.id)
|
||||
)
|
||||
)
|
||||
# nur Turniere der Saison der Meisterschaft
|
||||
turniere = [t for t in turniere if t.veranstaltung.datum.year == m.saison_jahr]
|
||||
turnier_ids = [t.id for t in turniere]
|
||||
|
||||
roh: dict[int, dict[int, Decimal]] = {} # fahrer_id -> {turnier_id: punkte}
|
||||
siege: dict[int, int] = {}
|
||||
if turnier_ids:
|
||||
for s in _relevante_starts(db, m, turnier_ids):
|
||||
p = punkte_fuer_platz(s.ergebnis.platzierung, punkte_tab)
|
||||
bucket = roh.setdefault(s.fahrer_id, {})
|
||||
# Mehrfachstart in derselben Kategorie am selben Tag: bestes Ergebnis zaehlt
|
||||
bucket[s.turnier_id] = max(bucket.get(s.turnier_id, Decimal(0)), p)
|
||||
if s.ergebnis.platzierung == 1:
|
||||
siege[s.fahrer_id] = siege.get(s.fahrer_id, 0) + 1
|
||||
|
||||
n_gewertet = gewertete_laeufe(len(turnier_ids), streich_tab)
|
||||
|
||||
zeilen: list[StandZeile] = []
|
||||
for fahrer_id, je_lauf in roh.items():
|
||||
sortiert = sorted(je_lauf.items(), key=lambda kv: kv[1], reverse=True)
|
||||
behalten = dict(sortiert[:n_gewertet])
|
||||
gestrichen = [tid for tid, _ in sortiert[n_gewertet:]]
|
||||
zeilen.append(
|
||||
StandZeile(
|
||||
fahrer_id=fahrer_id,
|
||||
punkte_gesamt=sum(behalten.values(), Decimal(0)),
|
||||
punkte_je_lauf=behalten,
|
||||
gestrichene_laeufe=gestrichen,
|
||||
anzahl_siege=siege.get(fahrer_id, 0),
|
||||
)
|
||||
)
|
||||
|
||||
zeilen.sort(key=lambda z: (-z.punkte_gesamt, -z.anzahl_siege, z.fahrer_id))
|
||||
for i, z in enumerate(zeilen, start=1):
|
||||
z.platz = i
|
||||
|
||||
return MeisterschaftsBericht(
|
||||
meisterschaft_id=meisterschaft_id,
|
||||
saison_jahr=m.saison_jahr,
|
||||
kategorie=m.kategorie.value,
|
||||
laeufe_gesamt=len(turnier_ids),
|
||||
laeufe_gewertet=n_gewertet,
|
||||
zeilen=zeilen,
|
||||
hinweise=hinweise,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualiVorschlag:
|
||||
fahrer_id: int
|
||||
platz: int
|
||||
punkte: Decimal
|
||||
anzahl_siege: int
|
||||
tie_break_relevant: bool
|
||||
|
||||
|
||||
def endlauf_qualifikation(
|
||||
db: Session, meisterschaft_id: int, plaetze: int = 10
|
||||
) -> list[QualiVorschlag]:
|
||||
"""Vorschlag Top-N fuer den Endlauf. Bei Punktgleichheit an der Grenze wird
|
||||
``tie_break_relevant`` gesetzt — die endgueltige Festlegung trifft der
|
||||
Bereichsleiter (Plan §3.4)."""
|
||||
|
||||
bericht = berechne_stand(db, meisterschaft_id)
|
||||
zeilen = bericht.zeilen[: plaetze + 5] # etwas Puffer fuer die Grenzbetrachtung
|
||||
vorschlaege: list[QualiVorschlag] = []
|
||||
grenz_punkte = bericht.zeilen[plaetze - 1].punkte_gesamt if len(bericht.zeilen) >= plaetze else None
|
||||
for z in zeilen:
|
||||
vorschlaege.append(
|
||||
QualiVorschlag(
|
||||
fahrer_id=z.fahrer_id,
|
||||
platz=z.platz or 0,
|
||||
punkte=z.punkte_gesamt,
|
||||
anzahl_siege=z.anzahl_siege,
|
||||
tie_break_relevant=grenz_punkte is not None and z.punkte_gesamt == grenz_punkte,
|
||||
)
|
||||
)
|
||||
return vorschlaege
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Alle ORM-Modelle. Import hier zentral, damit Alembic-Autogenerate sie sieht."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from oamc.models.audit import ImportJob
|
||||
from oamc.models.base import Base
|
||||
from oamc.models.benutzer import Benutzer
|
||||
from oamc.models.enums import (
|
||||
BenutzerRolle,
|
||||
GeraetTyp,
|
||||
Geschlecht,
|
||||
ImportStatus,
|
||||
Kategorie,
|
||||
StartStatus,
|
||||
TurnierStatus,
|
||||
VeranstaltungStatus,
|
||||
ZeitmessungStatus,
|
||||
)
|
||||
from oamc.models.fahrer import Fahrer
|
||||
from oamc.models.klasse import Klasse
|
||||
from oamc.models.meisterschaft import Meisterschaft, MeisterschaftStand
|
||||
from oamc.models.organisation import Saison, Verein
|
||||
from oamc.models.start import Ergebnis, Start
|
||||
from oamc.models.veranstaltung import Turnier, Veranstaltung, Wertungsklasse
|
||||
from oamc.models.zeitmessung import Geraet, Zeitmessung
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"Verein",
|
||||
"Saison",
|
||||
"Klasse",
|
||||
"Fahrer",
|
||||
"Veranstaltung",
|
||||
"Turnier",
|
||||
"Wertungsklasse",
|
||||
"Start",
|
||||
"Ergebnis",
|
||||
"Geraet",
|
||||
"Zeitmessung",
|
||||
"Meisterschaft",
|
||||
"MeisterschaftStand",
|
||||
"ImportJob",
|
||||
"Benutzer",
|
||||
"Kategorie",
|
||||
"Geschlecht",
|
||||
"VeranstaltungStatus",
|
||||
"TurnierStatus",
|
||||
"StartStatus",
|
||||
"GeraetTyp",
|
||||
"ZeitmessungStatus",
|
||||
"ImportStatus",
|
||||
"BenutzerRolle",
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Import-Audit-Trail (Plan §4, §6.1).
|
||||
|
||||
Jeder API-/Datei-Import landet hier mit Roh-Payload — bei Streit ueber eine
|
||||
Platzierung ist rekonstruierbar, was wann ankam.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from oamc.models.base import Base, sa_enum
|
||||
from oamc.models.enums import ImportStatus
|
||||
|
||||
|
||||
class ImportJob(Base):
|
||||
__tablename__ = "import_job"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
quelle: Mapped[str] = mapped_column(String(120), nullable=False) # "excel-auswertung-v3"
|
||||
idempotenz_schluessel: Mapped[str | None] = mapped_column(String(200), index=True)
|
||||
dateiname: Mapped[str | None] = mapped_column(String(300))
|
||||
roh_payload: Mapped[dict | None] = mapped_column(JSONB)
|
||||
ergebnis_bericht: Mapped[dict | None] = mapped_column(JSONB) # neue/gematchte Fahrer, Warnungen
|
||||
status: Mapped[ImportStatus] = mapped_column(
|
||||
sa_enum(ImportStatus, "import_status"), nullable=False
|
||||
)
|
||||
fehler: Mapped[str | None] = mapped_column(Text)
|
||||
angelegt_von: Mapped[str | None] = mapped_column(String(120))
|
||||
angelegt_am: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Deklarative Basis und gemeinsame Mixins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, MetaData, func
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
def sa_enum(py_enum: type, name: str) -> SAEnum:
|
||||
"""Postgres-Enum, das die ``value`` der Python-Enum-Member speichert (nicht den
|
||||
Namen) — lesbare Dumps, stabile Migrationen."""
|
||||
|
||||
return SAEnum(py_enum, name=name, values_callable=lambda e: [m.value for m in e])
|
||||
|
||||
# Feste Namenskonvention -> stabile Constraint-Namen in Alembic-Migrationen.
|
||||
NAMING_CONVENTION = {
|
||||
"ix": "ix_%(column_0_label)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||
"pk": "pk_%(table_name)s",
|
||||
}
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""``erstellt_am`` / ``geaendert_am`` — von der DB gepflegt."""
|
||||
|
||||
erstellt_am: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
geaendert_am: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Interne Benutzer (Login) — Admin / Auswerter / Lesend (Plan §4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from oamc.models.base import Base, TimestampMixin, sa_enum
|
||||
from oamc.models.enums import BenutzerRolle
|
||||
|
||||
|
||||
class Benutzer(Base, TimestampMixin):
|
||||
__tablename__ = "benutzer"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
benutzername: Mapped[str] = mapped_column(String(80), nullable=False, unique=True)
|
||||
anzeigename: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
passwort_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
rolle: Mapped[BenutzerRolle] = mapped_column(
|
||||
sa_enum(BenutzerRolle, "benutzer_rolle"), nullable=False, default=BenutzerRolle.LESEND
|
||||
)
|
||||
aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
letzter_login_am: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
@property
|
||||
def darf_schreiben(self) -> bool:
|
||||
return self.rolle in (BenutzerRolle.ADMIN, BenutzerRolle.AUSWERTER)
|
||||
|
||||
@property
|
||||
def ist_admin(self) -> bool:
|
||||
return self.rolle == BenutzerRolle.ADMIN
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Fachliche Aufzaehlungstypen.
|
||||
|
||||
Bewusst als String-Enums in der DB (lesbare Dumps, einfache Migration). Die
|
||||
*Wertungsformel* je Kategorie steht NICHT hier fest verdrahtet, sondern wird in
|
||||
``oamc.wertung.engine`` pro Kategorie/Saison aufgeloest (Plan §3.3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class Kategorie(str, enum.Enum):
|
||||
"""Grobklassierung eines Fahrers/Starts. Bestimmt die Default-Wertungsformel."""
|
||||
|
||||
JUGEND = "J"
|
||||
STAMM = "S"
|
||||
ANFAENGER = "A"
|
||||
|
||||
|
||||
class Geschlecht(str, enum.Enum):
|
||||
MAENNLICH = "M"
|
||||
WEIBLICH = "W"
|
||||
DIVERS = "D"
|
||||
|
||||
|
||||
class VeranstaltungStatus(str, enum.Enum):
|
||||
GEPLANT = "geplant"
|
||||
LAUFEND = "laufend"
|
||||
VORLAEUFIG = "vorlaeufig"
|
||||
FINAL = "final"
|
||||
|
||||
|
||||
class TurnierStatus(str, enum.Enum):
|
||||
GEPLANT = "geplant"
|
||||
LAUFEND = "laufend"
|
||||
VORLAEUFIG = "vorlaeufig"
|
||||
FINAL = "final"
|
||||
|
||||
|
||||
class StartStatus(str, enum.Enum):
|
||||
GENANNT = "genannt"
|
||||
GESTARTET = "gestartet"
|
||||
DNF = "DNF" # did not finish
|
||||
DNS = "DNS" # did not start
|
||||
DSQ = "DSQ" # disqualifiziert
|
||||
|
||||
|
||||
class GeraetTyp(str, enum.Enum):
|
||||
LICHTSCHRANKE = "lichtschranke"
|
||||
ANZEIGE = "anzeige"
|
||||
|
||||
|
||||
class ZeitmessungStatus(str, enum.Enum):
|
||||
GUELTIG = "gueltig"
|
||||
VERWORFEN = "verworfen"
|
||||
UNZUGEORDNET = "unzugeordnet"
|
||||
UNPLAUSIBEL = "unplausibel"
|
||||
|
||||
|
||||
class ImportStatus(str, enum.Enum):
|
||||
ANGENOMMEN = "angenommen"
|
||||
VORSCHAU = "vorschau"
|
||||
FEHLER = "fehler"
|
||||
TEILWEISE = "teilweise"
|
||||
|
||||
|
||||
class BenutzerRolle(str, enum.Enum):
|
||||
ADMIN = "admin"
|
||||
AUSWERTER = "auswerter"
|
||||
LESEND = "lesend"
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Fahrer-Stammdaten — einmalig, nicht pro Turnier (Plan §4).
|
||||
|
||||
Loest die heutige \"BAUSTELLE\" ``m/m-fahrer.htm`` ab. Dublettenerkennung beim
|
||||
Import: siehe ``oamc.matching.fahrer``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Boolean, Date, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from oamc.models.base import Base, TimestampMixin, sa_enum
|
||||
from oamc.models.enums import Geschlecht
|
||||
|
||||
|
||||
class Fahrer(Base, TimestampMixin):
|
||||
__tablename__ = "fahrer"
|
||||
# Hinweis: der GIN-Trigramm-Index ``ix_fahrer_name_trgm`` (unscharfe Suche beim
|
||||
# Import, Plan §6.4) wird als Ausdrucksindex in der Migration angelegt und in
|
||||
# ``migrations/env.py`` von der Autogenerate-Vergleichung ausgenommen.
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
nachname: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
vorname: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
geburtsjahr: Mapped[int | None] = mapped_column(Integer, index=True)
|
||||
# nullable: nur fuer die J-1-Pruefung "am Veranstaltungstag 7 Jahre" noetig.
|
||||
# Datenminimierung oeffentlich: nur das Jahr wird angezeigt (Plan §11).
|
||||
geburtsdatum: Mapped[date | None] = mapped_column(Date)
|
||||
geschlecht: Mapped[Geschlecht | None] = mapped_column(sa_enum(Geschlecht, "geschlecht"))
|
||||
verein_id: Mapped[int | None] = mapped_column(ForeignKey("verein.id"), index=True)
|
||||
|
||||
adac_mitgliedsnr: Mapped[str | None] = mapped_column(String(40)) # nie oeffentlich
|
||||
jugendausweis_gueltig_bis: Mapped[date | None] = mapped_column(Date)
|
||||
|
||||
# Datenschutz: Opt-out auf Anzeige als "Teilnehmer N" / Kuerzel (Plan §11)
|
||||
anzeige_anonymisiert: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
notiz: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
verein: Mapped[Verein | None] = relationship(back_populates="fahrer") # noqa: F821
|
||||
starts: Mapped[list[Start]] = relationship(back_populates="fahrer") # noqa: F821
|
||||
|
||||
@property
|
||||
def voller_name(self) -> str:
|
||||
return f"{self.nachname} {self.vorname}"
|
||||
|
||||
@property
|
||||
def anzeigename(self) -> str:
|
||||
if self.anzeige_anonymisiert:
|
||||
return f"Teilnehmer/in #{self.id}"
|
||||
return self.voller_name
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Klassendefinition pro Saison (Plan §3.2).
|
||||
|
||||
Die Jahrgangstabelle ist jahresabhaengig und liegt deshalb in der DB, nicht im
|
||||
Code. ``kategorie`` bestimmt die Default-Wertungsformel (J: nur Fehler; S/A:
|
||||
Zeit + Fehler), ist aber pro Saison in ``wertungsregel`` ueberschreibbar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, Numeric, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from oamc.models.base import Base, sa_enum
|
||||
from oamc.models.enums import Kategorie
|
||||
|
||||
|
||||
class Klasse(Base):
|
||||
__tablename__ = "klasse"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("saison_jahr", "code", name="klasse_saison_code"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
saison_jahr: Mapped[int] = mapped_column(
|
||||
ForeignKey("saison.jahr", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
code: Mapped[str] = mapped_column(String(10), nullable=False) # "J 1", "S 6", "A 4"
|
||||
kategorie: Mapped[Kategorie] = mapped_column(sa_enum(Kategorie, "kategorie"), nullable=False)
|
||||
bezeichnung: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
# Nur Jugend: erlaubte Geburtsjahrgaenge, z. B. [2019, 2018, 2017]
|
||||
jahrgaenge: Mapped[list[int] | None] = mapped_column(ARRAY(Integer))
|
||||
ccm_max: Mapped[int | None] = mapped_column(Integer)
|
||||
kw_max: Mapped[float | None] = mapped_column(Numeric(5, 2))
|
||||
fahrzeugtyp: Mapped[str | None] = mapped_column(String(200))
|
||||
# Fuer Klasse J 1: Mindestalter am Veranstaltungstag (Plan §3.2: "bereits 7 Jahre")
|
||||
mindestalter_am_veranstaltungstag: Mapped[int | None] = mapped_column(Integer)
|
||||
sortierung: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
notiz: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
saison: Mapped[Saison] = relationship(back_populates="klassen") # noqa: F821
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
|
||||
return f"<Klasse {self.saison_jahr}/{self.code}>"
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Meisterschafts-/Pokalwertung (HTH) — Plan §3.4.
|
||||
|
||||
* Erwachsene: ein Gesamtklassement A + S (klassenuebergreifend)
|
||||
* Jugend: Pokalwertung klassenweise J 1 - J 5
|
||||
* Streichresultate nach Tabelle in ``saison.streichresultate_tabelle``
|
||||
* Tie-Break: Majoritaet der Siege
|
||||
* Die Anwendung *schlaegt* die Endlaufqualifikation *vor*, entscheidet nicht.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from oamc.models.base import Base, sa_enum
|
||||
from oamc.models.enums import Kategorie
|
||||
|
||||
|
||||
class Meisterschaft(Base):
|
||||
__tablename__ = "meisterschaft"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
saison_jahr: Mapped[int] = mapped_column(ForeignKey("saison.jahr"), nullable=False, index=True)
|
||||
bezeichnung: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
kategorie: Mapped[Kategorie] = mapped_column(
|
||||
sa_enum(Kategorie, "meisterschaft_kategorie"), nullable=False
|
||||
)
|
||||
# nur bei Jugend gesetzt (klassenweise J1..J5); bei Erwachsenen NULL
|
||||
klasse_id: Mapped[int | None] = mapped_column(ForeignKey("klasse.id"))
|
||||
# Punktetabelle (Platz -> Punkte), Streichregel-Override, Endlauf-Parameter.
|
||||
# Solange O-1 offen ist, steht hier die (klar markierte) Platzhalter-Tabelle.
|
||||
regelwerk: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
|
||||
staende: Mapped[list[MeisterschaftStand]] = relationship(
|
||||
back_populates="meisterschaft", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class MeisterschaftStand(Base):
|
||||
"""Materialisiert — nach jedem Turnier neu berechnet."""
|
||||
|
||||
__tablename__ = "meisterschaft_stand"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
meisterschaft_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("meisterschaft.id", ondelete="CASCADE"), 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)
|
||||
# {turnier_id: punkte}
|
||||
punkte_je_lauf: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
# Liste der turnier_ids, die als Streichresultat NICHT zaehlen
|
||||
gestrichene_laeufe: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
anzahl_siege: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # Tie-Break
|
||||
platz: Mapped[int | None] = mapped_column(Integer)
|
||||
berechnet_am: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
meisterschaft: Mapped[Meisterschaft] = relationship(back_populates="staende")
|
||||
fahrer: Mapped[Fahrer] = relationship() # noqa: F821
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Stammorganisation: Regionalclubs/Ortsclubs und Saison (Regelversionierung)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Boolean, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from oamc.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class Verein(Base, TimestampMixin):
|
||||
"""Regional-/Ortsclub. Namensvarianten (\"Hainstadt\" = Ort statt Club) werden
|
||||
beim Import gegen ``kurzname``/``name`` gematcht (Plan §6.4)."""
|
||||
|
||||
__tablename__ = "verein"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False, unique=True)
|
||||
kurzname: Mapped[str | None] = mapped_column(String(60))
|
||||
adac_gau: Mapped[str | None] = mapped_column(String(120))
|
||||
aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
notiz: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
fahrer: Mapped[list[Fahrer]] = relationship(back_populates="verein") # noqa: F821
|
||||
|
||||
|
||||
class Saison(Base):
|
||||
"""Ein Wettkampfjahr. Macht Klassen, Jahrgaenge, Streichresultate und
|
||||
Punktetabelle versionierbar (Plan §4 Kernprinzipien)."""
|
||||
|
||||
__tablename__ = "saison"
|
||||
|
||||
jahr: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
|
||||
turnierordnung_version: Mapped[str | None] = mapped_column(String(60))
|
||||
# Tabelle "Turniere in der Saison" -> "davon gewertet" (Plan §3.4).
|
||||
# z. B. {"12": 8, "11": 8, ... , "4": 2}
|
||||
streichresultate_tabelle: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
nenngeld_cent: Mapped[int] = mapped_column(Integer, nullable=False, default=1000)
|
||||
aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
klassen: Mapped[list[Klasse]] = relationship( # noqa: F821
|
||||
back_populates="saison", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@property
|
||||
def nenngeld_euro(self) -> Decimal:
|
||||
return (Decimal(self.nenngeld_cent) / 100).quantize(Decimal("0.01"))
|
||||
@@ -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)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Renntag -> Wertungslauf -> zusammengefasste Wertungsklasse (Plan §3.1).
|
||||
|
||||
``veranstaltung`` (Renntag) : ``turnier`` (Wertungslauf) = 1:n. Ein Doppelturnier
|
||||
hat zwei Turniere, jeder Fahrer wird in beiden getrennt gewertet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Boolean, Date, ForeignKey, Index, Integer, String, Text, text
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from oamc.models.base import Base, TimestampMixin, sa_enum
|
||||
from oamc.models.enums import TurnierStatus, VeranstaltungStatus
|
||||
|
||||
|
||||
class Veranstaltung(Base, TimestampMixin):
|
||||
__tablename__ = "veranstaltung"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
datum: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
titel: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
ort: Mapped[str | None] = mapped_column(String(200))
|
||||
ausrichter_verein_id: Mapped[int | None] = mapped_column(ForeignKey("verein.id"))
|
||||
ist_doppelturnier: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
veranstaltungsleiter: Mapped[str | None] = mapped_column(String(200))
|
||||
turnierleiter: Mapped[str | None] = mapped_column(String(200))
|
||||
status: Mapped[VeranstaltungStatus] = mapped_column(
|
||||
sa_enum(VeranstaltungStatus, "veranstaltung_status"),
|
||||
nullable=False,
|
||||
default=VeranstaltungStatus.GEPLANT,
|
||||
)
|
||||
notiz: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
turniere: Mapped[list[Turnier]] = relationship(
|
||||
back_populates="veranstaltung", cascade="all, delete-orphan", order_by="Turnier.id"
|
||||
)
|
||||
|
||||
|
||||
class Turnier(Base, TimestampMixin):
|
||||
__tablename__ = "turnier"
|
||||
__table_args__ = (
|
||||
# Hoechstens EIN Turnier darf "aktiv" sein -> Geraete-API /turniere/aktiv/*
|
||||
Index(
|
||||
"uq_turnier_ist_aktiv",
|
||||
"ist_aktiv",
|
||||
unique=True,
|
||||
postgresql_where=text("ist_aktiv"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
veranstaltung_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("veranstaltung.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False) # "Reinheim" / "Hainstadt"
|
||||
veranstalter_verein_id: Mapped[int | None] = mapped_column(ForeignKey("verein.id"))
|
||||
# Nicht jede Veranstaltung zaehlt zur Meisterschaft ("o. M.", Plan §3.4).
|
||||
zaehlt_meisterschaft: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
# Genau ein Turnier kann als "aktiv" markiert sein -> Geraete-API-Endpunkte
|
||||
# /turniere/aktiv/*. Partieller Unique-Index in der Migration.
|
||||
ist_aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
status: Mapped[TurnierStatus] = mapped_column(
|
||||
sa_enum(TurnierStatus, "turnier_status"),
|
||||
nullable=False,
|
||||
default=TurnierStatus.GEPLANT,
|
||||
)
|
||||
|
||||
veranstaltung: Mapped[Veranstaltung] = relationship(back_populates="turniere")
|
||||
wertungsklassen: Mapped[list[Wertungsklasse]] = relationship(
|
||||
back_populates="turnier", cascade="all, delete-orphan", order_by="Wertungsklasse.sortierung"
|
||||
)
|
||||
starts: Mapped[list[Start]] = relationship( # noqa: F821
|
||||
back_populates="turnier", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Wertungsklasse(Base):
|
||||
"""Pro Turnier zusammengefasste Klassen (Plan §3.2): z. B. "S 5, S 9, S 3, S 6, S 7".
|
||||
Das Zusammenlegen ist eine Entscheidung pro Turnier, keine Regel im Code."""
|
||||
|
||||
__tablename__ = "wertungsklasse"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
turnier_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("turnier.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
bezeichnung: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
klasse_ids: Mapped[list[int]] = mapped_column(ARRAY(Integer), nullable=False, default=list)
|
||||
sortierung: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
turnier: Mapped[Turnier] = relationship(back_populates="wertungsklassen")
|
||||
starts: Mapped[list[Start]] = relationship(back_populates="wertungsklasse") # noqa: F821
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Messgeraet am Parcours und Einzelmessung (Plan §3.6, §4).
|
||||
|
||||
Grundsaetze:
|
||||
* Rohmessungen statt Summen speichern — die Summe bildet die Anwendung.
|
||||
* Die ``id`` vergibt das GERAET (UUID) -> Server ist idempotent.
|
||||
* Die Dauer kommt fertig vom Geraet (monotone Zeit), der Server rechnet keine
|
||||
Differenz aus Wanduhr-Zeitstempeln.
|
||||
* Eine Messung ohne zuordenbaren Start geht NICHT verloren (``start_id`` nullable,
|
||||
Status ``unzugeordnet``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from oamc.models.base import Base, TimestampMixin, sa_enum
|
||||
from oamc.models.enums import GeraetTyp, ZeitmessungStatus
|
||||
|
||||
|
||||
class Geraet(Base, TimestampMixin):
|
||||
"""Lichtschranke oder Anzeige. Eigener Geraete-Token (nicht der Auswerter-Login),
|
||||
einzeln sperrbar (Plan §3.6b)."""
|
||||
|
||||
__tablename__ = "geraet"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
bezeichnung: Mapped[str] = mapped_column(String(120), nullable=False, unique=True)
|
||||
typ: Mapped[GeraetTyp] = mapped_column(sa_enum(GeraetTyp, "geraet_typ"), nullable=False)
|
||||
token_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
letzte_meldung_am: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
firmware_version: Mapped[str | None] = mapped_column(String(60))
|
||||
queue_laenge_gemeldet: Mapped[int | None] = mapped_column(Integer)
|
||||
notiz: Mapped[str | None] = mapped_column(Text)
|
||||
# Zentral gepflegte Konfiguration, die der Mess-Pi per API abholt.
|
||||
konfiguration: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
|
||||
zeitmessungen: Mapped[list[Zeitmessung]] = relationship(back_populates="geraet")
|
||||
|
||||
|
||||
class Zeitmessung(Base):
|
||||
__tablename__ = "zeitmessung"
|
||||
__table_args__ = (
|
||||
Index("ix_zeitmessung_turnier_status", "turnier_id", "status"),
|
||||
Index("ix_zeitmessung_start", "start_id"),
|
||||
)
|
||||
|
||||
# vom Geraet vergeben (UUIDv4/v7) -> Idempotenz
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True)
|
||||
geraet_id: Mapped[int] = mapped_column(ForeignKey("geraet.id"), nullable=False)
|
||||
turnier_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("turnier.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
# nullable — unzugeordnete Messungen gehen nicht verloren
|
||||
start_id: Mapped[int | None] = mapped_column(ForeignKey("start.id", ondelete="SET NULL"))
|
||||
startnummer_gemeldet: Mapped[int | None] = mapped_column(Integer) # Hinweis, kein FK
|
||||
messpunkt: Mapped[str] = mapped_column(String(80), nullable=False, default="durchgang-1")
|
||||
dauer_sekunden: Mapped[Decimal] = mapped_column(Numeric(7, 2), nullable=False) # vom Geraet
|
||||
|
||||
gemessen_am: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
monotonic_ns: Mapped[int | None] = mapped_column(BigInteger)
|
||||
empfangen_am: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
nachgeliefert: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
status: Mapped[ZeitmessungStatus] = mapped_column(
|
||||
sa_enum(ZeitmessungStatus, "zeitmessung_status"),
|
||||
nullable=False,
|
||||
default=ZeitmessungStatus.GUELTIG,
|
||||
)
|
||||
verworfen_grund: Mapped[str | None] = mapped_column(Text)
|
||||
roh: Mapped[dict | None] = mapped_column(JSONB)
|
||||
|
||||
geraet: Mapped[Geraet] = relationship(back_populates="zeitmessungen")
|
||||
start: Mapped[Start | None] = relationship(back_populates="zeitmessungen") # noqa: F821
|
||||
|
||||
@property
|
||||
def zaehlt_in_summe(self) -> bool:
|
||||
return self.status == ZeitmessungStatus.GUELTIG and self.start_id is not None
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic-v2-Schemata: ein Schema fuer API-Validierung UND OpenAPI-Doku."""
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Schemata der Geraete-API (Mess-Pi, Bearer-Token). Plan §6.2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Heartbeat(BaseModel):
|
||||
firmware_version: str | None = None
|
||||
queue_laenge: int = Field(default=0, ge=0)
|
||||
freitext: str | None = None
|
||||
|
||||
|
||||
class HeartbeatAntwort(BaseModel):
|
||||
geraet_id: int
|
||||
bezeichnung: str
|
||||
server_zeit: datetime
|
||||
aktives_turnier_id: int | None
|
||||
|
||||
|
||||
class GeraeteKonfiguration(BaseModel):
|
||||
aktives_turnier_id: int | None
|
||||
entprellung_ms: int = 300
|
||||
messpunkte: list[str] = ["durchgang-1"]
|
||||
plausibilitaet_min_s: float
|
||||
plausibilitaet_max_s: float
|
||||
# frei konfigurierbare Zusatzwerte aus geraet.konfiguration
|
||||
extra: dict = {}
|
||||
|
||||
|
||||
class MessungEin(BaseModel):
|
||||
id: uuid.UUID = Field(description="vom Geraet vergeben (UUIDv4/v7) -> Idempotenz")
|
||||
turnier_id: int
|
||||
startnummer_gemeldet: int | None = None
|
||||
messpunkt: str = "durchgang-1"
|
||||
dauer_sekunden: Decimal = Field(gt=0, decimal_places=2, description="vom Geraet berechnet")
|
||||
gemessen_am: datetime
|
||||
monotonic_ns: int | None = None
|
||||
roh: dict | None = None
|
||||
|
||||
|
||||
class MessungBatch(BaseModel):
|
||||
geraet: str | None = Field(default=None, description="Kennung, informativ")
|
||||
messungen: list[MessungEin] = Field(min_length=1)
|
||||
|
||||
|
||||
class MessungQuittung(BaseModel):
|
||||
id: uuid.UUID
|
||||
status: str # angenommen | duplikat | unzugeordnet | unplausibel | fehler
|
||||
start_id: int | None = None
|
||||
hinweis: str | None = None
|
||||
|
||||
|
||||
class BatchAntwort(BaseModel):
|
||||
quittungen: list[MessungQuittung]
|
||||
angenommen: int
|
||||
duplikate: int
|
||||
unzugeordnet: int
|
||||
unplausibel: int
|
||||
|
||||
|
||||
class VerwerfenEin(BaseModel):
|
||||
grund: str = Field(min_length=1)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Schemata der Ingest-API (POST /api/v1/..., X-API-Key). Plan §6.1."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from oamc.models.enums import Geschlecht, StartStatus
|
||||
|
||||
|
||||
class FahrerRef(BaseModel):
|
||||
"""Fahrerangabe in einem Bulk-Upload — wird gegen die Stammdaten gematcht."""
|
||||
|
||||
nachname: str
|
||||
vorname: str
|
||||
geburtsjahr: int | None = Field(default=None, ge=1930, le=date.today().year)
|
||||
geschlecht: Geschlecht | None = None
|
||||
verein: str | None = None
|
||||
fahrer_id: int | None = Field(default=None, description="wenn bekannt: direkter Treffer")
|
||||
|
||||
|
||||
class VeranstaltungCreate(BaseModel):
|
||||
datum: date
|
||||
titel: str
|
||||
ort: str | None = None
|
||||
ausrichter_verein: str | None = None
|
||||
ist_doppelturnier: bool = False
|
||||
veranstaltungsleiter: str | None = None
|
||||
turnierleiter: str | None = None
|
||||
|
||||
|
||||
class TurnierCreate(BaseModel):
|
||||
veranstaltung_id: int
|
||||
name: str
|
||||
veranstalter_verein: str | None = None
|
||||
zaehlt_meisterschaft: bool = True
|
||||
|
||||
|
||||
class StartCreate(BaseModel):
|
||||
startnummer: int = Field(ge=1)
|
||||
fahrer: FahrerRef
|
||||
klasse: str = Field(description='Klassencode, z. B. "J 2", "S 6"')
|
||||
wertungsklasse: str | None = None
|
||||
fahrzeug: str | None = None
|
||||
nenngeld_bezahlt: bool = False
|
||||
nennreihenfolge: int | None = None
|
||||
status: StartStatus = StartStatus.GENANNT
|
||||
|
||||
|
||||
class StartBulk(BaseModel):
|
||||
quelle: str = "manuell"
|
||||
idempotenz_schluessel: str | None = None
|
||||
starts: list[StartCreate]
|
||||
|
||||
|
||||
class ErgebnisEintrag(BaseModel):
|
||||
startnummer: int
|
||||
fahrer: FahrerRef | None = None
|
||||
klasse: str | None = None
|
||||
wertungsklasse: str | None = None
|
||||
summe_zeit: Decimal | None = Field(default=None, ge=0, decimal_places=2)
|
||||
summe_fehler: int | None = Field(default=None, ge=0)
|
||||
zeit_vollstaendig: bool = True
|
||||
fehler_vollstaendig: bool = True
|
||||
# optional: Server rechnet nach und meldet Abweichung als Warnung
|
||||
gesamt_erwartet: Decimal | None = None
|
||||
platzierung_erwartet: int | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _mindestens_ein_wert(self):
|
||||
if self.summe_zeit is None and self.summe_fehler is None:
|
||||
raise ValueError("Weder summe_zeit noch summe_fehler angegeben")
|
||||
return self
|
||||
|
||||
|
||||
class ErgebnisBulk(BaseModel):
|
||||
quelle: str = Field(examples=["excel-auswertung-v3"])
|
||||
idempotenz_schluessel: str = Field(
|
||||
examples=["2026-05-31-reinheim-final"],
|
||||
description="Ein doppelt gesendeter Upload legt keine Dubletten an.",
|
||||
)
|
||||
ergebnisse: list[ErgebnisEintrag]
|
||||
|
||||
|
||||
# --- Vorschau-/Ergebnisberichte -----------------------------------------
|
||||
class FahrerMatchInfo(BaseModel):
|
||||
startnummer: int
|
||||
eingabe: str
|
||||
status: str # exakt | unscharf | neu
|
||||
fahrer_id: int | None = None
|
||||
vorschlaege: list[dict] = []
|
||||
|
||||
|
||||
class AbweichungInfo(BaseModel):
|
||||
startnummer: int | None
|
||||
feld: str
|
||||
erwartet: object
|
||||
berechnet: object
|
||||
|
||||
|
||||
class ImportBericht(BaseModel):
|
||||
import_job_id: int | None = None
|
||||
dry_run: bool
|
||||
angelegt: int = 0
|
||||
aktualisiert: int = 0
|
||||
neue_fahrer: int = 0
|
||||
fahrer_matches: list[FahrerMatchInfo] = []
|
||||
abweichungen: list[AbweichungInfo] = []
|
||||
warnungen: list[str] = []
|
||||
idempotent_uebersprungen: bool = False
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Authentifizierung: getrennte Profile fuer Ingest-API-Key, Geraete-Token und
|
||||
interne Benutzer-Session (Plan §6.1, §6.2).
|
||||
|
||||
Der Geraete-Token ist NICHT der Auswerter-Login und einzeln sperrbar.
|
||||
"""
|
||||
|
||||
from oamc.security.passwoerter import hash_passwort, pruefe_passwort
|
||||
from oamc.security.tokens import (
|
||||
erzeuge_geraete_token,
|
||||
hash_token,
|
||||
jwt_erzeugen,
|
||||
jwt_lesen,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"hash_passwort",
|
||||
"pruefe_passwort",
|
||||
"erzeuge_geraete_token",
|
||||
"hash_token",
|
||||
"jwt_erzeugen",
|
||||
"jwt_lesen",
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""FastAPI-Dependencies fuer die drei Auth-Profile."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from oamc.config import get_settings
|
||||
from oamc.db import get_db
|
||||
from oamc.models import Benutzer, Geraet
|
||||
from oamc.models.enums import BenutzerRolle
|
||||
from oamc.security.tokens import jwt_lesen, token_stimmt
|
||||
|
||||
DbDep = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
# --- Ingest-API-Key (POST /api/v1/...) --------------------------------------
|
||||
def require_ingest_key(
|
||||
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
|
||||
) -> str:
|
||||
keys = get_settings().ingest_api_key_set
|
||||
if not x_api_key or x_api_key not in keys:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltiger oder fehlender X-API-Key")
|
||||
return x_api_key
|
||||
|
||||
|
||||
IngestKey = Annotated[str, Depends(require_ingest_key)]
|
||||
|
||||
|
||||
# --- Geraete-Token (Mess-Pi) ---------------------------------------------
|
||||
def require_geraet(
|
||||
db: DbDep,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> Geraet:
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Bearer-Geraete-Token fehlt")
|
||||
roh = authorization.split(" ", 1)[1].strip()
|
||||
geraete = db.scalars(select(Geraet).where(Geraet.aktiv.is_(True))).all()
|
||||
for g in geraete:
|
||||
if token_stimmt(roh, g.token_hash):
|
||||
g.letzte_meldung_am = datetime.now(UTC)
|
||||
return g
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Geraete-Token ungueltig oder gesperrt")
|
||||
|
||||
|
||||
GeraetDep = Annotated[Geraet, Depends(require_geraet)]
|
||||
|
||||
|
||||
# --- Interne Benutzer-Session (Cookie-JWT) ------------------------------
|
||||
def aktueller_benutzer(request: Request, db: DbDep) -> Benutzer | None:
|
||||
token = request.cookies.get("oamc_session")
|
||||
if not token:
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth.lower().startswith("bearer "):
|
||||
token = auth.split(" ", 1)[1]
|
||||
if not token:
|
||||
return None
|
||||
daten = jwt_lesen(token)
|
||||
if not daten:
|
||||
return None
|
||||
b = db.scalars(
|
||||
select(Benutzer).where(Benutzer.benutzername == daten.get("sub"), Benutzer.aktiv.is_(True))
|
||||
).first()
|
||||
return b
|
||||
|
||||
|
||||
BenutzerOpt = Annotated["Benutzer | None", Depends(aktueller_benutzer)]
|
||||
|
||||
|
||||
def require_benutzer(b: BenutzerOpt) -> Benutzer:
|
||||
if b is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Anmeldung erforderlich")
|
||||
return b
|
||||
|
||||
|
||||
def require_auswerter(b: Annotated[Benutzer, Depends(require_benutzer)]) -> Benutzer:
|
||||
if not b.darf_schreiben:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Auswerter- oder Admin-Rolle erforderlich")
|
||||
return b
|
||||
|
||||
|
||||
def require_admin(b: Annotated[Benutzer, Depends(require_benutzer)]) -> Benutzer:
|
||||
if b.rolle != BenutzerRolle.ADMIN:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rolle erforderlich")
|
||||
return b
|
||||
|
||||
|
||||
Auswerter = Annotated[Benutzer, Depends(require_auswerter)]
|
||||
Admin = Annotated[Benutzer, Depends(require_admin)]
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Passwort-Hashing fuer interne Benutzer.
|
||||
|
||||
Direkt ``bcrypt`` (ohne passlib — dessen bcrypt-Backend ist mit bcrypt >= 4.1
|
||||
inkompatibel). Lange Passwoerter werden vorher per SHA-256 auf 44 Base64-Zeichen
|
||||
verdichtet, damit die 72-Byte-Grenze von bcrypt nie still abschneidet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
import bcrypt
|
||||
|
||||
|
||||
def _vorbereiten(klartext: str) -> bytes:
|
||||
digest = hashlib.sha256(klartext.encode("utf-8")).digest()
|
||||
return base64.b64encode(digest) # 44 Bytes, < 72
|
||||
|
||||
|
||||
def hash_passwort(klartext: str) -> str:
|
||||
return bcrypt.hashpw(_vorbereiten(klartext), bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def pruefe_passwort(klartext: str, hash_wert: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(_vorbereiten(klartext), hash_wert.encode("ascii"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Geraete-Token (undurchsichtig, gehasht gespeichert) und Benutzer-JWT."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
from oamc.config import get_settings
|
||||
|
||||
_TOKEN_PREFIX = "oamc_dev_" # nur ein Erkennungsmerkmal, kein Geheimnis
|
||||
|
||||
|
||||
def erzeuge_geraete_token() -> tuple[str, str]:
|
||||
"""Gibt ``(klartext_token, token_hash)`` zurueck. Der Klartext wird EINMALIG
|
||||
dem Geraet mitgegeben und nie gespeichert."""
|
||||
|
||||
roh = _TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||
return roh, hash_token(roh)
|
||||
|
||||
|
||||
def hash_token(roh: str) -> str:
|
||||
return hashlib.sha256(roh.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def token_stimmt(roh: str, gespeicherter_hash: str) -> bool:
|
||||
return hmac.compare_digest(hash_token(roh), gespeicherter_hash)
|
||||
|
||||
|
||||
def jwt_erzeugen(subject: str, rolle: str, **extra: Any) -> str:
|
||||
s = get_settings()
|
||||
jetzt = datetime.now(UTC)
|
||||
payload = {
|
||||
"sub": subject,
|
||||
"rolle": rolle,
|
||||
"iat": jetzt,
|
||||
"exp": jetzt + timedelta(minutes=s.access_token_minutes),
|
||||
**extra,
|
||||
}
|
||||
return jwt.encode(payload, s.secret_key, algorithm="HS256")
|
||||
|
||||
|
||||
def jwt_lesen(token: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
return jwt.decode(token, get_settings().secret_key, algorithms=["HS256"])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Stammdaten-Seed (AP 1).
|
||||
|
||||
``seed_saison_2026`` legt Saison 2026, Klassen J 1-J 5 + S/A, Vereine und die
|
||||
HTH-Meisterschaften an. Idempotent: mehrfaches Ausfuehren aendert nichts.
|
||||
|
||||
Offene Fragen, die den Seed betreffen:
|
||||
* O-1 Punktetabelle Meisterschaft -> Platzhalter in ``meisterschaft.regelwerk``
|
||||
* O-2 Zuordnung S 1-S 9 / A 1-A 4 -> Fahrzeugtyp -> ``fahrzeugtyp`` bleibt NULL
|
||||
"""
|
||||
|
||||
from oamc.seeds.saison_2026 import seed_saison_2026
|
||||
|
||||
__all__ = ["seed_saison_2026"]
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Seed fuer Saison 2026 — abgeleitet aus plan.md §3.2 und §3.4."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from oamc.meisterschaft.berechnung import PLATZHALTER_PUNKTETABELLE
|
||||
from oamc.models import Klasse, Meisterschaft, Saison, Verein
|
||||
from oamc.models.enums import Kategorie
|
||||
|
||||
# "Turniere in der Saison" -> "davon gewertet" (Plan §3.4)
|
||||
STREICHRESULTATE_2026 = {
|
||||
"12": 8, "11": 8, "10": 8, "9": 7, "8": 6, "7": 5, "6": 4, "5": 3, "4": 2,
|
||||
}
|
||||
|
||||
# Jugendklassen: Einteilung strikt nach Jahrgang (Plan §3.2)
|
||||
JUGENDKLASSEN = [
|
||||
("J 1", [2019, 2018, 2017], 110, 5.5, "Kindermotorrad", 7),
|
||||
("J 2", [2016, 2015], 110, 5.5, "Kindermotorrad", None),
|
||||
("J 3", [2014, 2013], 125, 11.0, "Mofa / Motorrad / Roller", None),
|
||||
("J 4", [2012, 2011], 125, 11.0, "Mofa / Motorrad / Roller", None),
|
||||
("J 5", [2010, 2009, 2008], 125, 11.0, "Mofa / Motorrad / Roller (bis 18. Geburtstag)", None),
|
||||
]
|
||||
|
||||
# Erwachsenenklassen: konkrete Fahrzeugzuordnung ist offene Frage O-2.
|
||||
STAMMKLASSEN = [f"S {i}" for i in range(1, 10)]
|
||||
ANFAENGERKLASSEN = [f"A {i}" for i in range(1, 5)]
|
||||
|
||||
VEREINE = [
|
||||
("OAMC Reinheim e.V. im ADAC", "OAMC Reinheim", "ADAC Hessen-Thueringen"),
|
||||
("MSC Hainstadt", "Hainstadt", "ADAC Hessen-Thueringen"),
|
||||
("MSC Schotten", "Schotten", "ADAC Hessen-Thueringen"),
|
||||
("PMS Kassel", "Kassel", "ADAC Hessen-Thueringen"),
|
||||
]
|
||||
|
||||
|
||||
def _get_or_create(db: Session, model, defaults=None, **kwargs):
|
||||
obj = db.scalars(select(model).filter_by(**kwargs)).first()
|
||||
if obj is not None:
|
||||
return obj, False
|
||||
obj = model(**kwargs, **(defaults or {}))
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
return obj, True
|
||||
|
||||
|
||||
def seed_saison_2026(db: Session) -> dict[str, int]:
|
||||
"""Idempotenter Seed. Rueckgabe: Zaehler der neu angelegten Objekte."""
|
||||
|
||||
zaehler = {"saison": 0, "verein": 0, "klasse": 0, "meisterschaft": 0}
|
||||
|
||||
saison, neu = _get_or_create(
|
||||
db,
|
||||
Saison,
|
||||
jahr=2026,
|
||||
defaults={
|
||||
"turnierordnung_version": "MT-D2025 (zu verifizieren, Plan §10)",
|
||||
"streichresultate_tabelle": STREICHRESULTATE_2026,
|
||||
"nenngeld_cent": 1000,
|
||||
"aktiv": True,
|
||||
},
|
||||
)
|
||||
zaehler["saison"] += int(neu)
|
||||
|
||||
for name, kurz, gau in VEREINE:
|
||||
_, neu = _get_or_create(
|
||||
db, Verein, name=name, defaults={"kurzname": kurz, "adac_gau": gau, "aktiv": True}
|
||||
)
|
||||
zaehler["verein"] += int(neu)
|
||||
|
||||
sortierung = 0
|
||||
for code, jahrgaenge, ccm, kw, typ, mindestalter in JUGENDKLASSEN:
|
||||
sortierung += 1
|
||||
_, neu = _get_or_create(
|
||||
db,
|
||||
Klasse,
|
||||
saison_jahr=2026,
|
||||
code=code,
|
||||
defaults={
|
||||
"kategorie": Kategorie.JUGEND,
|
||||
"bezeichnung": f"Jugend {code} (Jahrgang {'/'.join(map(str, jahrgaenge))})",
|
||||
"jahrgaenge": jahrgaenge,
|
||||
"ccm_max": ccm,
|
||||
"kw_max": kw,
|
||||
"fahrzeugtyp": typ,
|
||||
"mindestalter_am_veranstaltungstag": mindestalter,
|
||||
"sortierung": sortierung,
|
||||
},
|
||||
)
|
||||
zaehler["klasse"] += int(neu)
|
||||
|
||||
for code in STAMMKLASSEN:
|
||||
sortierung += 1
|
||||
_, neu = _get_or_create(
|
||||
db, Klasse, saison_jahr=2026, code=code,
|
||||
defaults={
|
||||
"kategorie": Kategorie.STAMM,
|
||||
"bezeichnung": f"Stammfahrer {code}",
|
||||
"fahrzeugtyp": None,
|
||||
"sortierung": sortierung,
|
||||
"notiz": "O-2 offen: Fahrzeugtyp/Hubraum/kW noch zuzuordnen",
|
||||
},
|
||||
)
|
||||
zaehler["klasse"] += int(neu)
|
||||
|
||||
for code in ANFAENGERKLASSEN:
|
||||
sortierung += 1
|
||||
_, neu = _get_or_create(
|
||||
db, Klasse, saison_jahr=2026, code=code,
|
||||
defaults={
|
||||
"kategorie": Kategorie.ANFAENGER,
|
||||
"bezeichnung": f"Anfaenger {code}",
|
||||
"fahrzeugtyp": None,
|
||||
"sortierung": sortierung,
|
||||
"notiz": "O-2 offen: Fahrzeugtyp/Hubraum/kW noch zuzuordnen",
|
||||
},
|
||||
)
|
||||
zaehler["klasse"] += int(neu)
|
||||
|
||||
platzhalter_regelwerk = {
|
||||
"punkte": {str(k): v for k, v in PLATZHALTER_PUNKTETABELLE.items()},
|
||||
"_hinweis": "O-1 offen: PLATZHALTER-Punktetabelle, nicht offiziell!",
|
||||
}
|
||||
|
||||
_, neu = _get_or_create(
|
||||
db, Meisterschaft, saison_jahr=2026, bezeichnung="HTH Erwachsene 2026",
|
||||
defaults={"kategorie": Kategorie.STAMM, "regelwerk": platzhalter_regelwerk},
|
||||
)
|
||||
zaehler["meisterschaft"] += int(neu)
|
||||
|
||||
jugendklassen = db.scalars(
|
||||
select(Klasse).where(Klasse.saison_jahr == 2026, Klasse.kategorie == Kategorie.JUGEND)
|
||||
).all()
|
||||
for k in jugendklassen:
|
||||
_, neu = _get_or_create(
|
||||
db, Meisterschaft, saison_jahr=2026, bezeichnung=f"HTH Jugend {k.code} 2026",
|
||||
defaults={
|
||||
"kategorie": Kategorie.JUGEND,
|
||||
"klasse_id": k.id,
|
||||
"regelwerk": platzhalter_regelwerk,
|
||||
},
|
||||
)
|
||||
zaehler["meisterschaft"] += int(neu)
|
||||
|
||||
return zaehler
|
||||
@@ -0,0 +1,50 @@
|
||||
/* OAMC Turnierauswertung — handgeschrieben, keine Build-Pipeline (Plan §5.2) */
|
||||
:root {
|
||||
--fg: #1a1a1a; --bg: #fbfbfa; --muted: #666; --line: #e2e2df;
|
||||
--akzent: #0b5; --akzent-dark: #084; --warn: #b45309; --warn-bg: #fef3c7;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; color: var(--fg); background: var(--bg);
|
||||
font: 16px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
a { color: var(--akzent-dark); }
|
||||
.wrap { max-width: 60rem; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
|
||||
.topbar { display: flex; flex-wrap: wrap; gap: 1rem; align-items: baseline;
|
||||
padding: .8rem 1rem; background: #fff; border-bottom: 1px solid var(--line); }
|
||||
.topbar .brand { font-weight: 800; text-decoration: none; color: var(--fg); }
|
||||
.topbar nav { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.foot { border-top: 1px solid var(--line); padding: 1rem; text-align: center;
|
||||
color: var(--muted); font-size: .85rem; }
|
||||
h1 { font-size: 1.7rem; margin: .2em 0 .6em; }
|
||||
h2 { font-size: 1.25rem; margin: 1.4em 0 .4em; }
|
||||
table { width: 100%; border-collapse: collapse; margin: .5rem 0 1.5rem;
|
||||
font-variant-numeric: tabular-nums; display: block; overflow-x: auto; }
|
||||
th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid var(--line); }
|
||||
th { font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
|
||||
td.num, th.num { text-align: right; }
|
||||
tr.vorlaeufig td { color: var(--muted); font-style: italic; }
|
||||
.badge { display: inline-block; font-size: .72rem; padding: .05em .5em; border-radius: 1em;
|
||||
background: #eee; color: #444; vertical-align: middle; }
|
||||
.hinweis { background: var(--warn-bg); color: var(--warn); padding: .6rem .9rem;
|
||||
border-radius: .4rem; border: 1px solid #fcd34d; }
|
||||
.cards { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); }
|
||||
.card { background: #fff; border: 1px solid var(--line); border-radius: .5rem; padding: 1rem; }
|
||||
.card h2 { margin-top: 0; }
|
||||
ul.plain { list-style: none; padding: 0; } ul.plain li { margin: .3rem 0; }
|
||||
.tiles { display: flex; gap: .8rem; flex-wrap: wrap; margin: 1rem 0; }
|
||||
.tiles a { background: #fff; border: 1px solid var(--line); border-radius: .4rem;
|
||||
padding: .6rem 1rem; text-decoration: none; }
|
||||
form.schmal, form.inline { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||||
form.schmal { flex-direction: column; align-items: stretch; max-width: 22rem; }
|
||||
label { display: block; }
|
||||
input, button { font: inherit; padding: .4rem .5rem; border: 1px solid var(--line);
|
||||
border-radius: .3rem; background: #fff; }
|
||||
button { background: var(--akzent-dark); color: #fff; border-color: var(--akzent-dark);
|
||||
cursor: pointer; }
|
||||
.mini { font-size: .75rem; color: var(--muted); display: inline; }
|
||||
.ok { color: var(--akzent-dark); } .warn { color: var(--warn); }
|
||||
kbd { background: #eee; border: 1px solid #ccc; border-radius: .2rem; padding: 0 .3em; }
|
||||
input[type=search] { width: 100%; max-width: 22rem; margin-bottom: .5rem; }
|
||||
@media print {
|
||||
.topbar, .foot, form { display: none; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,79 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Anzeige – OAMC Turnier</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin: 0; background: #0b0b0c; color: #f4f4f5;
|
||||
font: 16px/1.4 system-ui, sans-serif; }
|
||||
.kiosk { min-height: 100vh; display: flex; flex-direction: column; padding: 3vh 4vw; }
|
||||
.status { font-size: 2vw; opacity: .7; display: flex; justify-content: space-between; }
|
||||
.status .offline { color: #f87171; font-weight: 700; }
|
||||
.aktuell { flex: 1; display: flex; flex-direction: column; justify-content: center; }
|
||||
.nr { font-size: 12vw; font-weight: 800; line-height: 1; }
|
||||
.name { font-size: 6vw; font-weight: 700; }
|
||||
.meta { font-size: 3vw; opacity: .8; }
|
||||
.zeit { font-size: 9vw; font-weight: 800; color: #4ade80; }
|
||||
.naechste { font-size: 2.4vw; opacity: .85; }
|
||||
.naechste li { margin: .2em 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="kiosk">
|
||||
<div class="status">
|
||||
<span id="turnier">–</span>
|
||||
<span id="verbindung"></span>
|
||||
</div>
|
||||
<div class="aktuell">
|
||||
<div class="nr" id="nr">–</div>
|
||||
<div class="zeit" id="zeit"></div>
|
||||
<div class="meta" id="meta"></div>
|
||||
</div>
|
||||
<div class="naechste">
|
||||
<strong>Nächste Starter</strong>
|
||||
<ol id="naechste"></ol>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const LS_KEY = "oamc_anzeige_letzte";
|
||||
function render(d) {
|
||||
document.getElementById("turnier").textContent = d.aktiv ? ("Turnier " + d.turnier) : "kein aktives Turnier";
|
||||
if (d.letzte_messung) {
|
||||
document.getElementById("nr").textContent = "Nr. " + (d.letzte_messung.startnummer ?? "?");
|
||||
document.getElementById("zeit").textContent = d.letzte_messung.dauer_sekunden.toFixed(2).replace(".", ",") + " s";
|
||||
document.getElementById("meta").textContent = d.letzte_messung.status;
|
||||
}
|
||||
const ol = document.getElementById("naechste");
|
||||
ol.innerHTML = "";
|
||||
(d.naechste_starter || []).forEach(s => {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = `Nr. ${s.startnummer} – ${s.name} (${s.klasse ?? "?"})`;
|
||||
ol.appendChild(li);
|
||||
});
|
||||
}
|
||||
try {
|
||||
const cached = localStorage.getItem(LS_KEY);
|
||||
if (cached) render(JSON.parse(cached));
|
||||
} catch (e) {}
|
||||
|
||||
let es;
|
||||
function connect() {
|
||||
es = new EventSource("/api/v1/turniere/aktiv/anzeige");
|
||||
es.addEventListener("anzeige", ev => {
|
||||
document.getElementById("verbindung").textContent = "";
|
||||
const d = JSON.parse(ev.data);
|
||||
render(d);
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify(d)); } catch (e) {}
|
||||
});
|
||||
es.onerror = () => {
|
||||
document.getElementById("verbindung").innerHTML = '<span class="offline">OFFLINE – letzter Stand</span>';
|
||||
es.close();
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
}
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{% if noindex %}<meta name="robots" content="noindex">{% endif %}
|
||||
<title>{% block title %}{{ projekt }}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
<script src="/static/js/htmx.min.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a href="/" class="brand">OAMC Turnier</a>
|
||||
<nav>
|
||||
<a href="/termine">Termine</a>
|
||||
<a href="/ergebnisse">Ergebnisse</a>
|
||||
<a href="/fahrer">Fahrer</a>
|
||||
<a href="/meisterschaft">Meisterschaft</a>
|
||||
{% if request.cookies.get('oamc_session') %}<a href="/intern">Intern</a>{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
<main class="wrap">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="foot">
|
||||
<p>OAMC Reinheim e.V. im ADAC · Turnierauswertung · <a href="/docs">API</a> · <a href="/termine.ics">iCal</a></p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Intern · {{ projekt }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Intern</h1>
|
||||
<p>Angemeldet als <strong>{{ benutzer.anzeigename }}</strong> ({{ benutzer.rolle.value }}) ·
|
||||
<a href="/intern/logout">abmelden</a></p>
|
||||
|
||||
{% if hat_offene_messungen %}
|
||||
<p class="hinweis"><a href="/intern/messungen">Es gibt unzugeordnete oder unplausible
|
||||
Lichtschranken-Messungen →</a></p>
|
||||
{% endif %}
|
||||
|
||||
<nav class="tiles">
|
||||
<a href="/intern/messungen">Messungen zuordnen</a>
|
||||
<a href="/intern/geraete">Geräte</a>
|
||||
<a href="/docs">API-Doku</a>
|
||||
</nav>
|
||||
|
||||
<h2>Veranstaltungen</h2>
|
||||
<ul class="plain">
|
||||
{% for v in veranstaltungen %}
|
||||
<li>{{ v.datum.strftime('%d.%m.%Y') }} – {{ v.titel }}
|
||||
{% for t in v.turniere %}
|
||||
· <a href="/intern/erfassung/{{ t.id }}">Erfassung {{ t.name }}</a>
|
||||
{% endfor %}
|
||||
</li>
|
||||
{% else %}
|
||||
<li>Keine Veranstaltungen. Anlegen über die Ingest-API (<code>POST /api/v1/veranstaltungen</code>).</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Erfassung {{ turnier.name }} · {{ projekt }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Ergebniserfassung – Turnier {{ turnier.name }}</h1>
|
||||
<p>Zeit im Format <code>159,16</code> · Fehler als Vielfaches von 10 · Haken = Wert endgültig.
|
||||
Speichern per <kbd>Enter</kbd> im jeweiligen Feld.</p>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Nr.</th><th>Fahrer</th><th>Klasse</th><th>Zeit</th><th>Fehler</th>
|
||||
<th>Gesamt</th><th>Platz</th></tr></thead>
|
||||
<tbody>
|
||||
{% set turnier_id = turnier.id %}
|
||||
{% for s in starts %}
|
||||
{% include "partials/erfassung_zeile.html" with context %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Geräte · {{ projekt }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Geräte am Parcours</h1>
|
||||
<table>
|
||||
<thead><tr><th>Bezeichnung</th><th>Typ</th><th>aktiv</th><th>Heartbeat</th>
|
||||
<th class="num">Queue</th><th>Firmware</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in geraete %}
|
||||
<tr>
|
||||
<td>{{ row.g.bezeichnung }}</td>
|
||||
<td>{{ row.g.typ.value }}</td>
|
||||
<td>{{ 'ja' if row.g.aktiv else 'gesperrt' }}</td>
|
||||
<td>
|
||||
{% if row.sekunden is none %}nie
|
||||
{% elif row.lebt %}<span class="ok">vor {{ row.sekunden }} s</span>
|
||||
{% else %}<span class="warn">vor {{ row.sekunden }} s</span>{% endif %}
|
||||
</td>
|
||||
<td class="num">{{ row.g.queue_laenge_gemeldet if row.g.queue_laenge_gemeldet is not none else '–' }}</td>
|
||||
<td>{{ row.g.firmware_version or '–' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6">Keine Geräte registriert. Anlegen via
|
||||
<code>POST /api/v1/admin/geraete</code>.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Anmeldung · {{ projekt }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Interne Anmeldung</h1>
|
||||
{% if fehler %}<p class="hinweis">{{ fehler }}</p>{% endif %}
|
||||
<form method="post" action="/intern/login" class="schmal">
|
||||
<label>Benutzername <input name="benutzername" required autofocus></label>
|
||||
<label>Passwort <input name="passwort" type="password" required></label>
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Messungen zuordnen · {{ projekt }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Messungen zuordnen</h1>
|
||||
<p>Unzugeordnete und unplausible Lichtschranken-Messungen. Eine gemessene Zeit ist nicht
|
||||
reproduzierbar – sie wird nur mit Grund verworfen, nie stillschweigend.</p>
|
||||
<table>
|
||||
<thead><tr><th>Zeitpunkt</th><th>Turnier</th><th>gemeldete Nr.</th><th>Messpunkt</th>
|
||||
<th class="num">Dauer</th><th>Status</th><th>Aktion</th></tr></thead>
|
||||
<tbody>
|
||||
{% for m in messungen %}
|
||||
<tr>
|
||||
<td>{{ m.gemessen_am.strftime('%H:%M:%S') }}</td>
|
||||
<td>{{ m.turnier_id }}</td>
|
||||
<td>{{ m.startnummer_gemeldet or '–' }}</td>
|
||||
<td>{{ m.messpunkt }}</td>
|
||||
<td class="num">{{ '%.2f' % m.dauer_sekunden }}</td>
|
||||
<td><span class="badge">{{ m.status.value }}</span></td>
|
||||
<td>
|
||||
<form method="post" action="/api/v1/admin/zeitmessungen/{{ m.id }}/zuordnen" class="inline">
|
||||
<input type="number" name="startnummer" placeholder="Nr." style="width:4rem" required>
|
||||
<button type="submit">zuordnen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7">Keine offenen Messungen. 👍</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="mini">Hinweis: Die Zuordnen-Aktion nutzt die Admin-API (JSON). Für die
|
||||
produktive Bedienung wird hier ein HTMX-Formular mit JSON-Encoding ergänzt.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
<tr id="start-{{ s.id }}">
|
||||
<td>{{ s.startnummer }}</td>
|
||||
<td>{{ s.fahrer.voller_name if s.fahrer else '?' }}</td>
|
||||
<td>{{ s.klasse.code if s.klasse else '–' }}</td>
|
||||
<td>
|
||||
<form hx-post="/intern/erfassung/{{ turnier_id }}/start/{{ s.id }}"
|
||||
hx-target="#start-{{ s.id }}" hx-swap="outerHTML">
|
||||
<input type="text" name="summe_zeit" inputmode="decimal" style="width:6rem"
|
||||
value="{{ ('%.2f' % s.ergebnis.summe_zeit)|replace('.', ',') if s.ergebnis and s.ergebnis.summe_zeit is not none else '' }}">
|
||||
<label class="mini"><input type="checkbox" name="zeit_fertig" value="true"
|
||||
{{ 'checked' if s.ergebnis and s.ergebnis.zeit_vollstaendig }}> fertig</label>
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" name="summe_fehler" step="10" min="0" style="width:5rem"
|
||||
value="{{ s.ergebnis.summe_fehler if s.ergebnis and s.ergebnis.summe_fehler is not none else '' }}">
|
||||
<label class="mini"><input type="checkbox" name="fehler_fertig" value="true"
|
||||
{{ 'checked' if s.ergebnis and s.ergebnis.fehler_vollstaendig }}> fertig</label>
|
||||
<button type="submit">OK</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>{{ ('%.2f' % s.ergebnis.gesamt)|replace('.', ',') if s.ergebnis and s.ergebnis.gesamt is not none else '–' }}</td>
|
||||
<td>{{ s.ergebnis.platzierung if s.ergebnis and s.ergebnis.platzierung is not none else '–' }}</td>
|
||||
</tr>
|
||||
@@ -0,0 +1,14 @@
|
||||
<table id="fahrer-tabelle">
|
||||
<thead><tr><th>Name</th><th>Jg.</th><th>Verein</th></tr></thead>
|
||||
<tbody>
|
||||
{% for f in fahrer %}
|
||||
<tr>
|
||||
<td><a href="/fahrer/{{ f.id }}">{{ f.nachname }} {{ f.vorname }}</a></td>
|
||||
<td>{{ f.geburtsjahr or '–' }}</td>
|
||||
<td>{{ f.verein.name if f.verein else '–' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3">Keine Treffer.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Ergebnisse · {{ projekt }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Ergebnisse</h1>
|
||||
{% for v in veranstaltungen %}
|
||||
<section class="card">
|
||||
<h2>{{ v.datum.strftime('%d.%m.%Y') }} – {{ v.titel }}</h2>
|
||||
<ul class="plain">
|
||||
{% for t in v.turniere %}
|
||||
<li><a href="/ergebnisse/turnier/{{ t.id }}">Turnier {{ t.name }}</a>
|
||||
<span class="badge">{{ t.status.value }}</span>
|
||||
· <a href="/ergebnisse/turnier/{{ t.id }}.csv">CSV</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% else %}
|
||||
<p>Noch keine Ergebnisse.</p>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ veranstaltung.titel }} – {{ turnier.name }} · {{ projekt }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ veranstaltung.titel }}</h1>
|
||||
<p>{{ veranstaltung.datum.strftime('%d.%m.%Y') }} · Turnier <strong>{{ turnier.name }}</strong>
|
||||
<span class="badge">{{ turnier.status.value }}</span>
|
||||
· <a href="/ergebnisse/turnier/{{ turnier.id }}.csv">CSV-Export</a></p>
|
||||
|
||||
{% if not final %}
|
||||
<p class="hinweis">Vorläufiger Stand – solange Zeit oder Fehlerpunkte fehlen, ist die
|
||||
Platzierung nicht endgültig.</p>
|
||||
{% endif %}
|
||||
|
||||
{% for wk in wertungsklassen %}
|
||||
<h2>{{ wk.wertungsklasse }}</h2>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Platz</th><th>Nr.</th><th>Name</th><th>Verein</th><th>Klasse</th>
|
||||
<th class="num">Zeit</th><th class="num">Fehler</th><th class="num">Gesamt</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for z in wk.zeilen %}
|
||||
<tr class="{{ 'vorlaeufig' if z.vorlaeufig else '' }}">
|
||||
<td>{{ z.platzierung | platz }}</td>
|
||||
<td>{{ z.startnummer }}</td>
|
||||
<td>{% if z.fahrer %}<a href="/fahrer/{{ z.fahrer.id }}">{{ z.fahrer.name }}</a>{% endif %}
|
||||
{% if z.status != 'gestartet' %}<span class="badge">{{ z.status }}</span>{% endif %}</td>
|
||||
<td>{{ z.fahrer.verein if z.fahrer and z.fahrer.verein else '–' }}</td>
|
||||
<td>{{ z.klasse or '–' }}</td>
|
||||
<td class="num">{{ z.summe_zeit | zeit }}</td>
|
||||
<td class="num">{{ z.summe_fehler if z.summe_fehler is not none else '–' }}</td>
|
||||
<td class="num"><strong>{{ z.gesamt | zeit }}</strong></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>Für dieses Turnier liegen keine Startdaten vor.</p>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Fahrer · {{ projekt }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Fahrer</h1>
|
||||
<input type="search" name="q" placeholder="Name suchen…" value="{{ q }}"
|
||||
hx-get="/fahrer" hx-trigger="keyup changed delay:300ms" hx-target="#fahrer-tabelle"
|
||||
hx-select="#fahrer-tabelle" hx-swap="outerHTML" autocomplete="off">
|
||||
{% include "partials/fahrer_tabelle.html" %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ fahrer.anzeigename }} · {{ projekt }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ fahrer.anzeigename }}</h1>
|
||||
{% if not fahrer.anzeige_anonymisiert %}
|
||||
<p>{{ fahrer.geburtsjahr or '' }}{% if fahrer.verein %} · {{ fahrer.verein.name }}{% endif %}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="cards">
|
||||
<div class="card"><h2>{{ anzahl }}</h2><p>Starts</p></div>
|
||||
<div class="card"><h2>{{ bestzeit | zeit }}</h2><p>Bestzeit (s)</p></div>
|
||||
</div>
|
||||
|
||||
<h2>Starts</h2>
|
||||
<table>
|
||||
<thead><tr><th>Datum</th><th>Veranstaltung</th><th>Turnier</th><th>Klasse</th>
|
||||
<th class="num">Zeit</th><th class="num">Fehler</th><th class="num">Platz</th></tr></thead>
|
||||
<tbody>
|
||||
{% for s in starts %}
|
||||
<tr>
|
||||
<td>{{ s.turnier.veranstaltung.datum.strftime('%d.%m.%Y') }}</td>
|
||||
<td><a href="/ergebnisse/turnier/{{ s.turnier.id }}">{{ s.turnier.veranstaltung.titel }}</a></td>
|
||||
<td>{{ s.turnier.name }}</td>
|
||||
<td>{{ s.klasse.code if s.klasse else '–' }}</td>
|
||||
<td class="num">{{ s.ergebnis.summe_zeit | zeit if s.ergebnis else '–' }}</td>
|
||||
<td class="num">{{ s.ergebnis.summe_fehler if s.ergebnis and s.ergebnis.summe_fehler is not none else '–' }}</td>
|
||||
<td class="num">{{ s.ergebnis.platzierung | platz if s.ergebnis else '–' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7">Keine Starts.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Meisterschaftsstand · {{ projekt }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Meisterschaftsstand</h1>
|
||||
<p>Saison {{ bericht.saison_jahr }} · Kategorie {{ bericht.kategorie }} ·
|
||||
{{ bericht.laeufe_gewertet }} von {{ bericht.laeufe_gesamt }} Läufen gewertet
|
||||
(Rest = Streichresultate)</p>
|
||||
|
||||
{% for h in bericht.hinweise %}
|
||||
<p class="hinweis">{{ h }}</p>
|
||||
{% endfor %}
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Platz</th><th>Fahrer</th><th class="num">Punkte</th><th class="num">Siege</th>
|
||||
<th class="num">Streichresultate</th></tr></thead>
|
||||
<tbody>
|
||||
{% for z in bericht.zeilen %}
|
||||
{% set f = fahrer_map.get(z.fahrer_id) %}
|
||||
<tr>
|
||||
<td>{{ z.platz }}</td>
|
||||
<td>{% if f %}<a href="/fahrer/{{ f.id }}">{{ f.anzeigename }}</a>{% else %}#{{ z.fahrer_id }}{% endif %}</td>
|
||||
<td class="num">{{ '%.0f' % z.punkte_gesamt }}</td>
|
||||
<td class="num">{{ z.anzahl_siege }}</td>
|
||||
<td class="num">{{ z.gestrichene_laeufe | length }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5">Noch keine gewerteten Läufe.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Meisterschaft · {{ projekt }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Meisterschaften</h1>
|
||||
<ul class="plain">
|
||||
{% for m in meisterschaften %}
|
||||
<li><a href="/meisterschaft/{{ m.id }}">{{ m.bezeichnung }}</a>
|
||||
<span class="badge">{{ m.kategorie.value }}</span></li>
|
||||
{% else %}
|
||||
<li>Keine Meisterschaft angelegt.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>ADAC Motorrad-Turniere</h1>
|
||||
|
||||
<div class="cards">
|
||||
<section class="card">
|
||||
<h2>Nächster Termin</h2>
|
||||
{% if naechste %}
|
||||
<p><strong>{{ naechste.datum.strftime('%d.%m.%Y') }}</strong><br>
|
||||
{{ naechste.titel }}{% if naechste.ort %} · {{ naechste.ort }}{% endif %}</p>
|
||||
{% else %}
|
||||
<p>Kein Termin geplant.</p>
|
||||
{% endif %}
|
||||
<a href="/termine">Alle Termine →</a>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Letzte Ergebnisse</h2>
|
||||
<ul class="plain">
|
||||
{% for v in letzte %}
|
||||
<li><a href="/ergebnisse">{{ v.datum.strftime('%d.%m.%Y') }} – {{ v.titel }}</a>
|
||||
<span class="badge">{{ v.status.value }}</span></li>
|
||||
{% else %}
|
||||
<li>Noch keine Ergebnisse erfasst.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Meisterschaft</h2>
|
||||
<ul class="plain">
|
||||
{% for m in meisterschaften %}
|
||||
<li><a href="/meisterschaft/{{ m.id }}">{{ m.bezeichnung }}</a></li>
|
||||
{% else %}
|
||||
<li>Keine Meisterschaft angelegt.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Termine · {{ projekt }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Termine{% if saison %} {{ saison }}{% endif %}</h1>
|
||||
<p><a href="/termine.ics">iCal-Abo</a></p>
|
||||
<table>
|
||||
<thead><tr><th>Datum</th><th>Titel</th><th>Ort</th><th>Meisterschaft</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for v in veranstaltungen %}
|
||||
<tr>
|
||||
<td>{{ v.datum.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ v.titel }}{% if v.ist_doppelturnier %} <span class="badge">Doppelturnier</span>{% endif %}</td>
|
||||
<td>{{ v.ort or '–' }}</td>
|
||||
<td>{% if v.turniere and v.turniere[0].zaehlt_meisterschaft %}mit{% else %}o. M.{% endif %}</td>
|
||||
<td><span class="badge">{{ v.status.value }}</span></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5">Keine Termine.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Wertungs-Engine — das Kernstueck (Plan §3.3, AP 2).
|
||||
|
||||
Die Wertungsformel ist pro Kategorie und Saison konfigurierbar, nicht fest
|
||||
verdrahtet. Abnahmetest: die reale Ergebnisliste vom 31.05.2026 muss exakt
|
||||
reproduziert werden (``tests/test_wertung_regression_2026_05_31.py``).
|
||||
"""
|
||||
|
||||
from oamc.wertung.engine import (
|
||||
DEFAULT_REGEL_JE_KATEGORIE,
|
||||
REGELN,
|
||||
WertungsEingabe,
|
||||
WertungsErgebnis,
|
||||
platziere,
|
||||
regel_fuer,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"WertungsEingabe",
|
||||
"WertungsErgebnis",
|
||||
"platziere",
|
||||
"regel_fuer",
|
||||
"REGELN",
|
||||
"DEFAULT_REGEL_JE_KATEGORIE",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user