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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
linus
2026-09-05 18:49:00 +02:00
co-authored by Claude Sonnet 5
commit 03f6fdb0d9
115 changed files with 8041 additions and 0 deletions
+7
View File
@@ -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)"
]
}
}
+10
View File
@@ -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'\\)\")"
]
}
}
+18
View File
@@ -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
+26
View File
@@ -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
View File
@@ -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
+138
View File
@@ -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
View File
@@ -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
+8
View File
@@ -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
+29
View File
@@ -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.
+8
View File
@@ -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.
+18
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
var/oamc/uploads
etc/oamc-turnier
+4
View File
@@ -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
+28
View File
@@ -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
Vendored Executable
+33
View File
@@ -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
Vendored Executable
+14
View File
@@ -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
Vendored Executable
+38
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
3.0 (native)
+23
View File
@@ -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
+40
View File
@@ -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;
}
}
+28
View File
@@ -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
+55
View File
@@ -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:
+45
View File
@@ -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"]
+30
View File
@@ -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 "$@"
+40
View File
@@ -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;
}
}
+112
View File
@@ -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.
+66
View File
@@ -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()
+26
View File
@@ -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 ###
+35
View File
@@ -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
```
+19
View File
@@ -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
+8
View File
@@ -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
+848
View File
@@ -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 20022026),
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 | 79 | Kindermotorrad max. 110 ccm / 5,5 kW |
| J 2 | 2016 / 2015 | 1011 | Kindermotorrad max. 110 ccm / 5,5 kW |
| J 3 | 2014 / 2013 | 1213 | Mofa, Motorrad + Roller max. 125 ccm / 11 kW |
| J 4 | 2012 / 2011 | 1415 | Mofa, Motorrad + Roller max. 125 ccm / 11 kW |
| J 5 | 2010 / 2009 / 2008 (bis 18. Geburtstag) | 1618 | 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 1J 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 1J 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 13 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
+72
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
"""OAMC Reinheim — Auswertung von ADAC Motorrad-Turnieren.
Fachliche Grundlagen und Architektur: siehe ``plan.md`` im Projektwurzelverzeichnis.
"""
__version__ = "0.1.0"
+1
View File
@@ -0,0 +1 @@
"""HTTP-API (FastAPI). Zwei Richtungen: Ingest (rein) und Read (raus). Plan §6."""
+15
View File
@@ -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"]
+210
View File
@@ -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],
}
+146
View File
@@ -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))
+155
View File
@@ -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],
}
+218
View File
@@ -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
],
}
+67
View File
@@ -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),
}
+97
View File
@@ -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())
+67
View File
@@ -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()
+34
View File
@@ -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()
+9
View File
@@ -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"]
+157
View File
@@ -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})
+245
View File
@@ -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"
+26
View File
@@ -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"
+17
View File
@@ -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",
]
+170
View File
@@ -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
]
+15
View File
@@ -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",
]
+319
View File
@@ -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
+146
View File
@@ -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
)
+55
View File
@@ -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()
+27
View File
@@ -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",
]
+162
View File
@@ -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
+23
View File
@@ -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",
]
+226
View File
@@ -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
+53
View File
@@ -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",
]
+35
View File
@@ -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
)
+43
View File
@@ -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,
)
+33
View File
@@ -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
+71
View File
@@ -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"
+54
View File
@@ -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
+44
View File
@@ -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}>"
+64
View File
@@ -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
+50
View File
@@ -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"))
+102
View File
@@ -0,0 +1,102 @@
"""Start (eine Teilnahme) und Ergebnis (1:1 zum Start).
Schluessel eines Ergebnisses ist (Turnier, Start), NICHT (Turnier, Fahrer) ein
Fahrer kann am selben Tag mehrfach starten (Plan §3.3 Mehrfachstarts).
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from oamc.models.base import Base, sa_enum
from oamc.models.enums import StartStatus
class Start(Base):
__tablename__ = "start"
__table_args__ = (
UniqueConstraint("turnier_id", "startnummer", name="start_turnier_startnummer"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
turnier_id: Mapped[int] = mapped_column(
ForeignKey("turnier.id", ondelete="CASCADE"), nullable=False, index=True
)
fahrer_id: Mapped[int] = mapped_column(ForeignKey("fahrer.id"), nullable=False, index=True)
klasse_id: Mapped[int] = mapped_column(ForeignKey("klasse.id"), nullable=False)
wertungsklasse_id: Mapped[int | None] = mapped_column(
ForeignKey("wertungsklasse.id", ondelete="SET NULL"), index=True
)
startnummer: Mapped[int] = mapped_column(Integer, nullable=False)
fahrzeug: Mapped[str | None] = mapped_column(String(200))
nenngeld_bezahlt: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
status: Mapped[StartStatus] = mapped_column(
sa_enum(StartStatus, "start_status"), nullable=False, default=StartStatus.GENANNT
)
nennreihenfolge: Mapped[int | None] = mapped_column(Integer) # nummerierte Nennungsliste
turnier: Mapped[Turnier] = relationship(back_populates="starts") # noqa: F821
fahrer: Mapped[Fahrer] = relationship(back_populates="starts") # noqa: F821
klasse: Mapped[Klasse] = relationship() # noqa: F821
wertungsklasse: Mapped[Wertungsklasse | None] = relationship( # noqa: F821
back_populates="starts"
)
ergebnis: Mapped[Ergebnis | None] = relationship(
back_populates="start", cascade="all, delete-orphan", uselist=False
)
zeitmessungen: Mapped[list[Zeitmessung]] = relationship( # noqa: F821
back_populates="start"
)
@property
def gewertet(self) -> bool:
return self.status in (StartStatus.GESTARTET,)
class Ergebnis(Base):
"""1:1 zum Start — getrennte Tabelle fuer saubere Nachtraege/Korrekturen.
Zwei Quellen (Plan §3.6): ``summe_zeit`` aus ``zeitmessung`` summiert,
``summe_fehler`` manuell erfasst. Ein "halbes" Ergebnis ist der Normalzustand
waehrend der Veranstaltung. Gewertet wird erst, wenn beide ``*_vollstaendig``.
"""
__tablename__ = "ergebnis"
start_id: Mapped[int] = mapped_column(
ForeignKey("start.id", ondelete="CASCADE"), primary_key=True
)
summe_zeit: Mapped[Decimal | None] = mapped_column(Numeric(7, 2)) # aus zeitmessung summiert
summe_fehler: Mapped[int | None] = mapped_column(Integer) # manuell erfasst
gesamt: Mapped[Decimal | None] = mapped_column(Numeric(9, 2)) # berechnet
platzierung: Mapped[int | None] = mapped_column(Integer) # berechnet
zeit_vollstaendig: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
fehler_vollstaendig: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
erfasst_von: Mapped[str | None] = mapped_column(String(120))
erfasst_am: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
korrigiert_am: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
korrektur_grund: Mapped[str | None] = mapped_column(Text)
start: Mapped[Start] = relationship(back_populates="ergebnis")
@property
def wertbar(self) -> bool:
return bool(self.zeit_vollstaendig and self.fehler_vollstaendig)
+95
View File
@@ -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
+97
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Pydantic-v2-Schemata: ein Schema fuer API-Validierung UND OpenAPI-Doku."""
+67
View File
@@ -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)
+112
View File
@@ -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
+22
View File
@@ -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",
]
+93
View File
@@ -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)]
+29
View File
@@ -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
+51
View File
@@ -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
+13
View File
@@ -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"]
+146
View File
@@ -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
+50
View File
@@ -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; }
}
File diff suppressed because one or more lines are too long
+79
View File
@@ -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>
+29
View File
@@ -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&nbsp;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>
+31
View File
@@ -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 %}
+18
View File
@@ -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 %}
+28
View File
@@ -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 %}
+11
View File
@@ -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 %}
+33
View File
@@ -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">Streich­resultate</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 %}
+40
View File
@@ -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 %}
+22
View File
@@ -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 %}
+24
View File
@@ -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