Erstumsetzung: OAMC Turnierauswertung (AP 0–7, 9)
* Datenmodell + Alembic-Initialmigration (Plan §4), Seed Saison 2026 * Wertungs-Engine (Plan §3.3), regelbasiert je Kategorie/Saison - Abnahmetest gegen die Ergebnisliste 2026-05-31 * Meisterschaftsberechnung mit Streichresultaten + Tie-Break (PLATZHALTER-Punktetabelle, O-1 offen) * Ingest-API (idempotent, dry_run) inkl. Excel-Leser und Fahrer-Matching/Merge * Zeitmessungs-Endpoint (Batch, idempotent, unzugeordnet/unplausibel) + SSE-Anzeige - Mess-Pi-Client bewusst NICHT enthalten (docs/zeitmessung-endpoint.md) * Frontend: öffentliche Seiten + internes Backend (Jinja2/HTMX, kein Build) * Auslieferung: Docker (multi-arch) + docker-compose sowie Debian-Paket/APT-Repo * 57 Tests grün gegen echte PostgreSQL-Testdatenbank Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(python3 pdftxt.py /home/linus/.claude/projects/-home-linus-OAMC/40bc91cb-023f-4cbe-8d30-ab5118a56de2/tool-results/webfetch-1788609089573-vokmc5.pdf)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"WebFetch(domain:www.oamc.de)",
|
||||||
|
"WebFetch(domain:oamc.de)",
|
||||||
|
"Bash(python3 -c \"import pypdf; print\\('pypdf ok'\\)\")",
|
||||||
|
"Bash(python3 -c \"import fitz; print\\('pymupdf ok'\\)\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
.venv/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
tests/
|
||||||
|
docs/
|
||||||
|
debian/
|
||||||
|
deploy/
|
||||||
|
packaging/
|
||||||
|
var/
|
||||||
|
*.sqlite
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# OAMC Turnierauswertung — Beispiel-Konfiguration
|
||||||
|
# Kopieren nach .env und anpassen. Alle Werte koennen auch als Umgebungsvariable
|
||||||
|
# mit Prefix OAMC_ gesetzt werden (z. B. OAMC_DATABASE_URL).
|
||||||
|
|
||||||
|
# --- Datenbank ---------------------------------------------------------------
|
||||||
|
OAMC_DATABASE_URL=postgresql+psycopg://linus:linus@localhost:5432/oamc_dev
|
||||||
|
|
||||||
|
# --- Sicherheit ------------------------------------------------------------
|
||||||
|
# Signierschluessel fuer Session-/JWT-Cookies. In Produktion zwingend setzen!
|
||||||
|
OAMC_SECRET_KEY=bitte-aendern-mindestens-32-zeichen-zufaellig
|
||||||
|
# Kommagetrennte API-Keys fuer den Ingest (Excel-Auswertung etc.)
|
||||||
|
OAMC_INGEST_API_KEYS=dev-ingest-key
|
||||||
|
|
||||||
|
# --- Betrieb ---------------------------------------------------------------
|
||||||
|
OAMC_ENV=dev
|
||||||
|
OAMC_LOG_LEVEL=info
|
||||||
|
# Basis-URL, unter der die Anwendung oeffentlich erreichbar ist (fuer iCal, Links)
|
||||||
|
OAMC_PUBLIC_BASE_URL=http://localhost:8000
|
||||||
|
# Verzeichnis fuer Uploads (PDFs, Bilder)
|
||||||
|
OAMC_UPLOAD_DIR=./var/uploads
|
||||||
|
|
||||||
|
# --- Zeitmessung / Geraete ----------------------------------------------
|
||||||
|
# Plausibilitaetsgrenzen fuer eine Einzelmessung in Sekunden (global; je Klasse
|
||||||
|
# spaeter aus der DB). Verstoesse werden markiert, nicht abgelehnt.
|
||||||
|
OAMC_MESS_MIN_SEKUNDEN=15
|
||||||
|
OAMC_MESS_MAX_SEKUNDEN=600
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Lokale Konfiguration & Secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.local.toml
|
||||||
|
|
||||||
|
# Laufzeitdaten
|
||||||
|
/var/
|
||||||
|
uploads/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Debian-Build-Artefakte
|
||||||
|
debian/.debhelper/
|
||||||
|
debian/oamc-turnier/
|
||||||
|
debian/files
|
||||||
|
debian/*.substvars
|
||||||
|
debian/*.log
|
||||||
|
debian/debhelper-build-stamp
|
||||||
|
../*.deb
|
||||||
|
../*.buildinfo
|
||||||
|
../*.changes
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# OAMC Turnierauswertung
|
||||||
|
|
||||||
|
Webanwendung zur Auswertung von **ADAC Motorrad-Turnieren** des OAMC Reinheim e.V.:
|
||||||
|
Ergebnisse (Fehlerpunkte + Zeit → Platzierung), Fahrerdatenbank mit Historie,
|
||||||
|
Meisterschaftswertung und ein **Endpoint für die Lichtschranken-Zeitmessung**.
|
||||||
|
|
||||||
|
Fachliche Grundlagen, offene Fragen und Architekturbegründung: **[`plan.md`](plan.md)**.
|
||||||
|
|
||||||
|
> Der Mess-Pi-Client (GPIO/Lichtschranke) ist **nicht** Teil dieses Repos — die
|
||||||
|
> Anwendung stellt nur den Endpoint bereit. Contract:
|
||||||
|
> [`docs/zeitmessung-endpoint.md`](docs/zeitmessung-endpoint.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Schicht | Wahl |
|
||||||
|
|---|---|
|
||||||
|
| DB | PostgreSQL 17 (JSONB, ARRAY, `pg_trgm`) |
|
||||||
|
| Backend | Python 3.11+ · FastAPI · SQLAlchemy 2 · Alembic · Pydantic v2 |
|
||||||
|
| Frontend | serverseitig gerendertes Jinja2 + HTMX (kein Node, kein Build) |
|
||||||
|
| Live | Server-Sent Events (`sse-starlette`) |
|
||||||
|
| Auslieferung | **Docker-Image** *und* **Debian-Paket** (`.deb` / eigenes APT-Repo) |
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
src/oamc/
|
||||||
|
models/ ORM (§4 im Plan)
|
||||||
|
wertung/ Wertungs-Engine — der Kern (§3.3). engine.py + service.py
|
||||||
|
meisterschaft/ Punkte, Streichresultate, Tie-Break, Endlaufquali (§3.4)
|
||||||
|
matching/ Fahrer-Dublettenerkennung + Merge (§6.4)
|
||||||
|
importer/ Bulk-Ingest + Excel-Leser
|
||||||
|
geraete/ Zeitmessungs-Endpoint-Logik (§3.6)
|
||||||
|
api/v1/ read.py · ingest.py · geraete.py · admin.py
|
||||||
|
frontend/ public.py · intern.py + templates/ static/
|
||||||
|
migrations/ Alembic
|
||||||
|
tests/ pytest gegen echte PostgreSQL-Testdatenbank
|
||||||
|
docker/ Dockerfile · nginx.conf · entrypoint.sh
|
||||||
|
debian/ .deb-Paketierung (venv unter /opt/oamc-turnier)
|
||||||
|
deploy/ systemd-Unit · nginx-vHost · Backup-Skript (bare metal)
|
||||||
|
packaging/apt-repo/ reprepro-Konfiguration + Build-Skript
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m venv .venv && . .venv/bin/activate
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
cp .env.example .env # DATABASE_URL anpassen
|
||||||
|
|
||||||
|
createdb oamc_dev # PostgreSQL-Rolle mit CREATEDB nötig
|
||||||
|
alembic upgrade head
|
||||||
|
oamc-turnier seed # Stammdaten Saison 2026
|
||||||
|
oamc-turnier benutzer-anlegen admin --rolle admin
|
||||||
|
oamc-turnier serve --reload # http://127.0.0.1:8000 · /docs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Brauchen eine erreichbare PostgreSQL-Testdatenbank (`OAMC_DATABASE_URL`, Default
|
||||||
|
`…/oamc_test`). Das Schema wird pro Lauf frisch migriert, jeder Test läuft in einer
|
||||||
|
zurückgerollten Transaktion.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
createdb oamc_test
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Der **Abnahmetest der Wertungs-Engine** (`tests/test_wertung_regression_2026_05_31.py`)
|
||||||
|
rechnet die im Plan §3.3 zitierten Zeilen der realen Ergebnisliste vom 31.05.2026 nach.
|
||||||
|
|
||||||
|
## Auslieferung 1 — Docker / „docker repo"
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env # OAMC_SECRET_KEY setzen!
|
||||||
|
docker compose up -d # db + app + nginx → http://localhost:8080
|
||||||
|
docker compose exec app oamc-turnier seed
|
||||||
|
|
||||||
|
# Image für eine Registry bauen/pushen:
|
||||||
|
docker build -t <registry>/oamc-turnier:0.1.0 -f docker/Dockerfile .
|
||||||
|
docker push <registry>/oamc-turnier:0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-Arch für den Raspberry Pi 4:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker buildx build --platform linux/arm64,linux/amd64 \
|
||||||
|
-t <registry>/oamc-turnier:0.1.0 -f docker/Dockerfile --push .
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrationen laufen automatisch beim Containerstart (`docker/entrypoint.sh`).
|
||||||
|
|
||||||
|
## Auslieferung 2 — Debian-Paket / eigenes APT-Repo
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo apt install build-essential debhelper devscripts python3-venv
|
||||||
|
./packaging/apt-repo/build-deb.sh # → ../oamc-turnier_0.1.0_*.deb
|
||||||
|
sudo apt install ../oamc-turnier_0.1.0_*.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Paket bringt ein eigenes virtualenv unter `/opt/oamc-turnier/venv`, eine
|
||||||
|
systemd-Unit und Beispielkonfiguration mit. Danach:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudoedit /etc/oamc-turnier/oamc-turnier.env # DATABASE_URL, SECRET_KEY
|
||||||
|
sudo systemctl enable --now oamc-turnier
|
||||||
|
sudo -u oamc /opt/oamc-turnier/venv/bin/oamc-turnier seed
|
||||||
|
```
|
||||||
|
|
||||||
|
Eigenes signiertes APT-Repo aufsetzen: [`packaging/apt-repo/README.md`](packaging/apt-repo/README.md).
|
||||||
|
|
||||||
|
## API-Überblick (`/docs` für die vollständige OpenAPI-Doku)
|
||||||
|
|
||||||
|
| Bereich | Auth | Beispiele |
|
||||||
|
|---|---|---|
|
||||||
|
| **Read** | – | `GET /api/v1/turniere/{id}/ergebnisse`, `/api/v1/fahrer?q=`, `/api/v1/meisterschaften/{id}/stand` |
|
||||||
|
| **Ingest** | `X-API-Key` | `POST /api/v1/turniere/{id}/ergebnisse` (idempotent, `?dry_run=true`), `POST …/import/xlsx` |
|
||||||
|
| **Geräte** | `Authorization: Bearer <geräte-token>` | `POST /api/v1/zeitmessungen` (Batch, idempotent), SSE `GET /api/v1/turniere/aktiv/anzeige` |
|
||||||
|
| **Admin** | interne Session | `POST /api/v1/admin/geraete`, `…/zeitmessungen/{id}/zuordnen`, `…/meisterschaften/{id}/berechnen`, `…/fahrer/merge` |
|
||||||
|
|
||||||
|
## Bekannte offene Punkte (aus `plan.md` §10)
|
||||||
|
|
||||||
|
Diese sind **im Code klar markiert** und blockieren einen offiziellen Produktivgang:
|
||||||
|
|
||||||
|
* **O-1** — echte Meisterschafts-Punktetabelle fehlt. Es läuft eine
|
||||||
|
`PLATZHALTER_PUNKTETABELLE`; jeder berechnete Stand trägt einen Warnhinweis.
|
||||||
|
* **O-2** — Zuordnung `S 1`–`S 9` / `A 1`–`A 4` → Fahrzeugtyp: Klassen sind
|
||||||
|
angelegt, `fahrzeugtyp` bleibt `NULL`.
|
||||||
|
* **Wertungsregeln §3.3** sind aus PDFs *abgeleitet* — vor Produktivgang gegen die
|
||||||
|
ADAC-Turnierordnung 2026 zu verifizieren. Die Engine ist regelbasiert
|
||||||
|
konfigurierbar (`oamc.wertung.engine.REGELN`), nicht fest verdrahtet.
|
||||||
|
* **O-13/O-14/O-16** — Details der Zeitmessung (was wird gemessen, Zuordnung zum
|
||||||
|
Fahrer, Genauigkeit): siehe `docs/zeitmessung-endpoint.md`.
|
||||||
|
* **§11 Datenschutz** — Fahrerprofile sind `noindex` + `robots.txt`-gesperrt,
|
||||||
|
Geburts*jahr* statt -datum, Opt-out-Anonymisierung (`fahrer.anzeige_anonymisiert`)
|
||||||
|
ist umgesetzt; die Vorstandsentscheidung zur öffentlichen Darstellung von
|
||||||
|
Jugenddaten steht noch aus.
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# Alembic-Konfiguration. Die DB-URL kommt aus oamc.config (env.py), NICHT von hier.
|
||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
prepend_sys_path = src
|
||||||
|
path_separator = os
|
||||||
|
timezone = Europe/Berlin
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
oamc-turnier (0.1.0) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Erste Paketierung: FastAPI-Anwendung, Datenmodell + Alembic-Migrationen,
|
||||||
|
Wertungs-Engine (Plan §3.3), Meisterschaftsberechnung (Platzhalter-Punkte,
|
||||||
|
O-1 offen), Ingest-API, Geraete-/Zeitmessungs-Endpoint, oeffentliches und
|
||||||
|
internes Frontend.
|
||||||
|
|
||||||
|
-- OAMC Reinheim e.V. im ADAC <webmaster@oamc.de> Fri, 05 Sep 2026 18:00:00 +0200
|
||||||
Vendored
+29
@@ -0,0 +1,29 @@
|
|||||||
|
Source: oamc-turnier
|
||||||
|
Section: web
|
||||||
|
Priority: optional
|
||||||
|
Maintainer: OAMC Reinheim e.V. im ADAC <webmaster@oamc.de>
|
||||||
|
Build-Depends: debhelper-compat (= 13),
|
||||||
|
python3 (>= 3.11),
|
||||||
|
python3-venv,
|
||||||
|
python3-pip
|
||||||
|
Standards-Version: 4.6.2
|
||||||
|
Homepage: https://oamc.de
|
||||||
|
Rules-Requires-Root: no
|
||||||
|
|
||||||
|
Package: oamc-turnier
|
||||||
|
Architecture: any
|
||||||
|
Depends: ${misc:Depends},
|
||||||
|
python3 (>= 3.11),
|
||||||
|
adduser,
|
||||||
|
libpq5,
|
||||||
|
postgresql-client
|
||||||
|
Recommends: postgresql-17 | postgresql, nginx
|
||||||
|
Suggests: certbot
|
||||||
|
Description: Auswertung von ADAC Motorrad-Turnieren (OAMC Reinheim)
|
||||||
|
Webanwendung fuer Turnierergebnisse, Zeitmessungs-Ingest, Fahrerdatenbank und
|
||||||
|
Meisterschaftswertung. Enthaelt die FastAPI-Anwendung als eigenstaendiges
|
||||||
|
virtualenv unter /opt/oamc-turnier sowie eine systemd-Unit.
|
||||||
|
.
|
||||||
|
Nach der Installation ist /etc/oamc-turnier/oamc-turnier.env auszufuellen
|
||||||
|
(DATABASE_URL, SECRET_KEY) und der Dienst mit
|
||||||
|
"systemctl enable --now oamc-turnier" zu starten.
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||||
|
Upstream-Name: oamc-turnier
|
||||||
|
|
||||||
|
Files: *
|
||||||
|
Copyright: 2026 OAMC Reinheim e.V. im ADAC
|
||||||
|
License: proprietary
|
||||||
|
Interne Nutzung des OAMC Reinheim e.V. im ADAC. Weitergabe nur mit
|
||||||
|
ausdruecklicher Zustimmung des Vereinsvorstands.
|
||||||
Vendored
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# /etc/oamc-turnier/oamc-turnier.env — nach der Installation ausfuellen!
|
||||||
|
# Danach: systemctl enable --now oamc-turnier
|
||||||
|
|
||||||
|
# Pflicht:
|
||||||
|
OAMC_DATABASE_URL=postgresql+psycopg://oamc:CHANGE_ME@localhost:5432/oamc
|
||||||
|
OAMC_SECRET_KEY=CHANGE_ME_zufaellig_mind_32_zeichen
|
||||||
|
|
||||||
|
# Ingest-API-Keys (kommagetrennt), fuer die Excel-Auswertung:
|
||||||
|
OAMC_INGEST_API_KEYS=CHANGE_ME_ingest_key
|
||||||
|
|
||||||
|
# Betrieb:
|
||||||
|
OAMC_ENV=prod
|
||||||
|
OAMC_PUBLIC_BASE_URL=https://mt.oamc.de
|
||||||
|
OAMC_UPLOAD_DIR=/var/oamc/uploads
|
||||||
|
|
||||||
|
# Plausibilitaetsgrenzen Zeitmessung (Sekunden):
|
||||||
|
OAMC_MESS_MIN_SEKUNDEN=15
|
||||||
|
OAMC_MESS_MAX_SEKUNDEN=600
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
var/oamc/uploads
|
||||||
|
etc/oamc-turnier
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
debian/env/oamc-turnier.env etc/oamc-turnier
|
||||||
|
deploy/nginx/oamc-turnier.conf usr/share/doc/oamc-turnier/examples
|
||||||
|
deploy/backup/pg_dump.sh usr/share/doc/oamc-turnier/examples
|
||||||
|
docs/zeitmessung-endpoint.md usr/share/doc/oamc-turnier
|
||||||
Vendored
+28
@@ -0,0 +1,28 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OAMC Turnierauswertung (FastAPI/uvicorn)
|
||||||
|
After=network-online.target postgresql.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=exec
|
||||||
|
User=oamc
|
||||||
|
Group=oamc
|
||||||
|
EnvironmentFile=/etc/oamc-turnier/oamc-turnier.env
|
||||||
|
WorkingDirectory=/opt/oamc-turnier
|
||||||
|
# Migrationen vor dem Start (idempotent)
|
||||||
|
ExecStartPre=/opt/oamc-turnier/venv/bin/alembic -c /opt/oamc-turnier/alembic.ini upgrade head
|
||||||
|
ExecStart=/opt/oamc-turnier/venv/bin/uvicorn oamc.main:app \
|
||||||
|
--host 127.0.0.1 --port 8000 --proxy-headers --forwarded-allow-ips '*'
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
# Haertung
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/var/oamc
|
||||||
|
StateDirectory=oamc-turnier
|
||||||
|
RuntimeDirectory=oamc-turnier
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
configure)
|
||||||
|
# Systembenutzer fuer den Dienst
|
||||||
|
if ! getent passwd oamc >/dev/null; then
|
||||||
|
adduser --system --group --home /var/oamc --no-create-home \
|
||||||
|
--gecos "OAMC Turnierauswertung" oamc
|
||||||
|
fi
|
||||||
|
mkdir -p /var/oamc/uploads
|
||||||
|
chown -R oamc:oamc /var/oamc
|
||||||
|
|
||||||
|
# Konfig-Datei nur beim ersten Mal, mit engen Rechten
|
||||||
|
if [ ! -e /etc/oamc-turnier/oamc-turnier.env ]; then
|
||||||
|
echo "Hinweis: /etc/oamc-turnier/oamc-turnier.env anlegen (siehe Beispiel)."
|
||||||
|
fi
|
||||||
|
chown root:oamc /etc/oamc-turnier/oamc-turnier.env 2>/dev/null || true
|
||||||
|
chmod 0640 /etc/oamc-turnier/oamc-turnier.env 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "-----------------------------------------------------------------"
|
||||||
|
echo " oamc-turnier installiert. Naechste Schritte:"
|
||||||
|
echo " 1) PostgreSQL-DB + Rolle anlegen (siehe /usr/share/doc/oamc-turnier)"
|
||||||
|
echo " 2) /etc/oamc-turnier/oamc-turnier.env ausfuellen"
|
||||||
|
echo " 3) systemctl enable --now oamc-turnier"
|
||||||
|
echo " 4) /opt/oamc-turnier/venv/bin/oamc-turnier seed # Stammdaten 2026"
|
||||||
|
echo " 5) nginx: /usr/share/doc/oamc-turnier/examples/oamc-turnier.conf"
|
||||||
|
echo "-----------------------------------------------------------------"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
#DEBHELPER#
|
||||||
|
exit 0
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
purge)
|
||||||
|
rm -rf /opt/oamc-turnier/venv
|
||||||
|
rm -rf /var/oamc/uploads
|
||||||
|
# DB und /etc-Konfig bewusst NICHT loeschen (Datenverlust vermeiden)
|
||||||
|
echo "Hinweis: Datenbank und /etc/oamc-turnier bleiben erhalten."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
#DEBHELPER#
|
||||||
|
exit 0
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/make -f
|
||||||
|
# Baut ein in sich geschlossenes virtualenv unter /opt/oamc-turnier.
|
||||||
|
# Bewusst ohne dh-virtualenv (nicht in Debian 13 main) — venv + pip von Hand.
|
||||||
|
|
||||||
|
export DH_VERBOSE = 1
|
||||||
|
VENV = debian/oamc-turnier/opt/oamc-turnier/venv
|
||||||
|
APPDIR = debian/oamc-turnier/opt/oamc-turnier
|
||||||
|
|
||||||
|
%:
|
||||||
|
dh $@
|
||||||
|
|
||||||
|
override_dh_auto_build:
|
||||||
|
# nichts zu kompilieren
|
||||||
|
|
||||||
|
override_dh_auto_test:
|
||||||
|
# Tests laufen in CI gegen eine echte DB, nicht im Paketbau
|
||||||
|
|
||||||
|
override_dh_auto_install:
|
||||||
|
mkdir -p $(APPDIR)
|
||||||
|
python3 -m venv $(VENV)
|
||||||
|
$(VENV)/bin/pip install --no-cache-dir --upgrade pip
|
||||||
|
$(VENV)/bin/pip install --no-cache-dir .
|
||||||
|
# Migrationen + alembic.ini mitliefern (fuer "oamc-turnier migrate")
|
||||||
|
cp -r migrations $(APPDIR)/migrations
|
||||||
|
cp alembic.ini $(APPDIR)/alembic.ini
|
||||||
|
# venv relozierbar halten: absolute Shebangs auf den Zielpfad umschreiben
|
||||||
|
find $(VENV)/bin -type f -exec sed -i \
|
||||||
|
"1s|^#!.*python.*|#!/opt/oamc-turnier/venv/bin/python3|" {} + || true
|
||||||
|
# Wrapper in /usr/bin
|
||||||
|
mkdir -p debian/oamc-turnier/usr/bin
|
||||||
|
ln -sf /opt/oamc-turnier/venv/bin/oamc-turnier debian/oamc-turnier/usr/bin/oamc-turnier
|
||||||
|
|
||||||
|
override_dh_installsystemd:
|
||||||
|
dh_installsystemd --no-start --no-enable
|
||||||
|
|
||||||
|
override_dh_fixperms:
|
||||||
|
dh_fixperms
|
||||||
|
chmod 0640 debian/oamc-turnier/etc/oamc-turnier/oamc-turnier.env || true
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
3.0 (native)
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Taeglicher pg_dump der OAMC-Turnierdatenbank (Plan §5.3).
|
||||||
|
# Als Cron/Timer fuer den Benutzer 'postgres' oder 'oamc' einrichten:
|
||||||
|
# 15 2 * * * /usr/share/doc/oamc-turnier/examples/pg_dump.sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
DB="${OAMC_DB_NAME:-oamc}"
|
||||||
|
ZIEL="${OAMC_BACKUP_DIR:-/var/backups/oamc}"
|
||||||
|
BEHALTEN_TAGE="${OAMC_BACKUP_KEEP_DAYS:-30}"
|
||||||
|
STAMP="$(date +%Y-%m-%d_%H%M)"
|
||||||
|
|
||||||
|
mkdir -p "$ZIEL"
|
||||||
|
DATEI="$ZIEL/oamc_${STAMP}.sql.gz"
|
||||||
|
|
||||||
|
pg_dump --format=plain --no-owner --dbname="$DB" | gzip -9 > "$DATEI"
|
||||||
|
echo "Backup: $DATEI ($(du -h "$DATEI" | cut -f1))"
|
||||||
|
|
||||||
|
# Alte Backups aufraeumen
|
||||||
|
find "$ZIEL" -name 'oamc_*.sql.gz' -mtime "+${BEHALTEN_TAGE}" -delete
|
||||||
|
|
||||||
|
# WICHTIG (Plan §5.3): woechentlich eine Vollsicherung auf ein EXTERNES Medium
|
||||||
|
# ziehen und den Restore mindestens einmal testen:
|
||||||
|
# gunzip -c oamc_....sql.gz | psql -d oamc_restore_test
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# nginx vHost fuer die OAMC Turnierauswertung (bare-metal / Raspberry Pi).
|
||||||
|
# TLS via certbot: certbot --nginx -d mt.oamc.de
|
||||||
|
# Danach ergaenzt certbot die listen-443- und ssl_*-Zeilen automatisch.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name mt.oamc.de;
|
||||||
|
|
||||||
|
# certbot-Challenge
|
||||||
|
location /.well-known/acme-challenge/ { root /var/www/html; }
|
||||||
|
|
||||||
|
# Rest auf HTTPS umleiten (nach dem ersten certbot-Lauf aktivieren):
|
||||||
|
# return 301 https://$host$request_uri;
|
||||||
|
|
||||||
|
client_max_body_size 25m;
|
||||||
|
|
||||||
|
location = /favicon.ico { access_log off; log_not_found off; }
|
||||||
|
|
||||||
|
location /api/v1/turniere/aktiv/anzeige {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Manuelle Installation ohne .deb (git-Checkout + venv unter /opt/oamc-turnier/app).
|
||||||
|
# sudo cp deploy/systemd/oamc-turnier.service /etc/systemd/system/
|
||||||
|
# sudo systemctl daemon-reload && sudo systemctl enable --now oamc-turnier
|
||||||
|
[Unit]
|
||||||
|
Description=OAMC Turnierauswertung (FastAPI/uvicorn)
|
||||||
|
After=network-online.target postgresql.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=exec
|
||||||
|
User=oamc
|
||||||
|
Group=oamc
|
||||||
|
EnvironmentFile=/etc/oamc-turnier/oamc-turnier.env
|
||||||
|
WorkingDirectory=/opt/oamc-turnier/app
|
||||||
|
ExecStartPre=/opt/oamc-turnier/app/.venv/bin/alembic upgrade head
|
||||||
|
ExecStart=/opt/oamc-turnier/app/.venv/bin/uvicorn oamc.main:app \
|
||||||
|
--host 127.0.0.1 --port 8000 --proxy-headers --forwarded-allow-ips '*'
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/var/oamc
|
||||||
|
StateDirectory=oamc-turnier
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# OAMC Turnierauswertung — lokaler/kleiner Produktivbetrieb per Container.
|
||||||
|
# docker compose up -d (Build + Start)
|
||||||
|
# docker compose exec app oamc-turnier seed
|
||||||
|
#
|
||||||
|
# Fuer "docker repo": das app-Image separat bauen und pushen:
|
||||||
|
# docker build -t <registry>/oamc-turnier:0.1.0 -f docker/Dockerfile .
|
||||||
|
# docker push <registry>/oamc-turnier:0.1.0
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: oamc
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-oamc}
|
||||||
|
POSTGRES_DB: oamc
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U oamc"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
image: oamc-turnier:${OAMC_VERSION:-0.1.0}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
OAMC_DATABASE_URL: postgresql+psycopg://oamc:${POSTGRES_PASSWORD:-oamc}@db:5432/oamc
|
||||||
|
OAMC_SECRET_KEY: ${OAMC_SECRET_KEY:?bitte in .env setzen}
|
||||||
|
OAMC_INGEST_API_KEYS: ${OAMC_INGEST_API_KEYS:-dev-ingest-key}
|
||||||
|
OAMC_ENV: ${OAMC_ENV:-prod}
|
||||||
|
OAMC_PUBLIC_BASE_URL: ${OAMC_PUBLIC_BASE_URL:-http://localhost}
|
||||||
|
OAMC_SEED_ON_START: ${OAMC_SEED_ON_START:-0}
|
||||||
|
volumes:
|
||||||
|
- uploads:/var/oamc/uploads
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:1.27-alpine
|
||||||
|
depends_on: [app]
|
||||||
|
ports:
|
||||||
|
- "${OAMC_HTTP_PORT:-8080}:80"
|
||||||
|
volumes:
|
||||||
|
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
uploads:
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# OAMC Turnierauswertung — Anwendungs-Image
|
||||||
|
# Multi-Arch: baut fuer linux/arm64 (Raspberry Pi 4) und linux/amd64.
|
||||||
|
# docker buildx build --platform linux/arm64,linux/amd64 -f docker/Dockerfile .
|
||||||
|
|
||||||
|
FROM python:3.12-slim AS build
|
||||||
|
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_NO_CACHE_DIR=1
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# WeasyPrint (PDF-Export) braucht native Bibliotheken; Build-Tools nur hier.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY pyproject.toml README.md ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN python -m venv /opt/venv \
|
||||||
|
&& /opt/venv/bin/pip install --upgrade pip \
|
||||||
|
&& /opt/venv/bin/pip install .
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM python:3.12-slim AS runtime
|
||||||
|
ENV PYTHONUNBUFFERED=1 PATH="/opt/venv/bin:$PATH" \
|
||||||
|
OAMC_UPLOAD_DIR=/var/oamc/uploads
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq5 libpango-1.0-0 libpangoft2-1.0-0 libcairo2 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& useradd --system --create-home --uid 10001 oamc \
|
||||||
|
&& mkdir -p /var/oamc/uploads && chown -R oamc:oamc /var/oamc
|
||||||
|
|
||||||
|
COPY --from=build /opt/venv /opt/venv
|
||||||
|
COPY src ./src
|
||||||
|
COPY migrations ./migrations
|
||||||
|
COPY alembic.ini ./
|
||||||
|
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
USER oamc
|
||||||
|
EXPOSE 8000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||||
|
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz').status==200 else 1)"
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
CMD ["uvicorn", "oamc.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Wartet auf die DB, spielt Migrationen ein, optional Seed, startet dann das CMD.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
echo "[entrypoint] warte auf Datenbank ..."
|
||||||
|
python - <<'PY'
|
||||||
|
import os, time, sys
|
||||||
|
import psycopg
|
||||||
|
url = os.environ.get("OAMC_DATABASE_URL", "")
|
||||||
|
dsn = url.replace("postgresql+psycopg://", "postgresql://", 1)
|
||||||
|
for i in range(60):
|
||||||
|
try:
|
||||||
|
psycopg.connect(dsn, connect_timeout=3).close()
|
||||||
|
print("[entrypoint] DB erreichbar")
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as e: # noqa
|
||||||
|
print(f"[entrypoint] ({i+1}/60) noch nicht bereit: {e}")
|
||||||
|
time.sleep(2)
|
||||||
|
sys.exit("[entrypoint] DB nicht erreichbar")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "[entrypoint] alembic upgrade head"
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
if [ "${OAMC_SEED_ON_START:-0}" = "1" ]; then
|
||||||
|
echo "[entrypoint] Seed Saison 2026 (idempotent)"
|
||||||
|
oamc-turnier seed || echo "[entrypoint] Seed uebersprungen/fehlgeschlagen"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# nginx vor uvicorn: TLS-Terminierung (im Compose ohne TLS), statische Dateien,
|
||||||
|
# SSE-taugliches Proxying (kein Buffering, lange Timeouts).
|
||||||
|
|
||||||
|
upstream oamc_app { server app:8000; }
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
client_max_body_size 25m;
|
||||||
|
charset utf-8;
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
proxy_pass http://oamc_app;
|
||||||
|
proxy_cache_valid 200 10m;
|
||||||
|
expires 1h;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Server-Sent Events: Puffern aus, Verbindung offen halten
|
||||||
|
location /api/v1/turniere/aktiv/anzeige {
|
||||||
|
proxy_pass http://oamc_app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://oamc_app;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Endpoint für die Zeitmessung (Lichtschranke)
|
||||||
|
|
||||||
|
> Der **Mess-Pi-Client selbst ist nicht Teil dieses Projekts** — dieses Dokument
|
||||||
|
> beschreibt nur den **Endpoint**, den ein beliebiger Client (der zweite Raspberry
|
||||||
|
> Pi an der Lichtschranke) bespielt. Fachlicher Hintergrund: `plan.md` §3.6 / §6.2.
|
||||||
|
|
||||||
|
## Authentifizierung
|
||||||
|
|
||||||
|
Alle Geräte-Endpunkte erwarten einen **Geräte-Token** (nicht den Auswerter-Login):
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <geraete-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Token anlegen (interne Admin-Session):
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/admin/geraete
|
||||||
|
{ "bezeichnung": "lichtschranke-ziel-01", "typ": "lichtschranke" }
|
||||||
|
→ 201 { "id": 1, "bezeichnung": "...", "token": "oamc_dev_…" } # Token nur hier einmalig
|
||||||
|
```
|
||||||
|
|
||||||
|
Token neu ausgeben: `POST /api/v1/admin/geraete/{id}/token` ·
|
||||||
|
Gerät sperren: `POST /api/v1/admin/geraete/{id}/sperren`
|
||||||
|
|
||||||
|
## Ablauf auf dem Client (empfohlen)
|
||||||
|
|
||||||
|
1. `POST /api/v1/geraete/heartbeat` alle ~15 s → Statusanzeige + `aktives_turnier_id`
|
||||||
|
2. `GET /api/v1/geraete/konfiguration` → Entprellzeit, Messpunkte, Plausibilitätsgrenzen
|
||||||
|
3. `GET /api/v1/turniere/aktiv/startliste` (ETag-fähig) → Startreihenfolge für die Anzeige
|
||||||
|
4. Pro Durchfahrt eine Messung lokal puffern, dann als **Batch** senden:
|
||||||
|
`POST /api/v1/zeitmessungen`
|
||||||
|
5. `GET /api/v1/turniere/aktiv/anzeige` (SSE) → Live-Stream für die Anzeige am Parcours
|
||||||
|
|
||||||
|
## `POST /api/v1/zeitmessungen` — Batch, idempotent
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"geraet": "lichtschranke-ziel-01", // informativ
|
||||||
|
"messungen": [
|
||||||
|
{
|
||||||
|
"id": "9f2c1e7a-4d38-4b6e-9a11-5c0b2d7e8f30", // UUID VOM GERÄT → Idempotenz
|
||||||
|
"turnier_id": 42,
|
||||||
|
"startnummer_gemeldet": 32, // Hinweis, kein Fremdschlüssel
|
||||||
|
"messpunkt": "durchgang-1",
|
||||||
|
"dauer_sekunden": 159.16, // vom Gerät berechnet (monotone Zeit)
|
||||||
|
"gemessen_am": "2026-05-31T10:14:02.482+02:00",
|
||||||
|
"monotonic_ns": 884213771004,
|
||||||
|
"roh": { "trigger_start_ns": 884054611004, "trigger_ziel_ns": 884213771004 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Antwort — jede Messung wird **einzeln** quittiert
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"quittungen": [
|
||||||
|
{ "id": "9f2c1e7a-…", "status": "angenommen", "start_id": 512, "hinweis": null }
|
||||||
|
],
|
||||||
|
"angenommen": 1, "duplikate": 0, "unzugeordnet": 0, "unplausibel": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| `status` | Bedeutung | Client-Reaktion |
|
||||||
|
|----------------|-----------|-----------------|
|
||||||
|
| `angenommen` | gespeichert und einem Start zugeordnet | aus lokaler Queue löschen |
|
||||||
|
| `duplikat` | UUID war schon da (Nachlieferung nach Timeout) | aus lokaler Queue löschen |
|
||||||
|
| `unzugeordnet` | gespeichert, aber `startnummer_gemeldet` nicht auflösbar → manuelle Zuordnung im Frontend | aus lokaler Queue löschen |
|
||||||
|
| `unplausibel` | gespeichert, Dauer außerhalb der Grenzen → im Frontend markiert | aus lokaler Queue löschen |
|
||||||
|
| `fehler` | z. B. `turnier_id` unbekannt | **in der Queue behalten**, später erneut |
|
||||||
|
|
||||||
|
**Grundregeln** (Server-seitig umgesetzt):
|
||||||
|
|
||||||
|
* **Idempotent über `id`** — ein doppelt gesendeter Batch erzeugt keine Dubletten.
|
||||||
|
Ein abgebrochener Request darf gefahrlos wiederholt werden.
|
||||||
|
* Die **Dauer kommt fertig vom Gerät** — der Server rechnet **keine** Differenz aus
|
||||||
|
zwei Wanduhr-Zeitstempeln (NTP-Sprung auf einem Pi ohne RTC).
|
||||||
|
* Eine Messung **ohne** zuordenbare Startnummer wird **nie verworfen**, sondern als
|
||||||
|
`unzugeordnet` gespeichert und im internen Frontend zur Zuordnung angeboten.
|
||||||
|
* Plausibilitätsverstöße werden **markiert**, nicht abgelehnt. Grenzen:
|
||||||
|
`OAMC_MESS_MIN_SEKUNDEN` / `OAMC_MESS_MAX_SEKUNDEN` bzw. je Gerät in
|
||||||
|
`geraet.konfiguration`.
|
||||||
|
* `gemessen_am` weit in der Vergangenheit → die Messung wird als *nachgeliefert*
|
||||||
|
markiert und trotzdem angenommen.
|
||||||
|
|
||||||
|
## Fehlmessung nachträglich verwerfen
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/zeitmessungen/{id}/verwerfen
|
||||||
|
{ "grund": "Zuschauer hat die Schranke ausgelöst" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Messung bleibt mit Grund gespeichert (Status `verworfen`), zählt aber nicht in
|
||||||
|
die `summe_zeit`.
|
||||||
|
|
||||||
|
## Was der Server aus den Messungen macht
|
||||||
|
|
||||||
|
`ergebnis.summe_zeit` wird bei der Auswertung als **Summe aller gültigen,
|
||||||
|
zugeordneten Einzelmessungen** eines Starts gebildet
|
||||||
|
(`oamc.wertung.service.summe_zeit_fuer_start`). Aus Einzelmessungen lässt sich die
|
||||||
|
Summe jederzeit bilden — der umgekehrte Weg existiert nicht, deshalb speichert der
|
||||||
|
Server jede Einzelmessung.
|
||||||
|
|
||||||
|
## Offene Punkte (aus `plan.md` §10), die den Endpoint noch betreffen
|
||||||
|
|
||||||
|
* **O-13** Was misst die Lichtschranke genau (eine Gesamtdurchfahrt, mehrere
|
||||||
|
Durchgänge, einzelne Abschnitte)? → bestimmt `messpunkt` und die Summenbildung.
|
||||||
|
* **O-14** Woher kennt der Client die Startnummer? → bis geklärt: `startnummer_gemeldet`
|
||||||
|
als Hinweis, Server löst gegen die Startliste auf.
|
||||||
|
* **O-16** Geforderte Messgenauigkeit / eine oder zwei Lichtschranken.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Alembic-Umgebung. DB-URL und Metadaten kommen aus der Anwendung."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from oamc.config import get_settings
|
||||||
|
from oamc.models import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# Von der Autogenerate-Vergleichung ausgenommen: der Trigramm-Index wird als
|
||||||
|
# Ausdrucksindex per Roh-SQL in der Initialmigration gepflegt.
|
||||||
|
_IGNORE_INDEXES = {"ix_fahrer_name_trgm"}
|
||||||
|
|
||||||
|
|
||||||
|
def include_object(obj, name, type_, reflected, compare_to): # noqa: ANN001
|
||||||
|
if type_ == "index" and name in _IGNORE_INDEXES:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=get_settings().database_url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
compare_type=True,
|
||||||
|
compare_server_default=True,
|
||||||
|
include_object=include_object,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=True,
|
||||||
|
compare_server_default=True,
|
||||||
|
include_object=include_object,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: str | None = ${repr(down_revision)}
|
||||||
|
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||||
|
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""Initiales Schema
|
||||||
|
|
||||||
|
Revision ID: df5e2dd5a864
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-09-05 18:08:45.999851+02:00
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = 'df5e2dd5a864'
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# pg_trgm fuer die unscharfe Fahrersuche beim Import (Plan §6.4)
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||||||
|
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('benutzer',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('benutzername', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('anzeigename', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('passwort_hash', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('rolle', sa.Enum('admin', 'auswerter', 'lesend', name='benutzer_rolle'), nullable=False),
|
||||||
|
sa.Column('aktiv', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('letzter_login_am', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('erstellt_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('geaendert_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_benutzer')),
|
||||||
|
sa.UniqueConstraint('benutzername', name=op.f('uq_benutzer_benutzername'))
|
||||||
|
)
|
||||||
|
op.create_table('geraet',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('bezeichnung', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('typ', sa.Enum('lichtschranke', 'anzeige', name='geraet_typ'), nullable=False),
|
||||||
|
sa.Column('token_hash', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('aktiv', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('letzte_meldung_am', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('firmware_version', sa.String(length=60), nullable=True),
|
||||||
|
sa.Column('queue_laenge_gemeldet', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('notiz', sa.Text(), nullable=True),
|
||||||
|
sa.Column('konfiguration', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column('erstellt_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('geaendert_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_geraet')),
|
||||||
|
sa.UniqueConstraint('bezeichnung', name=op.f('uq_geraet_bezeichnung'))
|
||||||
|
)
|
||||||
|
op.create_table('import_job',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('quelle', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('idempotenz_schluessel', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('dateiname', sa.String(length=300), nullable=True),
|
||||||
|
sa.Column('roh_payload', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column('ergebnis_bericht', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column('status', sa.Enum('angenommen', 'vorschau', 'fehler', 'teilweise', name='import_status'), nullable=False),
|
||||||
|
sa.Column('fehler', sa.Text(), nullable=True),
|
||||||
|
sa.Column('angelegt_von', sa.String(length=120), nullable=True),
|
||||||
|
sa.Column('angelegt_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_import_job'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_import_job_idempotenz_schluessel'), 'import_job', ['idempotenz_schluessel'], unique=False)
|
||||||
|
op.create_table('saison',
|
||||||
|
sa.Column('jahr', sa.Integer(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('turnierordnung_version', sa.String(length=60), nullable=True),
|
||||||
|
sa.Column('streichresultate_tabelle', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column('nenngeld_cent', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('aktiv', sa.Boolean(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('jahr', name=op.f('pk_saison'))
|
||||||
|
)
|
||||||
|
op.create_table('verein',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('kurzname', sa.String(length=60), nullable=True),
|
||||||
|
sa.Column('adac_gau', sa.String(length=120), nullable=True),
|
||||||
|
sa.Column('aktiv', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('notiz', sa.Text(), nullable=True),
|
||||||
|
sa.Column('erstellt_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('geaendert_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_verein')),
|
||||||
|
sa.UniqueConstraint('name', name=op.f('uq_verein_name'))
|
||||||
|
)
|
||||||
|
op.create_table('fahrer',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('nachname', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('vorname', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('geburtsjahr', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('geburtsdatum', sa.Date(), nullable=True),
|
||||||
|
sa.Column('geschlecht', sa.Enum('M', 'W', 'D', name='geschlecht'), nullable=True),
|
||||||
|
sa.Column('verein_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('adac_mitgliedsnr', sa.String(length=40), nullable=True),
|
||||||
|
sa.Column('jugendausweis_gueltig_bis', sa.Date(), nullable=True),
|
||||||
|
sa.Column('anzeige_anonymisiert', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('aktiv', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('notiz', sa.Text(), nullable=True),
|
||||||
|
sa.Column('erstellt_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('geaendert_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['verein_id'], ['verein.id'], name=op.f('fk_fahrer_verein_id_verein')),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_fahrer'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_fahrer_geburtsjahr'), 'fahrer', ['geburtsjahr'], unique=False)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX ix_fahrer_name_trgm ON fahrer USING gin "
|
||||||
|
"((lower(nachname || ' ' || vorname)) gin_trgm_ops)"
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_fahrer_verein_id'), 'fahrer', ['verein_id'], unique=False)
|
||||||
|
op.create_table('klasse',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('saison_jahr', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=10), nullable=False),
|
||||||
|
sa.Column('kategorie', sa.Enum('J', 'S', 'A', name='kategorie'), nullable=False),
|
||||||
|
sa.Column('bezeichnung', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('jahrgaenge', postgresql.ARRAY(sa.Integer()), nullable=True),
|
||||||
|
sa.Column('ccm_max', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('kw_max', sa.Numeric(precision=5, scale=2), nullable=True),
|
||||||
|
sa.Column('fahrzeugtyp', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('mindestalter_am_veranstaltungstag', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('sortierung', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('notiz', sa.Text(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['saison_jahr'], ['saison.jahr'], name=op.f('fk_klasse_saison_jahr_saison'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_klasse')),
|
||||||
|
sa.UniqueConstraint('saison_jahr', 'code', name='klasse_saison_code')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_klasse_saison_jahr'), 'klasse', ['saison_jahr'], unique=False)
|
||||||
|
op.create_table('veranstaltung',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('datum', sa.Date(), nullable=False),
|
||||||
|
sa.Column('titel', sa.String(length=300), nullable=False),
|
||||||
|
sa.Column('ort', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('ausrichter_verein_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('ist_doppelturnier', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('veranstaltungsleiter', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('turnierleiter', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('status', sa.Enum('geplant', 'laufend', 'vorlaeufig', 'final', name='veranstaltung_status'), nullable=False),
|
||||||
|
sa.Column('notiz', sa.Text(), nullable=True),
|
||||||
|
sa.Column('erstellt_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('geaendert_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['ausrichter_verein_id'], ['verein.id'], name=op.f('fk_veranstaltung_ausrichter_verein_id_verein')),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_veranstaltung'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_veranstaltung_datum'), 'veranstaltung', ['datum'], unique=False)
|
||||||
|
op.create_table('meisterschaft',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('saison_jahr', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('bezeichnung', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('kategorie', sa.Enum('J', 'S', 'A', name='meisterschaft_kategorie'), nullable=False),
|
||||||
|
sa.Column('klasse_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('regelwerk', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['klasse_id'], ['klasse.id'], name=op.f('fk_meisterschaft_klasse_id_klasse')),
|
||||||
|
sa.ForeignKeyConstraint(['saison_jahr'], ['saison.jahr'], name=op.f('fk_meisterschaft_saison_jahr_saison')),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_meisterschaft'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_meisterschaft_saison_jahr'), 'meisterschaft', ['saison_jahr'], unique=False)
|
||||||
|
op.create_table('turnier',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('veranstaltung_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('veranstalter_verein_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('zaehlt_meisterschaft', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('ist_aktiv', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('geplant', 'laufend', 'vorlaeufig', 'final', name='turnier_status'), nullable=False),
|
||||||
|
sa.Column('erstellt_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('geaendert_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['veranstalter_verein_id'], ['verein.id'], name=op.f('fk_turnier_veranstalter_verein_id_verein')),
|
||||||
|
sa.ForeignKeyConstraint(['veranstaltung_id'], ['veranstaltung.id'], name=op.f('fk_turnier_veranstaltung_id_veranstaltung'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_turnier'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_turnier_veranstaltung_id'), 'turnier', ['veranstaltung_id'], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
'uq_turnier_ist_aktiv', 'turnier', ['ist_aktiv'], unique=True,
|
||||||
|
postgresql_where=sa.text('ist_aktiv'),
|
||||||
|
)
|
||||||
|
op.create_table('meisterschaft_stand',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('meisterschaft_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('fahrer_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('punkte_gesamt', sa.Numeric(precision=9, scale=2), nullable=False),
|
||||||
|
sa.Column('punkte_je_lauf', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column('gestrichene_laeufe', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column('anzahl_siege', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('platz', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('berechnet_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['fahrer_id'], ['fahrer.id'], name=op.f('fk_meisterschaft_stand_fahrer_id_fahrer')),
|
||||||
|
sa.ForeignKeyConstraint(['meisterschaft_id'], ['meisterschaft.id'], name=op.f('fk_meisterschaft_stand_meisterschaft_id_meisterschaft'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_meisterschaft_stand'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_meisterschaft_stand_fahrer_id'), 'meisterschaft_stand', ['fahrer_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_meisterschaft_stand_meisterschaft_id'), 'meisterschaft_stand', ['meisterschaft_id'], unique=False)
|
||||||
|
op.create_table('wertungsklasse',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('turnier_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('bezeichnung', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('klasse_ids', postgresql.ARRAY(sa.Integer()), nullable=False),
|
||||||
|
sa.Column('sortierung', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['turnier_id'], ['turnier.id'], name=op.f('fk_wertungsklasse_turnier_id_turnier'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_wertungsklasse'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_wertungsklasse_turnier_id'), 'wertungsklasse', ['turnier_id'], unique=False)
|
||||||
|
op.create_table('start',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('turnier_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('fahrer_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('klasse_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('wertungsklasse_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('startnummer', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('fahrzeug', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('nenngeld_bezahlt', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('genannt', 'gestartet', 'DNF', 'DNS', 'DSQ', name='start_status'), nullable=False),
|
||||||
|
sa.Column('nennreihenfolge', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['fahrer_id'], ['fahrer.id'], name=op.f('fk_start_fahrer_id_fahrer')),
|
||||||
|
sa.ForeignKeyConstraint(['klasse_id'], ['klasse.id'], name=op.f('fk_start_klasse_id_klasse')),
|
||||||
|
sa.ForeignKeyConstraint(['turnier_id'], ['turnier.id'], name=op.f('fk_start_turnier_id_turnier'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['wertungsklasse_id'], ['wertungsklasse.id'], name=op.f('fk_start_wertungsklasse_id_wertungsklasse'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_start')),
|
||||||
|
sa.UniqueConstraint('turnier_id', 'startnummer', name='start_turnier_startnummer')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_start_fahrer_id'), 'start', ['fahrer_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_start_turnier_id'), 'start', ['turnier_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_start_wertungsklasse_id'), 'start', ['wertungsklasse_id'], unique=False)
|
||||||
|
op.create_table('ergebnis',
|
||||||
|
sa.Column('start_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('summe_zeit', sa.Numeric(precision=7, scale=2), nullable=True),
|
||||||
|
sa.Column('summe_fehler', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('gesamt', sa.Numeric(precision=9, scale=2), nullable=True),
|
||||||
|
sa.Column('platzierung', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('zeit_vollstaendig', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('fehler_vollstaendig', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('erfasst_von', sa.String(length=120), nullable=True),
|
||||||
|
sa.Column('erfasst_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('korrigiert_am', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('korrektur_grund', sa.Text(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['start_id'], ['start.id'], name=op.f('fk_ergebnis_start_id_start'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('start_id', name=op.f('pk_ergebnis'))
|
||||||
|
)
|
||||||
|
op.create_table('zeitmessung',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('geraet_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('turnier_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('start_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('startnummer_gemeldet', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('messpunkt', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('dauer_sekunden', sa.Numeric(precision=7, scale=2), nullable=False),
|
||||||
|
sa.Column('gemessen_am', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('monotonic_ns', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('empfangen_am', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('nachgeliefert', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('gueltig', 'verworfen', 'unzugeordnet', 'unplausibel', name='zeitmessung_status'), nullable=False),
|
||||||
|
sa.Column('verworfen_grund', sa.Text(), nullable=True),
|
||||||
|
sa.Column('roh', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['geraet_id'], ['geraet.id'], name=op.f('fk_zeitmessung_geraet_id_geraet')),
|
||||||
|
sa.ForeignKeyConstraint(['start_id'], ['start.id'], name=op.f('fk_zeitmessung_start_id_start'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['turnier_id'], ['turnier.id'], name=op.f('fk_zeitmessung_turnier_id_turnier'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_zeitmessung'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_zeitmessung_start', 'zeitmessung', ['start_id'], unique=False)
|
||||||
|
op.create_index('ix_zeitmessung_turnier_status', 'zeitmessung', ['turnier_id', 'status'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('ix_zeitmessung_turnier_status', table_name='zeitmessung')
|
||||||
|
op.drop_index('ix_zeitmessung_start', table_name='zeitmessung')
|
||||||
|
op.drop_table('zeitmessung')
|
||||||
|
op.drop_table('ergebnis')
|
||||||
|
op.drop_index(op.f('ix_start_wertungsklasse_id'), table_name='start')
|
||||||
|
op.drop_index(op.f('ix_start_turnier_id'), table_name='start')
|
||||||
|
op.drop_index(op.f('ix_start_fahrer_id'), table_name='start')
|
||||||
|
op.drop_table('start')
|
||||||
|
op.drop_index(op.f('ix_wertungsklasse_turnier_id'), table_name='wertungsklasse')
|
||||||
|
op.drop_table('wertungsklasse')
|
||||||
|
op.drop_index(op.f('ix_meisterschaft_stand_meisterschaft_id'), table_name='meisterschaft_stand')
|
||||||
|
op.drop_index(op.f('ix_meisterschaft_stand_fahrer_id'), table_name='meisterschaft_stand')
|
||||||
|
op.drop_table('meisterschaft_stand')
|
||||||
|
op.drop_index('uq_turnier_ist_aktiv', table_name='turnier')
|
||||||
|
op.drop_index(op.f('ix_turnier_veranstaltung_id'), table_name='turnier')
|
||||||
|
op.drop_table('turnier')
|
||||||
|
op.drop_index(op.f('ix_meisterschaft_saison_jahr'), table_name='meisterschaft')
|
||||||
|
op.drop_table('meisterschaft')
|
||||||
|
op.drop_index(op.f('ix_veranstaltung_datum'), table_name='veranstaltung')
|
||||||
|
op.drop_table('veranstaltung')
|
||||||
|
op.drop_index(op.f('ix_klasse_saison_jahr'), table_name='klasse')
|
||||||
|
op.drop_table('klasse')
|
||||||
|
op.drop_index(op.f('ix_fahrer_verein_id'), table_name='fahrer')
|
||||||
|
op.drop_index('ix_fahrer_name_trgm', table_name='fahrer', postgresql_using='gin')
|
||||||
|
op.drop_index(op.f('ix_fahrer_geburtsjahr'), table_name='fahrer')
|
||||||
|
op.drop_table('fahrer')
|
||||||
|
op.drop_table('verein')
|
||||||
|
op.drop_table('saison')
|
||||||
|
op.drop_index(op.f('ix_import_job_idempotenz_schluessel'), table_name='import_job')
|
||||||
|
op.drop_table('import_job')
|
||||||
|
op.drop_table('geraet')
|
||||||
|
op.drop_table('benutzer')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# APT-Repository für `oamc-turnier`
|
||||||
|
|
||||||
|
Kleines, signiertes Ein-Paket-Repo (für den Vereins-Pi / eigenen Server). Nutzt
|
||||||
|
`reprepro`.
|
||||||
|
|
||||||
|
## Einmalig: Repo anlegen
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo apt install reprepro devscripts build-essential debhelper
|
||||||
|
export GNUPGHOME=~/.gnupg
|
||||||
|
gpg --full-generate-key # Repo-Signierschlüssel, z. B. "OAMC APT <apt@oamc.de>"
|
||||||
|
gpg --armor --export apt@oamc.de > packaging/apt-repo/oamc-archive-keyring.asc
|
||||||
|
|
||||||
|
mkdir -p /srv/apt/oamc/conf
|
||||||
|
cp packaging/apt-repo/distributions /srv/apt/oamc/conf/distributions # SignWith anpassen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paket bauen und einspielen
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./packaging/apt-repo/build-deb.sh # erzeugt ../oamc-turnier_*.deb
|
||||||
|
reprepro -b /srv/apt/oamc includedeb trixie ../oamc-turnier_*.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
`/srv/apt/oamc` per nginx/Apache unter z. B. `https://apt.oamc.de` ausliefern.
|
||||||
|
|
||||||
|
## Auf dem Zielrechner
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fsSL https://apt.oamc.de/oamc-archive-keyring.asc \
|
||||||
|
| sudo tee /etc/apt/keyrings/oamc.asc >/dev/null
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/oamc.asc] https://apt.oamc.de trixie main" \
|
||||||
|
| sudo tee /etc/apt/sources.list.d/oamc.list
|
||||||
|
sudo apt update && sudo apt install oamc-turnier
|
||||||
|
```
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Baut das .deb aus dem Projektwurzelverzeichnis.
|
||||||
|
# Voraussetzungen: dpkg-dev, debhelper (>= 13), python3-venv, Internet (pip).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
WURZEL="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
cd "$WURZEL"
|
||||||
|
|
||||||
|
command -v dpkg-buildpackage >/dev/null || {
|
||||||
|
echo "dpkg-dev / debhelper fehlen: sudo apt install build-essential debhelper devscripts"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# -us -uc: unsigniert (Signatur macht reprepro beim includedeb)
|
||||||
|
dpkg-buildpackage -b -us -uc
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Fertig. Artefakte:"
|
||||||
|
ls -1 ../oamc-turnier_*.deb 2>/dev/null || true
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Origin: OAMC Reinheim
|
||||||
|
Label: OAMC Turnierauswertung
|
||||||
|
Suite: stable
|
||||||
|
Codename: trixie
|
||||||
|
Architectures: arm64 amd64 source
|
||||||
|
Components: main
|
||||||
|
Description: OAMC Turnierauswertung - internes APT-Repo
|
||||||
|
SignWith: apt@oamc.de
|
||||||
@@ -0,0 +1,848 @@
|
|||||||
|
# Plan: OAMC Turnierauswertung — Webanwendung
|
||||||
|
|
||||||
|
**Stand:** 2026-09-05 · **Status:** Entwurf, noch nicht freigegeben
|
||||||
|
**Änderung 05.09.:** Zeitmessung per Lichtschranke aufgenommen (§3.6, §5.4, §6.2, AP 5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ausgangslage
|
||||||
|
|
||||||
|
### 1.1 Der Verein und die Sportart
|
||||||
|
|
||||||
|
Der **OAMC Reinheim e.V. im ADAC** (Odenwälder Automobil- und Motorsportclub) richtet
|
||||||
|
**ADAC Motorrad-Turniere** aus. Diese laufen nach der **ADAC-Turnierordnung** im Bereich
|
||||||
|
des **ADAC Hessen-Thüringen (HTH)**.
|
||||||
|
|
||||||
|
Motorrad-Turniersport ist kein Rennsport, sondern ein Geschicklichkeitswettbewerb: Die
|
||||||
|
Fahrer durchfahren einen Parcours mit festen Aufgaben, dabei zählen **Fehlerpunkte** und
|
||||||
|
**Fahrzeit**.
|
||||||
|
|
||||||
|
Aufgaben im Parcours (laut Sportbeschreibung auf oamc.de):
|
||||||
|
|
||||||
|
Fahrzeugabnahme · Schätzen (im Stand und in Fahrt) · Slalom · Acht-Figur · Spurgasse ·
|
||||||
|
Kreisel · Tor · Schrägbrett · Wippe (mit und ohne Spurbrett) · Gummiringe ·
|
||||||
|
Dose umsetzen · Fahrgasse · Stop Haltelinie · Langsamfahrstrecke
|
||||||
|
|
||||||
|
### 1.2 Aktueller Ist-Zustand der Website
|
||||||
|
|
||||||
|
Die bestehende Seite **oamc.de** (Zweitdomain: oamc-reinheim.de) ist eine statische
|
||||||
|
**HTML-4.01-Frameset-Website**:
|
||||||
|
|
||||||
|
- Aufbau: `index` → `oben.htm` (Navigation) + `hauptfenster.htm`, jede Rubrik wieder
|
||||||
|
ein eigenes Frameset (`m/frame-m.htm` → `m1.htm` Menü + `m2.htm` Inhalt)
|
||||||
|
- Rubriken: Verein · VÜP ADAC-Platz · Kartgruppe · **Motorradgruppe** · Fahrrad JVS ·
|
||||||
|
Anfahrt · Download · Weblinks · Flohmarkt · BikerSafetyDay
|
||||||
|
- **Alle Ergebnisse liegen ausschließlich als PDF** unter `/datei/` und werden auf
|
||||||
|
`d/d-mot-turnier.htm` verlinkt — Tagesergebnisse, Meisterschaftsstände (HTH 2002–2026),
|
||||||
|
Bundesturnier-Ergebnisse, Protokolle
|
||||||
|
- Die Seite **`m/m-fahrer.htm` ist eine „BAUSTELLE"** — es gibt aktuell **keine
|
||||||
|
Fahrerdatenbank**. Genau das ist die Lücke, die dieses Projekt schließt.
|
||||||
|
- `m-ergebnisse.htm` und `m-ausschreibung.htm` verweisen nur auf die PDF-Downloadseite
|
||||||
|
|
||||||
|
### 1.3 Wie heute ausgewertet wird
|
||||||
|
|
||||||
|
Die Rahmenausschreibung 2026 nennt als Anforderung an den Veranstalter:
|
||||||
|
> „Auswertungscomputer mit MSOffice (**Excel**) und Drucker sollte zeitgemäß vorhanden sein."
|
||||||
|
|
||||||
|
Es existieren Vorlagen als `.xlsx` (z. B. `MT-NENNLISTE-5-2025.xlsx`). Die Auswertung
|
||||||
|
läuft also heute **in Excel**, das Ergebnis wird als PDF exportiert und manuell auf die
|
||||||
|
Website gelegt. Meisterschaftsstände werden separat fortgeschrieben.
|
||||||
|
|
||||||
|
**Schmerzpunkte:**
|
||||||
|
|
||||||
|
| Problem | Auswirkung |
|
||||||
|
|---|---|
|
||||||
|
| Ergebnisse nur als PDF | nicht durchsuchbar, nicht verlinkbar, nicht mobil lesbar |
|
||||||
|
| Meisterschaftsstand manuell | fehleranfällig, spät verfügbar |
|
||||||
|
| Keine Fahrerhistorie | „Wie oft war X schon dabei?" nicht beantwortbar |
|
||||||
|
| Fahrerstammdaten pro Turnier neu erfasst | Tippfehler, Dubletten, Namensvarianten |
|
||||||
|
| Frameset-Website | kein Deep-Link, schlechtes SEO, unbrauchbar auf dem Handy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Ziel
|
||||||
|
|
||||||
|
Eine Webanwendung, die
|
||||||
|
|
||||||
|
1. **Turnierergebnisse auswertet** — Fehlerpunkte + Zeit → Platzierung je Wertungsklasse
|
||||||
|
2. **Alle Daten in einer Datenbank hält** statt in Excel und PDF
|
||||||
|
3. **Zeiten per API von der Lichtschranken-Zeitmessung entgegennimmt** (zweiter Raspberry
|
||||||
|
Pi am Parcours, siehe §3.6), in die Datenbank schreibt und **live anzeigt**
|
||||||
|
4. **Fahrer samt Stammdaten und Historie** bereitstellt (die heutige „Baustelle")
|
||||||
|
5. Die **Meisterschafts-/Pokalwertung** automatisch fortschreibt
|
||||||
|
|
||||||
|
### Nicht-Ziele (bewusst außerhalb dieses Projekts)
|
||||||
|
|
||||||
|
- Ablösung der kompletten oamc.de (Kart, Fahrrad, Flohmarkt, VÜP bleiben, wo sie sind)
|
||||||
|
- Online-Nennung/Anmeldung durch Teilnehmer → **Phase 2**, siehe §9
|
||||||
|
- Elektronische Erfassung der **Fehlerpunkte** — die bleiben beim Wertungspersonal im
|
||||||
|
Parcours und werden manuell eingegeben (siehe §3.6)
|
||||||
|
- Zahlungsabwicklung des Nenngelds (10 € je Start, wird vor Ort kassiert)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Fachliche Grundlagen
|
||||||
|
|
||||||
|
> ⚠️ Die folgenden Regeln wurden aus der Ergebnisliste
|
||||||
|
> `2026-05-31-MT-REIN-HAIN.PDF` und der `MT-Rahmenausschreibung-2026.pdf`
|
||||||
|
> **abgeleitet**. Sie sind **vor der Implementierung gegen die ADAC-Turnierordnung 2026
|
||||||
|
> (`MT-D2025.pdf`) und mit dem Bereichsleiter zu verifizieren** — siehe §10.
|
||||||
|
|
||||||
|
### 3.1 Veranstaltungsstruktur
|
||||||
|
|
||||||
|
- Ein Renntag kann ein **Doppelturnier** sein: zwei Turniere zweier Ortsclubs laufen
|
||||||
|
parallel auf einem Platz, z. B. „ADAC-Motorrad-Doppelturnier des MSC Hainstadt und
|
||||||
|
des OAMC Reinheim".
|
||||||
|
- **Jeder Fahrer wird in beiden Turnieren getrennt gewertet** — die Ergebnisliste vom
|
||||||
|
31.05.2026 enthält für dieselben Fahrer je einen Block „Reinheim" und einen Block
|
||||||
|
„Hainstadt" mit **eigenen Zeiten, Fehlern und Platzierungen**.
|
||||||
|
- ⇒ Datenmodell-Konsequenz: `veranstaltung` (Renntag) : `turnier` (Wertungslauf) = 1:n
|
||||||
|
|
||||||
|
### 3.2 Klassen
|
||||||
|
|
||||||
|
**Jugendklassen** (Einteilung strikt nach Jahrgang, Ausweispflicht bei der Nennung):
|
||||||
|
|
||||||
|
| Klasse | Jahrgang | ~Alter | Fahrzeug |
|
||||||
|
|---|---|---|---|
|
||||||
|
| J 1 | 2019 / 2018 / 2017 | 7–9 | Kindermotorrad max. 110 ccm / 5,5 kW |
|
||||||
|
| J 2 | 2016 / 2015 | 10–11 | Kindermotorrad max. 110 ccm / 5,5 kW |
|
||||||
|
| J 3 | 2014 / 2013 | 12–13 | Mofa, Motorrad + Roller max. 125 ccm / 11 kW |
|
||||||
|
| J 4 | 2012 / 2011 | 14–15 | Mofa, Motorrad + Roller max. 125 ccm / 11 kW |
|
||||||
|
| J 5 | 2010 / 2009 / 2008 (bis 18. Geburtstag) | 16–18 | Mofa, Motorrad + Roller max. 125 ccm / 11 kW |
|
||||||
|
|
||||||
|
Für Klasse 1 muss der Teilnehmer **am Veranstaltungstag** bereits 7 Jahre alt sein.
|
||||||
|
⇒ Die Jahrgangstabelle ist **jahresabhängig** und muss versioniert in der DB liegen,
|
||||||
|
nicht im Code (`klassen_jahrgang` mit `saison`).
|
||||||
|
|
||||||
|
**Erwachsenenklassen:** `S 1`–`S 9` (Stammfahrer) und `A 1`–`A 4` (Anfänger), unterteilt
|
||||||
|
nach Fahrzeugtyp/Hubraum. Aus der Sportbeschreibung: Mokicks und Roller bis 80 ccm ·
|
||||||
|
bis 250 ccm · bis 650 ccm · über 650 ccm · Enduro (mehrere Hubraumklassen) ·
|
||||||
|
Seitenwagen · **Pedelec / E-Bike** · **E-Scooter** (neu).
|
||||||
|
|
||||||
|
> ❗ Die genaue Zuordnung `S 1`…`S 9` / `A 1`…`A 4` → Fahrzeugtyp ist aus den
|
||||||
|
> öffentlichen Seiten **nicht** ersichtlich. Muss erfragt werden (§10).
|
||||||
|
|
||||||
|
**Wertungsklasse ≠ Klasse:** In der Ergebnisliste werden Klassen zu Wertungsklassen
|
||||||
|
zusammengefasst, wenn zu wenige Starter da sind — z. B. eine gemeinsame Wertung
|
||||||
|
„S 5, S 9, S 3, S 6, S 7" und „A 1, A 4". Das Zusammenlegen ist eine
|
||||||
|
**Entscheidung pro Turnier** und gehört als Datum in die DB, nicht als Regel in den Code.
|
||||||
|
|
||||||
|
### 3.3 Wertungslogik ← Kernstück der Anwendung
|
||||||
|
|
||||||
|
Spalten der Ergebnisliste:
|
||||||
|
|
||||||
|
`Start-Nr.` · `Voller Name` · `Regionalclub` · `Klasse` · `Geschlecht` · `Kategorie` ·
|
||||||
|
`Summe Zeit` · `Summe Fehler` · `Gesamt` · `Platzierung`
|
||||||
|
|
||||||
|
**Es gibt zwei verschiedene Berechnungen für `Gesamt`:**
|
||||||
|
|
||||||
|
**a) Erwachsene (Kategorie S, und A-Fahrer):**
|
||||||
|
```
|
||||||
|
Gesamt = Summe Zeit + Summe Fehler
|
||||||
|
Platzierung = aufsteigend nach Gesamt
|
||||||
|
```
|
||||||
|
Belege aus der Liste vom 31.05.2026, Wertungsklasse „S 5, S 9, S 3, S 6, S 7":
|
||||||
|
| Fahrer | Klasse | Zeit | Fehler | Gesamt | Platz |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Bernius Meik | S 6 | 105,43 | 0 | **105,43** | 1 |
|
||||||
|
| Schmunk Christian | S 9 | 103,26 | 10 | **113,26** | 2 |
|
||||||
|
| Krack Robin | S 5 | 130,33 | 0 | **130,33** | 4 |
|
||||||
|
| Steinbrech Nicolai | S 5 | 65,71 | 1210 | **1275,71** | 11 |
|
||||||
|
|
||||||
|
**b) Jugend (Kategorie J):**
|
||||||
|
```
|
||||||
|
Gesamt = Summe Fehler ← die Zeit geht NICHT in die Wertung ein
|
||||||
|
Platzierung = aufsteigend nach Fehler, bei Gleichstand aufsteigend nach Zeit
|
||||||
|
```
|
||||||
|
Das deckt sich mit der Sportbeschreibung:
|
||||||
|
> „Bei den Turnieren kommt es in der Jugendklasse vorrangig auf die fehlerfreie
|
||||||
|
> Durchfahrt an, da die Zeit erst bei Punktgleichheit herangezogen wird."
|
||||||
|
|
||||||
|
Belege, Wertungsklasse J 3:
|
||||||
|
| Fahrer | Zeit | Fehler | Gesamt | Platz | Anmerkung |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Freund Philipp | 136,88 | 0 | **0** | 1 | drei Fahrer mit 0 Fehlern … |
|
||||||
|
| D'Angelo Leandro | 140,40 | 0 | **0** | 2 | … Reihenfolge über die Zeit |
|
||||||
|
| Heil Luca | 144,46 | 0 | **0** | 3 | |
|
||||||
|
| Mattes Flynn | 160,05 | 10 | **10** | 4 | |
|
||||||
|
| Ratter Mitja | 148,42 | 60 | **60** | 7 | Gleichstand 60 Fehler … |
|
||||||
|
| Kemesies Finlay | 165,39 | 60 | **60** | 8 | … 148,42 < 165,39 |
|
||||||
|
|
||||||
|
⇒ Die Wertungsformel ist **pro Kategorie konfigurierbar** zu implementieren, nicht
|
||||||
|
hart zu verdrahten (`wertungsregel` als Strategie/Enum je Kategorie und Saison).
|
||||||
|
|
||||||
|
**Fehlerpunkte** treten in Vielfachen von 10 auf (10/20/30/…/1210). Die Zuordnung
|
||||||
|
„welcher Fehler an welcher Aufgabe kostet wie viele Punkte" steht in der
|
||||||
|
ADAC-Turnierordnung und ist zu beschaffen (§10).
|
||||||
|
|
||||||
|
**Mehrfachstarts:** Ein Fahrer kann am selben Tag **mehrfach starten** (anderes Fahrzeug,
|
||||||
|
andere Klasse). Beispiel 31.05.2026: *Schmunk Christian* startet in S 9 **und** S 3,
|
||||||
|
*Bernius Meik* in S 6 **und** S 7 — jeweils mit eigener Startnummer und eigenem Ergebnis.
|
||||||
|
⇒ Der Schlüssel eines Ergebnisses ist **nicht** (Turnier, Fahrer), sondern
|
||||||
|
(Turnier, Start), und Nenngeld fällt „für jeden Start" an.
|
||||||
|
|
||||||
|
### 3.4 Meisterschafts-/Pokalwertung (HTH)
|
||||||
|
|
||||||
|
- **Erwachsene:** ein Gesamtklassement der A- und S-Fahrer/innen (klassenübergreifend)
|
||||||
|
- **Jugend:** Pokalwertung **klassenweise** J 1 – J 5
|
||||||
|
|
||||||
|
**Streichresultate** — nicht alle Läufe zählen:
|
||||||
|
|
||||||
|
| Turniere in der Saison | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| **davon gewertet** | 8 | 8 | 8 | 7 | 6 | 5 | 4 | 3 | 2 |
|
||||||
|
|
||||||
|
Nicht jede Veranstaltung zählt zur Meisterschaft — der Terminkalender markiert Läufe
|
||||||
|
„mit Meisterschaftswertung" bzw. „o. M. = ohne Meisterschaft".
|
||||||
|
|
||||||
|
**Endlaufqualifikation:** die besten **10 Erwachsenen** und **10 Jugendlichen** der
|
||||||
|
HTH-Meisterschaft (Jugend ohne Trennung Damen/Herren, Verteilung prozentual nach
|
||||||
|
Teilnehmerzahl der 5 Jugendklassen). **Bei Punktgleichheit zählt die Majorität der
|
||||||
|
Siege.** Endgültige Festlegung durch den Bereichsleiter ⇒ die Anwendung *schlägt vor*,
|
||||||
|
sie *entscheidet nicht*.
|
||||||
|
|
||||||
|
> ❗ Die **Punktetabelle** (Platz → Meisterschaftspunkte) ist noch nicht bekannt (§10).
|
||||||
|
|
||||||
|
### 3.5 Weitere Regeln aus der Rahmenausschreibung 2026
|
||||||
|
|
||||||
|
- Nenngeld einheitlich **10 €** je Start (S, A, Jugend, Mofa/Moped, E-Bike, Pedelec, E-Scooter)
|
||||||
|
- **Nennschluss:** Klasse S + Jugendklassen 14:00 Uhr, A-Klasse + E-Bike/Pedelec/E-Scooter 15:00 Uhr
|
||||||
|
- Pokalläufe bei Doppelveranstaltungen beginnen generell 09:00 Uhr
|
||||||
|
- Gültiger Jugendausweis + unterschriebener Haftungsausschluss sind bei der Nennung vorzulegen
|
||||||
|
- Eine **nummerierte Nennungsliste** ist zu führen, die Reihenfolge nach Möglichkeit einzuhalten
|
||||||
|
- Für A-Klassen-Fahrer und Gelegenheitsfahrer wird z. B. jede 10. Startnummer freigehalten
|
||||||
|
- Schlussbericht + Ergebnisliste müssen nach FFM gemeldet werden — **der Veranstalter-Zuschuss
|
||||||
|
wird erst überwiesen, wenn beide Dokumente dort sind** ⇒ Export in genau diesen Formaten
|
||||||
|
ist kein „nice to have", sondern zahlungsrelevant
|
||||||
|
|
||||||
|
### 3.6 Zeitmessung per Lichtschranke *(Vorgabe des Auftraggebers)*
|
||||||
|
|
||||||
|
Die API wird von einem **zweiten Raspberry Pi am Parcours** bespielt. Dieser ist an eine
|
||||||
|
**Lichtschranke** angeschlossen, misst die Fahrzeiten, sendet sie an die Anwendung und
|
||||||
|
**zeigt die Daten des aktuellen Fahrzeugs/Starters an**.
|
||||||
|
|
||||||
|
Damit gibt es **zwei Datenquellen pro Ergebnis**:
|
||||||
|
|
||||||
|
| Wert | Quelle | Weg |
|
||||||
|
|---|---|---|
|
||||||
|
| `Summe Zeit` | **Lichtschranke** (Messgerät) | automatisch per API |
|
||||||
|
| `Summe Fehler` | **Wertungspersonal im Parcours** | manuell im internen Frontend |
|
||||||
|
|
||||||
|
Ein Ergebnis ist also erst vollständig, wenn **beide** Seiten geliefert haben. Das
|
||||||
|
Datenmodell muss einen Start mit Zeit, aber ohne Fehlerpunkte (und umgekehrt) sauber
|
||||||
|
abbilden können — ein „halbes" Ergebnis ist der Normalzustand während der Veranstaltung,
|
||||||
|
kein Fehler.
|
||||||
|
|
||||||
|
#### Konsequenzen für den Entwurf
|
||||||
|
|
||||||
|
**a) Rohmessungen statt Summen speichern.**
|
||||||
|
Die Spalte heißt in der Ergebnisliste `Summe Zeit` — die Zeit ist also vermutlich eine
|
||||||
|
**Summe mehrerer Messungen** (mehrere Durchgänge oder mehrere gemessene Abschnitte,
|
||||||
|
z. B. Langsamfahrstrecke und Gesamtdurchfahrt). Die Anwendung speichert deshalb **jede
|
||||||
|
Einzelmessung** (`zeitmessung`) und bildet die Summe selbst. Rückweg gibt es nicht:
|
||||||
|
aus einer gespeicherten Summe lassen sich Einzelmessungen nie wieder herstellen, aus
|
||||||
|
Einzelmessungen die Summe jederzeit. → offene Frage **O-13**
|
||||||
|
|
||||||
|
**b) Das Messgerät ist ein eigenständiger API-Client, kein Teil der Anwendung.**
|
||||||
|
Es bekommt einen **Geräte-Token** (nicht den Auswerter-Login), darf nur Messungen
|
||||||
|
schreiben und Starter-Daten lesen, und wird in der DB als `geraet` geführt. Ein Gerät
|
||||||
|
am offenen Platz muss man notfalls einzeln sperren können.
|
||||||
|
|
||||||
|
**c) Netzausfall ist der Normalfall, nicht die Ausnahme.**
|
||||||
|
Am ADAC-Platz kann das Netz wegbrechen. Der Mess-Pi **muss lokal puffern** (SQLite oder
|
||||||
|
Datei-Queue) und nach Wiederverbindung nachliefern. Deshalb gilt zwingend:
|
||||||
|
- jede Messung bekommt eine **UUID vom Messgerät** → Server ist idempotent, doppelt
|
||||||
|
gesendete Messungen erzeugen keine Dubletten
|
||||||
|
- die Messung trägt **zwei Zeitstempel**: Wanduhr (`gemessen_am`) und **monotone
|
||||||
|
Gerätezeit** (`monotonic_ns`). Die Wanduhr eines Pi ohne Netz und ohne RTC springt
|
||||||
|
beim NTP-Sync — die Dauer zwischen zwei Lichtschranken-Ereignissen darf davon nicht
|
||||||
|
abhängen. **Der Pi berechnet die Dauer selbst und sendet sie**, der Server rechnet
|
||||||
|
keine Differenz aus zwei Wanduhr-Zeitstempeln.
|
||||||
|
- der Server nimmt Messungen **rückwirkend** an (`gemessen_am` in der Vergangenheit)
|
||||||
|
und markiert sie als nachgeliefert
|
||||||
|
|
||||||
|
**d) Zuordnung Messung → Starter ist der kritische Punkt.**
|
||||||
|
Eine Lichtschranke misst eine Zeit, sie weiß nicht, **wer** durchgefahren ist. Die
|
||||||
|
Zuordnung muss irgendwo herkommen — Eingabe der Startnummer am Mess-Pi, eine
|
||||||
|
Warteschlange „nächster Starter" aus der Nennliste, oder ein Transponder/RFID.
|
||||||
|
→ offene Frage **O-14**. Bis zur Klärung wird konservativ geplant:
|
||||||
|
- der Mess-Pi holt sich per API die **Startreihenfolge** des laufenden Turniers
|
||||||
|
- er sendet die Messung **mit** der Startnummer, die er gerade anzeigt
|
||||||
|
- eine Messung **ohne** zuordenbare Startnummer wird nicht verworfen, sondern als
|
||||||
|
`zeitmessung` ohne `start_id` gespeichert und im Frontend zur manuellen Zuordnung
|
||||||
|
angeboten. Eine gemessene Zeit ist nicht reproduzierbar — sie darf nie verloren gehen,
|
||||||
|
nur weil die Zuordnung fehlte.
|
||||||
|
|
||||||
|
**e) Fehlmessungen sind Betriebsrealität.**
|
||||||
|
Zuschauer, Helfer oder ein zurücksetzendes Motorrad lösen die Lichtschranke aus.
|
||||||
|
Nötig sind: **Entprellung/Mindestabstand** zwischen zwei Triggern auf dem Pi, ein
|
||||||
|
**Verwerfen-Knopf** in der Erfassung, sowie Plausibilitätsprüfung serverseitig
|
||||||
|
(eine Fahrzeit von 3 s oder 900 s ist zu markieren, nicht stillschweigend zu übernehmen).
|
||||||
|
Jede verworfene Messung bleibt mit Grund gespeichert.
|
||||||
|
|
||||||
|
**f) Die Anzeige am Parcours ist ein eigener Anwendungsfall.**
|
||||||
|
Der Mess-Pi zeigt Starter- und Fahrzeugdaten an, zieht sie also **lesend** aus der API.
|
||||||
|
Das braucht einen schlanken, schnellen Endpunkt und eine Live-Aktualisierung (SSE),
|
||||||
|
und er muss **auch bei Netzausfall die zuletzt geladene Startliste weiter anzeigen**.
|
||||||
|
|
||||||
|
> ❗ Was genau angezeigt werden soll (Startnummer, Name, Klasse, Fahrzeug, laufende Zeit,
|
||||||
|
> letzte Zeit?) und auf welcher Hardware (HDMI-Monitor, kleines Display, LED-Tafel) ist
|
||||||
|
> noch offen → **O-15**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Datenmodell
|
||||||
|
|
||||||
|
```
|
||||||
|
verein ──┐
|
||||||
|
├──< fahrer ──< start >── turnier >── veranstaltung
|
||||||
|
saison ──┤ │ │ │
|
||||||
|
├──< klasse ───┘ │ └──< wertungsklasse
|
||||||
|
│ ├──< ergebnis (Zeit + Fehler, berechnet)
|
||||||
|
│ └──< zeitmessung >── geraet (Lichtschranke)
|
||||||
|
└──< meisterschaft ──< meisterschaft_stand
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tabellen
|
||||||
|
|
||||||
|
**`verein`** — Regionalclub / Ortsclub
|
||||||
|
`id` · `name` („OAMC Reinheim", „MSC Schotten", „PMS Kassel", „Hainstadt") ·
|
||||||
|
`kurzname` · `adac_gau` · `aktiv`
|
||||||
|
|
||||||
|
**`fahrer`** — Stammdaten, **einmalig**, nicht pro Turnier
|
||||||
|
`id` · `nachname` · `vorname` · `geburtsjahr` · `geburtsdatum` (nullable, für J1-Prüfung
|
||||||
|
„am Veranstaltungstag 7 Jahre") · `geschlecht` (M/W/D) · `verein_id` ·
|
||||||
|
`adac_mitgliedsnr` (nullable) · `jugendausweis_gueltig_bis` · `aktiv` · `notiz` ·
|
||||||
|
`erstellt_am` / `geaendert_am`
|
||||||
|
→ **Dublettenerkennung** beim Import nötig, siehe §6.3
|
||||||
|
|
||||||
|
**`saison`** — Jahr, macht Regeln versionierbar
|
||||||
|
`jahr` (PK) · `turnierordnung_version` · `streichresultate_tabelle` (JSONB) ·
|
||||||
|
`nenngeld_cent` · `aktiv`
|
||||||
|
|
||||||
|
**`klasse`** — Klassendefinition **pro Saison**
|
||||||
|
`id` · `saison_jahr` · `code` („J 1", „S 6", „A 4") · `kategorie` (J/S/A) ·
|
||||||
|
`bezeichnung` · `jahrgaenge` (int[], nur Jugend) · `ccm_max` · `kw_max` ·
|
||||||
|
`fahrzeugtyp` · `sortierung`
|
||||||
|
|
||||||
|
**`veranstaltung`** — der Renntag
|
||||||
|
`id` · `datum` · `titel` · `ort` · `ausrichter_verein_id` · `ist_doppelturnier` ·
|
||||||
|
`veranstaltungsleiter` · `turnierleiter` · `status` (geplant/laufend/vorlaeufig/final)
|
||||||
|
|
||||||
|
**`turnier`** — der einzelne Wertungslauf (bei Doppelturnier zwei pro Veranstaltung)
|
||||||
|
`id` · `veranstaltung_id` · `name` („Reinheim" / „Hainstadt") · `veranstalter_verein_id` ·
|
||||||
|
`zaehlt_meisterschaft` (bool) · `status`
|
||||||
|
|
||||||
|
**`wertungsklasse`** — pro Turnier zusammengefasste Klassen
|
||||||
|
`id` · `turnier_id` · `bezeichnung` („S 5, S 9, S 3, S 6, S 7") ·
|
||||||
|
`klasse_ids` (int[]) · `sortierung`
|
||||||
|
|
||||||
|
**`start`** — eine Teilnahme; ein Fahrer kann mehrere pro Turnier haben
|
||||||
|
`id` · `turnier_id` · `fahrer_id` · `klasse_id` · `wertungsklasse_id` · `startnummer` ·
|
||||||
|
`fahrzeug` (Text) · `nenngeld_bezahlt` · `status` (genannt/gestartet/DNF/DNS/DSQ)
|
||||||
|
→ UNIQUE (`turnier_id`, `startnummer`)
|
||||||
|
|
||||||
|
**`ergebnis`** — 1:1 zum Start, getrennt für saubere Nachträge/Korrekturen
|
||||||
|
`start_id` (PK) · `summe_zeit` (numeric(7,2), **aus `zeitmessung` summiert**) ·
|
||||||
|
`summe_fehler` (int, **manuell erfasst**) · `gesamt` (numeric(9,2), **berechnet**) ·
|
||||||
|
`platzierung` (int, **berechnet**) · `zeit_vollstaendig` (bool) ·
|
||||||
|
`fehler_vollstaendig` (bool) · `erfasst_von` · `erfasst_am` · `korrigiert_am` ·
|
||||||
|
`korrektur_grund`
|
||||||
|
→ Die beiden `*_vollstaendig`-Flags machen sichtbar, welche der zwei Quellen (§3.6)
|
||||||
|
noch fehlt. Gewertet wird erst, wenn beide gesetzt sind.
|
||||||
|
|
||||||
|
**`geraet`** — Messgerät / Anzeige am Parcours
|
||||||
|
`id` · `bezeichnung` („Lichtschranke Ziel", „Anzeige Start") · `typ`
|
||||||
|
(lichtschranke/anzeige) · `token_hash` · `aktiv` · `letzte_meldung_am` ·
|
||||||
|
`firmware_version` · `notiz`
|
||||||
|
→ `letzte_meldung_am` ist der Heartbeat: das Frontend zeigt, ob die Messung noch lebt
|
||||||
|
|
||||||
|
**`zeitmessung`** — Einzelmessung, **nicht** die Summe
|
||||||
|
`id` (UUID, **vom Gerät vergeben** → Idempotenz) · `geraet_id` · `turnier_id` ·
|
||||||
|
`start_id` (**nullable** — unzugeordnete Messungen gehen nicht verloren) ·
|
||||||
|
`startnummer_gemeldet` (int, was das Gerät angezeigt hat) · `messpunkt`
|
||||||
|
(z. B. „Durchgang 1", „Langsamfahrstrecke") · `dauer_sekunden` (numeric(7,2),
|
||||||
|
**vom Gerät berechnet**) · `gemessen_am` (timestamptz, Wanduhr) · `monotonic_ns` (bigint) ·
|
||||||
|
`empfangen_am` · `nachgeliefert` (bool) · `status` (gueltig/verworfen/unzugeordnet) ·
|
||||||
|
`verworfen_grund` · `roh` (JSONB, Rohereignisse der Lichtschranke)
|
||||||
|
→ UNIQUE (`id`); Index auf (`turnier_id`, `status`), (`start_id`)
|
||||||
|
|
||||||
|
**`aufgabe`** / **`ergebnis_aufgabe`** — *optional, Phase 2*: Einzelwertung je Parcours-Aufgabe
|
||||||
|
(Slalom, Wippe, Kreisel …). Die heutigen PDFs enthalten nur Summen; die Struktur
|
||||||
|
vorzusehen kostet jetzt nichts und erlaubt später Detailauswertungen. Sobald die
|
||||||
|
Lichtschranke mehrere Messpunkte bedient, wächst `zeitmessung.messpunkt` natürlich in
|
||||||
|
diese Struktur hinein.
|
||||||
|
|
||||||
|
**`meisterschaft`** — z. B. „HTH Erwachsene 2026", „HTH Jugend J 3 2026"
|
||||||
|
`id` · `saison_jahr` · `bezeichnung` · `kategorie` · `klasse_id` (nullable) · `regelwerk` (JSONB)
|
||||||
|
|
||||||
|
**`meisterschaft_stand`** — materialisiert, nach jedem Turnier neu berechnet
|
||||||
|
`meisterschaft_id` · `fahrer_id` · `punkte_gesamt` · `punkte_je_lauf` (JSONB) ·
|
||||||
|
`gestrichene_laeufe` (JSONB) · `anzahl_siege` (Tie-Break!) · `platz` · `berechnet_am`
|
||||||
|
|
||||||
|
**`import_job`** — Audit-Trail jedes API-/Datei-Imports
|
||||||
|
`id` · `quelle` · `dateiname` · `roh_payload` (JSONB) · `status` · `fehler` ·
|
||||||
|
`angelegt_von` · `angelegt_am`
|
||||||
|
|
||||||
|
**`benutzer`** / **`rolle`** — Admin, Auswerter, Lesend
|
||||||
|
|
||||||
|
### Kernprinzipien
|
||||||
|
|
||||||
|
- `gesamt` und `platzierung` werden **von der Anwendung berechnet**, nicht importiert —
|
||||||
|
importierte Werte werden gegen die Berechnung **geprüft** und Abweichungen gemeldet
|
||||||
|
(fängt Excel-Fehler in Altdaten und Regelmissverständnisse auf beiden Seiten)
|
||||||
|
- Alles **saisonversioniert**: Klassen, Jahrgänge, Streichresultate, Punktetabelle.
|
||||||
|
Ergebnisse aus 2019 müssen 2030 noch korrekt reproduzierbar sein.
|
||||||
|
- Ergebnisse sind **append-only mit Korrektur-Historie**, kein stilles UPDATE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Architektur & Technologie
|
||||||
|
|
||||||
|
### 5.1 Vorhandene Umgebung (geprüft auf diesem Gerät)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Hardware | Raspberry Pi 4 Model B Rev 1.5, 8 GB RAM, 117 GB Speicher (94 GB frei) |
|
||||||
|
| OS | Debian 13 (trixie), Linux 6.18 aarch64 |
|
||||||
|
| **PostgreSQL 17.10** | installiert **und aktiv** ✅ |
|
||||||
|
| **nginx** | installiert **und aktiv** ✅ |
|
||||||
|
| **Python 3.13.5** + pip 25.1.1 | ✅ |
|
||||||
|
| Node.js / npm | ❌ nicht installiert |
|
||||||
|
| Docker | ❌ nicht installiert |
|
||||||
|
| git | ❌ **nicht installiert** — vor Projektstart nachinstallieren |
|
||||||
|
|
||||||
|
### 5.2 Empfohlener Stack
|
||||||
|
|
||||||
|
Der Stack folgt dem, was **schon läuft** — auf einem Pi 4 zählt jedes vermiedene
|
||||||
|
Laufzeit-Ökosystem.
|
||||||
|
|
||||||
|
| Schicht | Wahl | Begründung |
|
||||||
|
|---|---|---|
|
||||||
|
| Datenbank | **PostgreSQL 17** | läuft bereits; JSONB für Regelwerke, saubere Constraints, echte Transaktionen |
|
||||||
|
| Backend | **Python 3.13 + FastAPI** | Python ist da; FastAPI liefert die API-Doku (OpenAPI/Swagger) automatisch mit — wichtig, weil Dritte die API bespielen sollen |
|
||||||
|
| ORM / Migration | **SQLAlchemy 2 + Alembic** | Schemaänderungen über Jahre nachvollziehbar |
|
||||||
|
| Validierung | **Pydantic v2** | ein Schema für API-Validierung *und* Doku |
|
||||||
|
| Templates | **Jinja2**, serverseitig gerendert | Ergebnislisten sind Dokumente, keine App. Kein Build-Schritt, kein Node, sofort suchmaschinenlesbar |
|
||||||
|
| Interaktivität | **HTMX** (~14 kB, per CDN oder lokal) | Filter/Sortierung/Live-Nachladen ohne SPA-Apparat |
|
||||||
|
| Live-Updates | **SSE** (Server-Sent Events), von FastAPI direkt unterstützt | Anzeige am Parcours und Live-Ergebnisse; einseitiger Datenfluss, kein WebSocket-Zustand nötig, reconnectet von selbst |
|
||||||
|
| CSS | handgeschrieben oder **Pico.css** | keine Build-Pipeline |
|
||||||
|
| Excel/CSV-Import | **openpyxl** + `csv` | die Nennlisten sind `.xlsx` |
|
||||||
|
| PDF-Export | **WeasyPrint** | Schlussbericht + Ergebnisliste müssen nach FFM |
|
||||||
|
| Server | **uvicorn** hinter dem vorhandenen **nginx** | nginx macht TLS und liefert Statisches |
|
||||||
|
| Prozess | **systemd-Unit** | kein Docker nötig |
|
||||||
|
| TLS | **Let's Encrypt / certbot** | |
|
||||||
|
|
||||||
|
**Bewusst nicht gewählt:** React/Vue-SPA (Build-Kette auf dem Pi, kein SEO, viel Aufwand
|
||||||
|
für Tabellenanzeige) · Docker (Overhead ohne Nutzen bei einer App) · SQLite
|
||||||
|
(Mehrbenutzerbetrieb am Turniertag, Postgres ist schon da)
|
||||||
|
|
||||||
|
### 5.3 Betrieb
|
||||||
|
|
||||||
|
```
|
||||||
|
╔═══ Am Parcours ═══════════════╗
|
||||||
|
║ Pi #2 „Mess-Pi" ║
|
||||||
|
║ Lichtschranke → GPIO ║
|
||||||
|
║ lokale Queue (SQLite) ║──┐ POST Messungen (idempotent, gepuffert)
|
||||||
|
║ Anzeige Starter/Fahrzeug ║<─┼─ GET Startliste + SSE Live
|
||||||
|
╚═══════════════════════════════╝ │
|
||||||
|
│
|
||||||
|
Internet / LAN ──> nginx (TLS, statisch) ┘
|
||||||
|
└→ uvicorn/FastAPI (systemd) → PostgreSQL 17 (lokal)
|
||||||
|
→ /var/oamc/uploads (PDFs, Bilder)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Backup:** täglicher `pg_dump` + wöchentliche Vollsicherung auf externes Medium.
|
||||||
|
Auf einer SD-Karte/SSD im Pi ist das nicht optional.
|
||||||
|
- **Offline-Fähigkeit am Turniertag** ist durch die Zeitmessung **Pflicht geworden**, nicht
|
||||||
|
mehr optional (§3.6c). Zwei Ebenen:
|
||||||
|
1. der **Mess-Pi** puffert lokal und liefert nach — deckt kurze Aussetzer ab
|
||||||
|
2. bei Veranstaltungen **ohne Internet** muss der Anwendungs-Pi **mit vor Ort** und
|
||||||
|
beide Geräte hängen an einem lokalen Netz (Router/Hotspot ohne Uplink). Die
|
||||||
|
Synchronisation zum öffentlichen Server erfolgt danach.
|
||||||
|
⇒ Entscheidung dazu offen, siehe **O-7**.
|
||||||
|
- **Zeitserver:** ohne Internet gibt es kein NTP. Der Anwendungs-Pi sollte im lokalen
|
||||||
|
Netz selbst NTP anbieten, damit die Zeitstempel zusammenpassen. Für die *Messung*
|
||||||
|
ist das unkritisch (der Mess-Pi rechnet monoton, §3.6c), für die Protokollierung nicht.
|
||||||
|
|
||||||
|
### 5.4 Software auf dem Mess-Pi
|
||||||
|
|
||||||
|
Bewusst klein und eigenständig — das Gerät muss auch dann sinnvoll weiterlaufen, wenn
|
||||||
|
der Server nicht erreichbar ist.
|
||||||
|
|
||||||
|
| Teil | Wahl |
|
||||||
|
|---|---|
|
||||||
|
| GPIO / Lichtschranke | `gpiozero` mit `pigpio`- oder `lgpio`-Backend; Trigger als **Interrupt**, nicht Polling |
|
||||||
|
| Zeitmessung | `time.monotonic_ns()`, Entprellung/Mindestabstand konfigurierbar |
|
||||||
|
| Lokale Queue | SQLite, Einträge bleiben bis vom Server quittiert |
|
||||||
|
| Versand | HTTP + Geräte-Token, Retry mit Backoff, Batch-Nachlieferung |
|
||||||
|
| Anzeige | Browser im Kiosk-Modus auf die Anzeige-Seite der Anwendung (§7), Daten per SSE, letzte Startliste im `localStorage` als Offline-Rückfall |
|
||||||
|
| Betrieb | systemd-Unit, `WatchdogSec`, Autostart, read-only Root-FS erwägen (Stromausfall am Platz) |
|
||||||
|
|
||||||
|
> Für saubere Zeiten unter Last ist ein Interrupt-basierter Trigger Pflicht. Ob die
|
||||||
|
> Genauigkeit eines Linux-Userspace-Prozesses reicht, hängt von der geforderten
|
||||||
|
> Auflösung ab → **O-16**. Die Ergebnislisten führen **1/100 s** (z. B. `159,16`);
|
||||||
|
> das ist mit `pigpio` gut erreichbar, mit Polling in Python nicht zuverlässig.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API
|
||||||
|
|
||||||
|
Zwei Richtungen: **Ingest** (Daten kommen rein) und **Read** (Daten gehen raus).
|
||||||
|
|
||||||
|
### 6.1 Ingest — `POST /api/v1/…` (authentifiziert, API-Key)
|
||||||
|
|
||||||
|
| Endpunkt | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/v1/veranstaltungen` | Renntag anlegen |
|
||||||
|
| `POST /api/v1/turniere` | Wertungslauf zum Renntag |
|
||||||
|
| `POST /api/v1/fahrer` · `PUT /api/v1/fahrer/{id}` | Stammdaten |
|
||||||
|
| `POST /api/v1/turniere/{id}/starts` | Nennliste (Bulk) |
|
||||||
|
| `POST /api/v1/turniere/{id}/ergebnisse` | **Ergebnis-Bulk-Upload** ← Kernfall |
|
||||||
|
| `POST /api/v1/import/xlsx` | Excel-Nennliste/Ergebnisliste als Datei |
|
||||||
|
| `POST /api/v1/turniere/{id}/finalisieren` | Wertung rechnen, Meisterschaft fortschreiben |
|
||||||
|
|
||||||
|
Beispiel-Payload Ergebnis-Upload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"quelle": "excel-auswertung-v3",
|
||||||
|
"idempotenz_schluessel": "2026-05-31-reinheim-final",
|
||||||
|
"ergebnisse": [
|
||||||
|
{ "startnummer": 32, "fahrer": { "nachname": "Gál-Szász", "vorname": "Péter",
|
||||||
|
"geburtsjahr": 2015, "geschlecht": "M", "verein": "OAMC Reinheim" },
|
||||||
|
"klasse": "J 2", "summe_zeit": 159.16, "summe_fehler": 10,
|
||||||
|
"gesamt_erwartet": 10, "platzierung_erwartet": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regeln für den Ingest:**
|
||||||
|
|
||||||
|
- **Idempotent** über `idempotenz_schluessel` — ein doppelt gesendeter Upload legt keine
|
||||||
|
Dubletten an. Am Turniertag mit wackligem Netz ist das Pflicht, nicht Komfort.
|
||||||
|
- **Zweistufig:** `?dry_run=true` liefert eine Validierungsvorschau (welche Fahrer sind
|
||||||
|
neu, welche werden gematcht, welche Werte weichen ab) — erst danach wird geschrieben.
|
||||||
|
- **`gesamt_erwartet` / `platzierung_erwartet`** sind optional; wenn geliefert, rechnet
|
||||||
|
der Server nach und meldet Abweichungen als Warnung, statt sie zu übernehmen.
|
||||||
|
- Jeder Aufruf landet in `import_job` mit Roh-Payload — bei Streit über eine Platzierung
|
||||||
|
ist rekonstruierbar, was wann ankam.
|
||||||
|
|
||||||
|
### 6.2 Geräte-API — Zeitmessung & Anzeige *(Mess-Pi, Token-Auth)*
|
||||||
|
|
||||||
|
Eigener Namensraum mit eigenem Rechteprofil: schreiben **nur** Messungen, lesen **nur**
|
||||||
|
das, was die Anzeige braucht. Kompromittierung des Geräts am offenen Platz darf keine
|
||||||
|
Ergebnisse manipulieren können.
|
||||||
|
|
||||||
|
| Endpunkt | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/v1/geraete/heartbeat` | „ich lebe" + Firmware/Queue-Länge → Statusanzeige im Frontend |
|
||||||
|
| `GET /api/v1/geraete/konfiguration` | Entprellzeit, Messpunkte, aktives Turnier — zentral gepflegt statt auf dem Pi |
|
||||||
|
| `GET /api/v1/turniere/aktiv/startliste` | Startreihenfolge für die Anzeige, ETag-fähig, offlinefest zwischenspeicherbar |
|
||||||
|
| `GET /api/v1/turniere/aktiv/anzeige` (SSE) | Live-Stream: aktueller Starter, letzte Zeit |
|
||||||
|
| `POST /api/v1/zeitmessungen` | **Messungen (Batch, idempotent)** ← Kernfall |
|
||||||
|
| `POST /api/v1/zeitmessungen/{id}/verwerfen` | Fehlmessung markieren |
|
||||||
|
|
||||||
|
Beispiel-Payload der Zeitmessung — bewusst als **Batch**, weil der Pi nach einem
|
||||||
|
Netzausfall mehrere Messungen auf einmal nachliefert:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"geraet": "lichtschranke-ziel-01",
|
||||||
|
"messungen": [
|
||||||
|
{
|
||||||
|
"id": "9f2c1e7a-4d38-4b6e-9a11-5c0b2d7e8f30",
|
||||||
|
"turnier_id": 42,
|
||||||
|
"startnummer_gemeldet": 32,
|
||||||
|
"messpunkt": "durchgang-1",
|
||||||
|
"dauer_sekunden": 159.16,
|
||||||
|
"gemessen_am": "2026-05-31T10:14:02.482+02:00",
|
||||||
|
"monotonic_ns": 884213771004,
|
||||||
|
"roh": { "trigger_start_ns": 884054611004, "trigger_ziel_ns": 884213771004 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwort quittiert **jede Messung einzeln** (`angenommen` / `duplikat` / `unzugeordnet` /
|
||||||
|
`unplausibel`) — erst eine Quittung löscht den Eintrag aus der lokalen Queue des Pi.
|
||||||
|
|
||||||
|
**Festlegungen:**
|
||||||
|
- Die **`id` vergibt das Gerät** (UUIDv4/v7). Nur so ist Nachliefern nach Timeout ohne
|
||||||
|
Dubletten möglich — der Pi weiß nach einem abgebrochenen Request nicht, ob der Server
|
||||||
|
ihn verarbeitet hat, und darf ihn deshalb gefahrlos wiederholen.
|
||||||
|
- Die **Dauer kommt fertig vom Gerät**, der Server rechnet keine Differenz aus Wanduhr-
|
||||||
|
Zeitstempeln (Begründung §3.6c).
|
||||||
|
- `startnummer_gemeldet` ist ein **Hinweis, kein Fremdschlüssel**. Der Server löst sie
|
||||||
|
gegen die Startliste auf; scheitert das, wird die Messung als `unzugeordnet`
|
||||||
|
gespeichert und im Frontend zur Zuordnung angeboten — **nie verworfen**.
|
||||||
|
- Plausibilitätsgrenzen (min/max Fahrzeit je Klasse) sind konfigurierbar; Verstöße
|
||||||
|
werden **markiert**, nicht abgelehnt.
|
||||||
|
- Der Geräte-Token ist **nicht** der Auswerter-Login und einzeln sperrbar.
|
||||||
|
|
||||||
|
### 6.3 Read — `GET /api/v1/…` (öffentlich, ohne Key)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/veranstaltungen?saison=2026
|
||||||
|
GET /api/v1/turniere/{id}/ergebnisse?wertungsklasse=J3
|
||||||
|
GET /api/v1/fahrer?q=bernius&verein=OAMC+Reinheim
|
||||||
|
GET /api/v1/fahrer/{id}/historie
|
||||||
|
GET /api/v1/meisterschaften/{id}/stand
|
||||||
|
GET /api/v1/meisterschaften/{id}/endlauf-qualifikation
|
||||||
|
```
|
||||||
|
|
||||||
|
Zusätzlich **iCal** (`/termine.ics`) für den Terminkalender und **CSV-Export** je Liste.
|
||||||
|
|
||||||
|
### 6.4 Fahrer-Matching beim Import
|
||||||
|
|
||||||
|
Der heikelste Teil des Imports. Aus den Altdaten sichtbar: Namen mit Sonderzeichen
|
||||||
|
(*Gál-Szász*), Vereinsangaben mal als „OAMC Reinheim", mal als „Hainstadt" (Ortsname
|
||||||
|
statt Clubname), Namensgleichheiten in Familien (*Gál-Szász Péter* / *Máté* / *János*;
|
||||||
|
*Bernius Meik* / *Jörg*; *Krack Robin* / *Luca*; *Tinz Xenia* / *Enrique*).
|
||||||
|
|
||||||
|
Vorgehen:
|
||||||
|
1. Exakter Match auf (Nachname, Vorname, Geburtsjahr) — normalisiert (Unicode NFKD,
|
||||||
|
Kleinschreibung, Bindestriche)
|
||||||
|
2. Unscharfer Match (Levenshtein / `pg_trgm`) → **Vorschlag**, nie automatisch
|
||||||
|
3. Kein Match → neuer Fahrer wird angelegt, aber im Import-Report als „NEU" markiert
|
||||||
|
4. **Merge-Funktion** im Admin, um nachträglich erkannte Dubletten zusammenzuführen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Seiten (Frontend)
|
||||||
|
|
||||||
|
**Öffentlich:**
|
||||||
|
|
||||||
|
| Seite | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| Startseite Motorrad-Turnier | nächster Termin, letzte Ergebnisse, aktueller Meisterschaftsstand |
|
||||||
|
| **Termine** | Saisonkalender, mit/ohne Meisterschaftswertung, Ausrichter, iCal-Abo |
|
||||||
|
| **Ergebnisse** | je Veranstaltung → Turnier → Wertungsklasse, sortierbar, mobil lesbar |
|
||||||
|
| **Live** (Turniertag) | vorläufiger Stand, sobald Zeiten eintreffen — deutlich als *vorläufig* gekennzeichnet, solange Fehlerpunkte fehlen (§3.6) |
|
||||||
|
| **Fahrer** | durchsuchbare Liste (löst die „BAUSTELLE" ab) |
|
||||||
|
| **Fahrerprofil** | Stammdaten, alle Starts, Platzierungen, Bestzeiten, Saisonverlauf |
|
||||||
|
| **Meisterschaft** | HTH-Stand Erwachsene + Jugend J 1–J 5, inkl. Streichresultat-Kennzeichnung |
|
||||||
|
| **Endlauf-Qualifikation** | aktueller Stand der Top-10-Plätze |
|
||||||
|
| Sportbeschreibung / Ausschreibung / Downloads | bestehende Inhalte, sauber übernommen |
|
||||||
|
|
||||||
|
**Intern (Login):**
|
||||||
|
|
||||||
|
| Seite | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| Nennungsannahme | Fahrer suchen/anlegen, Klasse automatisch aus Jahrgang vorschlagen, Startnummer vergeben, Nenngeld quittieren |
|
||||||
|
| Ergebniserfassung | schnelle Eingabe Zeit/Fehler je Startnummer, tastaturoptimiert |
|
||||||
|
| Auswertung | Wertungsklassen bilden, rechnen, vorläufig/final setzen |
|
||||||
|
| Korrekturen | mit Begründung, mit Historie |
|
||||||
|
| Exporte | Ergebnisliste (PDF, ADAC-Layout), Schlussbericht, Nennliste, CSV |
|
||||||
|
| **Messungen zuordnen** | unzugeordnete/unplausible Lichtschranken-Messungen einem Start zuweisen oder mit Grund verwerfen |
|
||||||
|
| **Geräte** | Mess-Pi-Status (Heartbeat, Queue-Länge), Token vergeben/sperren, Entprellzeit und Messpunkte konfigurieren |
|
||||||
|
| Import-Monitor | Import-Jobs, Warnungen, Fahrer-Dubletten mergen |
|
||||||
|
| Stammdaten | Saison, Klassen, Vereine, Punktetabelle, Benutzer |
|
||||||
|
|
||||||
|
**Anzeige am Parcours** (eigener, minimaler Modus für den Mess-Pi):
|
||||||
|
|
||||||
|
| Seite | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `/anzeige` | aktueller Starter groß (Nr., Name, Klasse, Fahrzeug), laufende/letzte Zeit, nächste Starter; SSE-Update; Kiosk-tauglich; **behält bei Verbindungsverlust die letzte Startliste** und zeigt den Offline-Zustand deutlich an |
|
||||||
|
|
||||||
|
**Anforderungen quer:** responsiv (am Platz wird das Handy benutzt) · Deep-Links auf jede
|
||||||
|
Ergebnisliste · druckbare Ansichten · Aushang-/Beamer-Modus (große Schrift,
|
||||||
|
Autorefresh) · barrierearm (Kontrast, Tabellen-Semantik)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Arbeitspakete
|
||||||
|
|
||||||
|
### AP 0 — Klärung & Setup *(vor allem anderen)*
|
||||||
|
- Offene Fragen §10 mit dem Verein / Bereichsleiter klären
|
||||||
|
- **ADAC-Turnierordnung 2026 (`MT-D2025.pdf`) beschaffen und Wertungsregeln verifizieren**
|
||||||
|
- Punktetabelle Meisterschaft beschaffen
|
||||||
|
- `git` installieren, Repository anlegen, Python-venv, Projektgerüst
|
||||||
|
- Zugriff auf die Excel-Vorlagen und ein paar echte Nennlisten
|
||||||
|
|
||||||
|
### AP 1 — Datenmodell & Migrationen
|
||||||
|
- SQLAlchemy-Modelle + Alembic-Initialmigration
|
||||||
|
- Stammdaten-Seed: Saison 2026, Klassen J 1–J 5, S/A-Klassen, Vereine
|
||||||
|
- Unit-Tests auf Constraints (Mehrfachstart erlaubt, Startnummer eindeutig je Turnier)
|
||||||
|
|
||||||
|
### AP 2 — Wertungs-Engine *(der Kern — zuerst und mit Tests)*
|
||||||
|
- Regelstrategien je Kategorie (Jugend: Fehler → Zeit; Erwachsene: Zeit + Fehler)
|
||||||
|
- Platzierung inkl. Gleichstandsauflösung, DNF/DNS/DSQ-Behandlung
|
||||||
|
- **Regressionstest gegen die reale Ergebnisliste vom 31.05.2026** — alle 13 Wertungs-
|
||||||
|
blöcke müssen exakt die publizierten Platzierungen reproduzieren. Das ist der
|
||||||
|
Abnahmetest für die Engine.
|
||||||
|
|
||||||
|
### AP 3 — Meisterschaftsberechnung
|
||||||
|
- Punktevergabe, Streichresultate nach Tabelle §3.4
|
||||||
|
- Tie-Break „Majorität der Siege"
|
||||||
|
- Endlauf-Qualifikationsvorschlag Top 10 / Top 10 Jugend
|
||||||
|
- Nachrechnen gegen einen veröffentlichten HTH-Jahresstand als Test
|
||||||
|
|
||||||
|
### AP 4 — API
|
||||||
|
- Read-Endpunkte + OpenAPI-Doku
|
||||||
|
- Ingest-Endpunkte, Idempotenz, `dry_run`, API-Key-Auth, Rate-Limit
|
||||||
|
- Import-Job-Protokollierung
|
||||||
|
- Fahrer-Matching inkl. Merge
|
||||||
|
|
||||||
|
### AP 5 — Zeitmessung: Geräte-API & Mess-Pi
|
||||||
|
- Geräte-Registrierung, Token, Heartbeat, zentrale Konfiguration
|
||||||
|
- `POST /zeitmessungen` mit Batch, Idempotenz, Einzelquittung, Plausibilitätsprüfung
|
||||||
|
- Auflösung `startnummer_gemeldet` → Start; unzugeordnete Messungen im Frontend behandeln
|
||||||
|
- **Client auf dem Mess-Pi:** GPIO-Trigger, Entprellung, monotone Zeitmessung, lokale
|
||||||
|
SQLite-Queue, Retry mit Backoff, Kiosk-Anzeige mit Offline-Rückfall
|
||||||
|
- SSE-Endpunkt und `/anzeige`-Seite
|
||||||
|
- **Abnahmetest:** Netzstecker während der Messung ziehen — nach Wiederverbindung müssen
|
||||||
|
alle Messungen genau einmal in der DB stehen. Ebenso: Batch doppelt senden → keine Dubletten.
|
||||||
|
- **Kalibrierung:** gemessene Zeiten gegen eine Handstoppung/Referenz vergleichen, bevor
|
||||||
|
produktiv damit gewertet wird
|
||||||
|
|
||||||
|
### AP 6 — Öffentliches Frontend
|
||||||
|
- Layout, Navigation, responsive Tabellen
|
||||||
|
- Termine, Ergebnisse, Fahrerliste, Fahrerprofil, Meisterschaft
|
||||||
|
- iCal, CSV, Druckansichten
|
||||||
|
|
||||||
|
### AP 7 — Internes Frontend
|
||||||
|
- Login/Rollen, Nennungsannahme, Ergebniserfassung, Auswertung, Korrekturen
|
||||||
|
- Exporte (PDF-Ergebnisliste im ADAC-Layout, Schlussbericht)
|
||||||
|
|
||||||
|
### AP 8 — Datenübernahme Altbestand
|
||||||
|
- Import der PDF-Ergebnisse ab einem zu definierenden Jahr
|
||||||
|
(⚠️ die PDFs nutzen Subset-Fonts mit eigener Kodierung — reine Textextraktion schlägt
|
||||||
|
bei den Meisterschafts-PDFs fehl; realistisch ist ein **halbautomatischer** Import mit
|
||||||
|
manueller Nacharbeit, ggf. direkt aus den Excel-Originalen statt aus den PDFs)
|
||||||
|
- Vereinheitlichung Vereinsnamen, Dublettenbereinigung Fahrer
|
||||||
|
|
||||||
|
### AP 9 — Deployment & Betrieb
|
||||||
|
- systemd-Unit, nginx-vHost, TLS
|
||||||
|
- Backup-Automatik + **getesteter Restore**
|
||||||
|
- Monitoring, Logrotation
|
||||||
|
- Einweisung der Auswerter, Kurzanleitung
|
||||||
|
|
||||||
|
### AP 10 — Pilotbetrieb
|
||||||
|
- Eine Veranstaltung **parallel** zu Excel auswerten und Ergebnisse vergleichen
|
||||||
|
- **Zeitmessung zunächst nur mitlaufen lassen**, nicht werten: Lichtschranken-Zeit gegen
|
||||||
|
die manuell gestoppte Zeit stellen. Erst wenn beide über eine ganze Veranstaltung
|
||||||
|
übereinstimmen, wird die Messung wertungsrelevant.
|
||||||
|
- Erst nach fehlerfreiem Parallellauf umstellen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Ausbaustufen nach dem Kern
|
||||||
|
|
||||||
|
- **Online-Voranmeldung** — die Rahmenausschreibung wünscht ausdrücklich Voranmeldung
|
||||||
|
größerer Teilnehmergruppen und eine Anmeldeliste; das ist die naheliegendste Erweiterung
|
||||||
|
- **Aufgaben-Einzelwertung** (Tabellen aus §4 bereits vorgesehen) → „woran verliere ich Zeit?"
|
||||||
|
- Bildarchiv mit Verknüpfung Foto ↔ Fahrer ↔ Veranstaltung
|
||||||
|
- Automatische Meldung von Schlussbericht/Ergebnisliste an den ADAC
|
||||||
|
- Mehrmandantenfähigkeit für andere Ortsclubs (MSC Schotten, MSC Hainstadt, PMS Kassel …)
|
||||||
|
- Übernahme der übrigen oamc.de-Rubriken aus dem Frameset
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Offene Fragen
|
||||||
|
|
||||||
|
| # | Frage | Warum blockierend |
|
||||||
|
|---|---|---|
|
||||||
|
| **O-1** | **Punktetabelle der Meisterschaft** (Platz → Punkte) | AP 3 nicht baubar |
|
||||||
|
| **O-2** | Zuordnung `S 1`–`S 9` und `A 1`–`A 4` → Fahrzeugtyp/Hubraum/kW | Stammdaten-Seed |
|
||||||
|
| **O-3** | Zählen **A-Fahrer** zur Meisterschaft? Die Rahmenausschreibung sagt „Gesamtklassement der A- und S-Fahrer/innen", die Spalte `Kategorie` ist bei A-Fahrern in der Ergebnisliste jedoch **leer** (bei S steht „S", bei Jugend „J") | AP 3 |
|
||||||
|
| **O-4** | Gilt die Jugend-Wertung (nur Fehler) auch beim Bundesendlauf, oder dort Zeit+Fehler? | AP 2 |
|
||||||
|
| **O-5** | Behandlung von DNF / DNS / Disqualifikation in Tages- und Jahreswertung | AP 2/3 |
|
||||||
|
| ~~O-6~~ | ~~Wer soll die API bespielen?~~ → **beantwortet:** ein zweiter Raspberry Pi an der Lichtschranke, der Zeiten sendet und Starter-/Fahrzeugdaten anzeigt (§3.6) | erledigt |
|
||||||
|
| **O-7** | **Ist am ADAC-Platz ein verlässliches Netz?** Wenn nein: muss der Anwendungs-Pi mit vor Ort? WLAN, LTE oder Kabel zwischen Mess-Pi und Server? | **hoch** — bestimmt die gesamte Betriebsarchitektur (§5.3) |
|
||||||
|
| **O-8** | Ab welchem Jahr sollen Altdaten übernommen werden (PDFs reichen bis 2002 zurück)? | Aufwand AP 8 |
|
||||||
|
| **O-9** | Wo wird gehostet — dieser Raspberry Pi, ein Server beim Verein, Webhosting? | AP 9, Erreichbarkeit |
|
||||||
|
| **O-10** | Verhältnis zur bestehenden oamc.de: Unterseite, Subdomain (z. B. `mt.oamc.de`) oder Ablösung? | Navigation, DNS |
|
||||||
|
| **O-11** | Wer bekommt Schreibrechte, wie viele Auswerter, gemeinsamer Account oder je Person? | AP 7 |
|
||||||
|
| **O-12** | Liegen die Excel-Originale der Altveranstaltungen noch vor? | macht AP 8 um ein Vielfaches einfacher |
|
||||||
|
|
||||||
|
### Neue Fragen zur Zeitmessung
|
||||||
|
|
||||||
|
| # | Frage | Warum blockierend |
|
||||||
|
|---|---|---|
|
||||||
|
| **O-13** | Was misst die Lichtschranke genau? **Eine** Gesamtdurchfahrt, **mehrere Durchgänge** oder einzelne Abschnitte (Langsamfahrstrecke …)? Woraus setzt sich `Summe Zeit` zusammen? | bestimmt `zeitmessung.messpunkt` und die Summenbildung; AP 5 |
|
||||||
|
| **O-14** | **Woher weiß der Mess-Pi, wer gerade fährt?** Startnummer manuell eintippen, Warteschlange „nächster Starter" aus der Nennliste, Transponder/RFID? | kritischster Punkt der Zuordnung; AP 5 |
|
||||||
|
| **O-15** | Was genau soll die **Anzeige** zeigen und auf welcher Hardware (HDMI-Monitor, kleines Display, LED-Tafel)? Für wen — Fahrer, Publikum, Wertungspersonal? | Gestaltung `/anzeige`; AP 5 |
|
||||||
|
| **O-16** | Geforderte **Messgenauigkeit**? Die Listen führen 1/100 s. Reicht Userspace-GPIO oder braucht es dedizierte Hardware? Ein oder zwei Lichtschranken (Start **und** Ziel)? | Hardware-Auswahl, AP 5 |
|
||||||
|
| **O-17** | **Wie kommen die Fehlerpunkte in die Anwendung** — Zettel vom Wertungspersonal und zentrale Eingabe, oder Tablets im Parcours? | AP 7; Phase 2 könnte hier ansetzen |
|
||||||
|
| **O-18** | Löst die Lichtschranke **automatisch** aus (Fahrzeug unterbricht Strahl) oder gibt es zusätzlich einen manuellen Start-/Stopp-Taster als Rückfall? | Ausfallkonzept, AP 5 |
|
||||||
|
| **O-19** | Wer baut/betreut die **Hardware** (Lichtschranke, Verkabelung, Stromversorgung am Platz)? Existiert sie schon oder wird sie beschafft? | Terminplanung |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Datenschutz
|
||||||
|
|
||||||
|
**Das ist hier kein Formalpunkt.** Es werden **Namen, Geburtsjahre, Geschlecht und
|
||||||
|
Vereinszugehörigkeit von Kindern ab 7 Jahren** verarbeitet und **öffentlich im Internet
|
||||||
|
angezeigt**. Die heutige Praxis (Namen in öffentlichen PDFs) wird durch eine
|
||||||
|
durchsuchbare Datenbank mit Personenprofilen **qualitativ verändert** — aus einer
|
||||||
|
schwer auffindbaren PDF-Liste wird ein über Jahre verknüpftes Profil eines Minderjährigen.
|
||||||
|
Das ist datenschutzrechtlich eine andere Kategorie und muss vor dem Livegang geklärt sein.
|
||||||
|
|
||||||
|
Zu klären und umzusetzen:
|
||||||
|
|
||||||
|
- **Rechtsgrundlage** je Verarbeitung; Einwilligung der Erziehungsberechtigten für die
|
||||||
|
Veröffentlichung von Jugenddaten (idealerweise in Nennformular/Haftungsausschluss
|
||||||
|
aufnehmen — die Formulare werden ohnehin unterschrieben)
|
||||||
|
- **Datenminimierung öffentlich:** Geburts*jahr* statt Geburtsdatum, kein Wohnort,
|
||||||
|
keine Kontaktdaten, keine ADAC-Mitgliedsnummer
|
||||||
|
- **`robots.txt` / `noindex`** für Fahrerprofile — Ergebnislisten dürfen auffindbar sein,
|
||||||
|
Personenprofile von Minderjährigen sollten es nicht sein
|
||||||
|
- **Opt-out**: Anzeige als „Teilnehmer 12" oder Namenskürzel auf Wunsch
|
||||||
|
- **Löschkonzept / Aufbewahrungsfristen**, Auskunfts- und Berichtigungsrecht
|
||||||
|
- **Verzeichnis von Verarbeitungstätigkeiten**, TOMs (Backup-Verschlüsselung, TLS,
|
||||||
|
Zugriffsrollen, Protokollierung)
|
||||||
|
- Ggf. **Auftragsverarbeitung** mit dem Hoster
|
||||||
|
|
||||||
|
> Empfehlung: Vor AP 6 (öffentliches Frontend) eine Entscheidung des Vorstands
|
||||||
|
> herbeiführen, wie Jugenddaten öffentlich dargestellt werden. Die technische Umsetzung
|
||||||
|
> ist einfach — die Entscheidung ist es nicht, und sie nachträglich zu ändern bedeutet,
|
||||||
|
> bereits indexierte Seiten wieder einzufangen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Risiken
|
||||||
|
|
||||||
|
| Risiko | Auswirkung | Gegenmaßnahme |
|
||||||
|
|---|---|---|
|
||||||
|
| Wertungsregeln in §3.3 sind aus PDFs **abgeleitet**, nicht bestätigt | falsche Platzierungen, Vertrauensverlust | AP 0: Turnierordnung beschaffen; AP 2: Regressionstest gegen echte Listen; AP 10: Parallellauf |
|
||||||
|
| Ausfall am Turniertag (Pi, Netz, Strom) | Veranstaltung blockiert | Excel-Fallback bereithalten, bis der Parallellauf sauber ist; Export vor Ort möglich halten |
|
||||||
|
| **Lichtschranke fällt aus oder misst falsch** | Zeiten des laufenden Turniers fehlen — **nicht nachholbar**, die Fahrt ist vorbei | manuelle Zeitnahme als Rückfall **immer** parallel bereithalten; manuelle Nacherfassung im Frontend; Plausibilitätsprüfung serverseitig; AP 10 misst zunächst nur mit |
|
||||||
|
| **Fehlauslösung** durch Zuschauer/Helfer/rangierendes Motorrad | falsche Zeit im Ergebnis | Entprellung auf dem Pi, Verwerfen-Funktion mit Grund, Plausibilitätsgrenzen, Vier-Augen-Prinzip vor „final" |
|
||||||
|
| **Messung dem falschen Fahrer zugeordnet** | zwei Ergebnisse gleichzeitig falsch, schwer zu bemerken | Zuordnung (O-14) früh und sauber klären; Anzeige am Parcours macht die Zuordnung für alle sichtbar → Fehler fällt sofort auf |
|
||||||
|
| Netzausfall zwischen Mess-Pi und Server | Messungen gehen verloren | lokale Queue + Idempotenz (§3.6c); Abnahmetest in AP 5 zieht bewusst den Netzstecker |
|
||||||
|
| Altdaten-Import aus PDFs scheitert an Font-Kodierung | AP 8 wird teuer | Excel-Originale beschaffen (O-12); Import zeitlich begrenzen; notfalls Start ab 2026 |
|
||||||
|
| Fahrer-Dubletten durch Namensvarianten | falsche Meisterschaftsstände | Matching + Merge-Werkzeug in AP 4, Import-Report mit „NEU"-Markierung |
|
||||||
|
| Datenschutzthema Jugenddaten wird spät geklärt | Livegang verzögert oder Rückbau | §11 vor AP 6 entscheiden |
|
||||||
|
| Projekt hängt an einer Person | keine Wartung | Doku, sauberes Repo, zweite eingewiesene Person |
|
||||||
|
| Raspberry Pi: SD-Karte/Speicherausfall | Datenverlust | Backup extern + Restore testen, SSD statt SD-Karte erwägen; auf dem Mess-Pi read-only Root-FS erwägen |
|
||||||
|
| Scope-Ausweitung (Online-Nennung, Bildarchiv …) | Kern wird nie fertig | §9 ist bewusst *nach* dem Kern; AP 10 ist der Zielpunkt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Nächster Schritt
|
||||||
|
|
||||||
|
1. Diesen Plan im Verein durchgehen, insbesondere **§10 (offene Fragen)** und
|
||||||
|
**§11 (Datenschutz)**
|
||||||
|
2. **ADAC-Turnierordnung 2026 und Punktetabelle beschaffen** — ohne die stehen AP 2 und AP 3
|
||||||
|
3. **Zeitmessung klären: O-13 (was wird gemessen), O-14 (Zuordnung zum Fahrer),
|
||||||
|
O-16 (Genauigkeit/Hardware), O-19 (wer baut sie)** — davon hängt AP 5 komplett ab
|
||||||
|
4. Entscheidung zu **O-7** (Netz am Platz) und **O-9/O-10** (Hosting, Domain)
|
||||||
|
5. Danach AP 0 → AP 1 → AP 2
|
||||||
|
|
||||||
|
> **Reihenfolge-Hinweis:** Die Zeitmessung ist das sichtbarste, aber nicht das erste
|
||||||
|
> Arbeitspaket. Sie liefert nur die Hälfte eines Ergebnisses (§3.6) und braucht Turnier,
|
||||||
|
> Startliste und Wertungslogik als Unterbau. AP 1–3 zuerst, dann AP 5 — sonst hat der
|
||||||
|
> Mess-Pi nichts, wogegen er Startnummern auflösen könnte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Quellen
|
||||||
|
|
||||||
|
- `www.oamc.de` — Startseite, Navigation, Rubrik Motorradgruppe (`m/m1.htm`, `m/m2.htm`,
|
||||||
|
`m/m-sportbeschreibung.htm`), Downloadseite `d/d-mot-turnier.htm`
|
||||||
|
- `www.oamc.de/datei/2026-05-31-MT-REIN-HAIN.PDF` — Tagesergebnis Doppelturnier
|
||||||
|
MSC Hainstadt / OAMC Reinheim, 13 Wertungsblöcke; Grundlage für §3.3
|
||||||
|
- `www.oamc.de/datei/MT-Rahmenausschreibung-2026.pdf` (Stand 1.2.2026) — Nenngeld,
|
||||||
|
Nennschluss, Streichresultate, Jugend-Klasseneinteilung, Endlaufqualifikation
|
||||||
|
- Systemprüfung auf dem Zielgerät (Raspberry Pi 4B) für §5.1
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
[project]
|
||||||
|
name = "oamc-turnier"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "OAMC Reinheim — Auswertung von ADAC Motorrad-Turnieren (Ergebnisse, Zeitmessung, Meisterschaft)"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = { text = "Proprietär — OAMC Reinheim e.V. im ADAC" }
|
||||||
|
authors = [{ name = "OAMC Reinheim e.V." }]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"uvicorn[standard]>=0.30",
|
||||||
|
"sqlalchemy>=2.0.30",
|
||||||
|
"alembic>=1.13",
|
||||||
|
"pydantic>=2.7",
|
||||||
|
"pydantic-settings>=2.2",
|
||||||
|
"psycopg[binary]>=3.2",
|
||||||
|
"jinja2>=3.1",
|
||||||
|
"python-multipart>=0.0.9",
|
||||||
|
"bcrypt>=4.1",
|
||||||
|
"pyjwt>=2.8",
|
||||||
|
"openpyxl>=3.1",
|
||||||
|
"python-dateutil>=2.9",
|
||||||
|
"sse-starlette>=2.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
# WeasyPrint zieht native Abhängigkeiten (pango, cairo). Auf dem Pi separat via apt.
|
||||||
|
pdf = ["weasyprint>=62"]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.2",
|
||||||
|
"pytest-asyncio>=0.23",
|
||||||
|
"httpx>=0.27",
|
||||||
|
"ruff>=0.5",
|
||||||
|
"mypy>=1.10",
|
||||||
|
"types-python-dateutil",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
oamc-turnier = "oamc.cli:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
oamc = ["templates/**/*", "static/**/*", "seeds/data/*"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
filterwarnings = [
|
||||||
|
"error::DeprecationWarning:oamc.*",
|
||||||
|
"ignore::DeprecationWarning:starlette.*",
|
||||||
|
"ignore::DeprecationWarning:httpx.*",
|
||||||
|
"ignore:Using `httpx`:UserWarning",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
plugins = ["pydantic.mypy"]
|
||||||
|
warn_unused_ignores = true
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""OAMC Reinheim — Auswertung von ADAC Motorrad-Turnieren.
|
||||||
|
|
||||||
|
Fachliche Grundlagen und Architektur: siehe ``plan.md`` im Projektwurzelverzeichnis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""HTTP-API (FastAPI). Zwei Richtungen: Ingest (rein) und Read (raus). Plan §6."""
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Zusammenbau der API v1."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from oamc.api.v1 import admin, geraete, ingest, read
|
||||||
|
|
||||||
|
api_v1 = APIRouter()
|
||||||
|
api_v1.include_router(read.router)
|
||||||
|
api_v1.include_router(ingest.router)
|
||||||
|
api_v1.include_router(geraete.router)
|
||||||
|
api_v1.include_router(geraete.turnier_router)
|
||||||
|
api_v1.include_router(geraete.messung_router)
|
||||||
|
api_v1.include_router(admin.router)
|
||||||
|
|
||||||
|
__all__ = ["api_v1"]
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Interne Admin-/Auswerter-API (Session-Auth). AP 4/5/7.
|
||||||
|
|
||||||
|
Geraete-Verwaltung, Zuordnung unzugeordneter Messungen, Meisterschaftsberechnung
|
||||||
|
mit Persistierung, Fahrer-Merge, Import-Monitor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from oamc.matching.fahrer import fahrer_zusammenfuehren
|
||||||
|
from oamc.meisterschaft.berechnung import berechne_stand
|
||||||
|
from oamc.models import (
|
||||||
|
Geraet,
|
||||||
|
ImportJob,
|
||||||
|
MeisterschaftStand,
|
||||||
|
Start,
|
||||||
|
Zeitmessung,
|
||||||
|
)
|
||||||
|
from oamc.models.enums import GeraetTyp, ZeitmessungStatus
|
||||||
|
from oamc.security.deps import Admin, Auswerter, DbDep
|
||||||
|
from oamc.security.tokens import erzeuge_geraete_token
|
||||||
|
from oamc.wertung.service import turnier_auswerten
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
class GeraetCreate(BaseModel):
|
||||||
|
bezeichnung: str
|
||||||
|
typ: GeraetTyp = GeraetTyp.LICHTSCHRANKE
|
||||||
|
konfiguration: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/geraete", status_code=201)
|
||||||
|
def geraet_anlegen(db: DbDep, _admin: Admin, daten: GeraetCreate):
|
||||||
|
if db.scalars(select(Geraet).where(Geraet.bezeichnung == daten.bezeichnung)).first():
|
||||||
|
raise HTTPException(409, "Bezeichnung bereits vergeben")
|
||||||
|
roh, token_hash = erzeuge_geraete_token()
|
||||||
|
g = Geraet(
|
||||||
|
bezeichnung=daten.bezeichnung,
|
||||||
|
typ=daten.typ,
|
||||||
|
token_hash=token_hash,
|
||||||
|
konfiguration=daten.konfiguration,
|
||||||
|
)
|
||||||
|
db.add(g)
|
||||||
|
db.flush()
|
||||||
|
# Klartext-Token wird NUR hier ein einziges Mal ausgegeben.
|
||||||
|
return {"id": g.id, "bezeichnung": g.bezeichnung, "token": roh}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/geraete/{geraet_id}/token")
|
||||||
|
def geraet_token_neu(db: DbDep, _admin: Admin, geraet_id: int):
|
||||||
|
g = db.get(Geraet, geraet_id)
|
||||||
|
if g is None:
|
||||||
|
raise HTTPException(404, "Geraet nicht gefunden")
|
||||||
|
roh, token_hash = erzeuge_geraete_token()
|
||||||
|
g.token_hash = token_hash
|
||||||
|
return {"id": g.id, "token": roh}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/geraete/{geraet_id}/sperren")
|
||||||
|
def geraet_sperren(db: DbDep, _admin: Admin, geraet_id: int):
|
||||||
|
g = db.get(Geraet, geraet_id)
|
||||||
|
if g is None:
|
||||||
|
raise HTTPException(404, "Geraet nicht gefunden")
|
||||||
|
g.aktiv = False
|
||||||
|
return {"id": g.id, "aktiv": g.aktiv}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/geraete")
|
||||||
|
def geraete_liste(db: DbDep, _auswerter: Auswerter):
|
||||||
|
jetzt = datetime.now(UTC)
|
||||||
|
out = []
|
||||||
|
for g in db.scalars(select(Geraet)):
|
||||||
|
alter_s = (jetzt - g.letzte_meldung_am).total_seconds() if g.letzte_meldung_am else None
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": g.id,
|
||||||
|
"bezeichnung": g.bezeichnung,
|
||||||
|
"typ": g.typ.value,
|
||||||
|
"aktiv": g.aktiv,
|
||||||
|
"letzte_meldung_am": g.letzte_meldung_am.isoformat() if g.letzte_meldung_am else None,
|
||||||
|
"sekunden_seit_meldung": round(alter_s) if alter_s is not None else None,
|
||||||
|
"lebt": alter_s is not None and alter_s < 60,
|
||||||
|
"queue_laenge_gemeldet": g.queue_laenge_gemeldet,
|
||||||
|
"firmware_version": g.firmware_version,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/zeitmessungen/offen")
|
||||||
|
def offene_messungen(db: DbDep, _auswerter: Auswerter, turnier_id: int | None = None):
|
||||||
|
stmt = select(Zeitmessung).where(
|
||||||
|
Zeitmessung.status.in_([ZeitmessungStatus.UNZUGEORDNET, ZeitmessungStatus.UNPLAUSIBEL])
|
||||||
|
)
|
||||||
|
if turnier_id:
|
||||||
|
stmt = stmt.where(Zeitmessung.turnier_id == turnier_id)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(m.id),
|
||||||
|
"turnier_id": m.turnier_id,
|
||||||
|
"startnummer_gemeldet": m.startnummer_gemeldet,
|
||||||
|
"messpunkt": m.messpunkt,
|
||||||
|
"dauer_sekunden": float(m.dauer_sekunden),
|
||||||
|
"status": m.status.value,
|
||||||
|
"gemessen_am": m.gemessen_am.isoformat(),
|
||||||
|
}
|
||||||
|
for m in db.scalars(stmt.order_by(Zeitmessung.gemessen_am))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ZuordnenEin(BaseModel):
|
||||||
|
startnummer: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/zeitmessungen/{messung_id}/zuordnen")
|
||||||
|
def messung_zuordnen(db: DbDep, _auswerter: Auswerter, messung_id: str, daten: ZuordnenEin):
|
||||||
|
m = db.get(Zeitmessung, messung_id)
|
||||||
|
if m is None:
|
||||||
|
raise HTTPException(404, "Messung nicht gefunden")
|
||||||
|
start = db.scalars(
|
||||||
|
select(Start).where(
|
||||||
|
Start.turnier_id == m.turnier_id, Start.startnummer == daten.startnummer
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if start is None:
|
||||||
|
raise HTTPException(404, f"Kein Start mit Nr. {daten.startnummer} im Turnier {m.turnier_id}")
|
||||||
|
m.start_id = start.id
|
||||||
|
m.startnummer_gemeldet = daten.startnummer
|
||||||
|
if m.status == ZeitmessungStatus.UNZUGEORDNET:
|
||||||
|
m.status = ZeitmessungStatus.GUELTIG
|
||||||
|
return {"id": str(m.id), "start_id": start.id, "status": m.status.value}
|
||||||
|
|
||||||
|
|
||||||
|
class MergeEin(BaseModel):
|
||||||
|
ziel_fahrer_id: int
|
||||||
|
quelle_fahrer_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fahrer/merge")
|
||||||
|
def fahrer_merge(db: DbDep, _admin: Admin, daten: MergeEin):
|
||||||
|
try:
|
||||||
|
anzahl = fahrer_zusammenfuehren(db, daten.ziel_fahrer_id, daten.quelle_fahrer_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
return {"umgehaengte_starts": anzahl, "ziel_fahrer_id": daten.ziel_fahrer_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/import-jobs")
|
||||||
|
def import_jobs(db: DbDep, _auswerter: Auswerter, limit: int = 50):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": j.id,
|
||||||
|
"quelle": j.quelle,
|
||||||
|
"idempotenz_schluessel": j.idempotenz_schluessel,
|
||||||
|
"status": j.status.value,
|
||||||
|
"angelegt_am": j.angelegt_am.isoformat(),
|
||||||
|
"warnungen": (j.ergebnis_bericht or {}).get("warnungen", []),
|
||||||
|
}
|
||||||
|
for j in db.scalars(
|
||||||
|
select(ImportJob).order_by(ImportJob.angelegt_am.desc()).limit(limit)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/meisterschaften/{meisterschaft_id}/berechnen")
|
||||||
|
def meisterschaft_berechnen(db: DbDep, _auswerter: Auswerter, meisterschaft_id: int):
|
||||||
|
"""Stand neu berechnen und materialisieren (``meisterschaft_stand``)."""
|
||||||
|
|
||||||
|
bericht = berechne_stand(db, meisterschaft_id)
|
||||||
|
db.query(MeisterschaftStand).filter(
|
||||||
|
MeisterschaftStand.meisterschaft_id == meisterschaft_id
|
||||||
|
).delete()
|
||||||
|
for z in bericht.zeilen:
|
||||||
|
db.add(
|
||||||
|
MeisterschaftStand(
|
||||||
|
meisterschaft_id=meisterschaft_id,
|
||||||
|
fahrer_id=z.fahrer_id,
|
||||||
|
punkte_gesamt=z.punkte_gesamt,
|
||||||
|
punkte_je_lauf={str(k): float(v) for k, v in z.punkte_je_lauf.items()},
|
||||||
|
gestrichene_laeufe=z.gestrichene_laeufe,
|
||||||
|
anzahl_siege=z.anzahl_siege,
|
||||||
|
platz=z.platz,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"meisterschaft_id": meisterschaft_id,
|
||||||
|
"zeilen": len(bericht.zeilen),
|
||||||
|
"hinweise": bericht.hinweise,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/turniere/{turnier_id}/neu-auswerten")
|
||||||
|
def turnier_neu_auswerten(db: DbDep, _auswerter: Auswerter, turnier_id: int):
|
||||||
|
try:
|
||||||
|
bericht = turnier_auswerten(db, turnier_id, zeit_aus_messungen=True, schreiben=True)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(404, str(exc)) from exc
|
||||||
|
return {
|
||||||
|
"turnier_id": turnier_id,
|
||||||
|
"gewertete_starts": bericht.gewertete_starts,
|
||||||
|
"vorlaeufige_starts": bericht.vorlaeufige_starts,
|
||||||
|
"wertungsklassen": bericht.wertungsklassen,
|
||||||
|
"abweichungen": [a.__dict__ for a in bericht.abweichungen],
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""Geraete-API — Zeitmessung & Anzeige (Mess-Pi, Bearer-Token). Plan §6.2.
|
||||||
|
|
||||||
|
Eigenes Rechteprofil: schreiben NUR Messungen, lesen NUR was die Anzeige braucht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
|
from oamc.db import SessionLocal
|
||||||
|
from oamc.geraete.service import (
|
||||||
|
aktives_turnier,
|
||||||
|
geraet_konfiguration,
|
||||||
|
messung_verwerfen,
|
||||||
|
startliste_fuer_anzeige,
|
||||||
|
verarbeite_batch,
|
||||||
|
)
|
||||||
|
from oamc.models import Zeitmessung
|
||||||
|
from oamc.schemas.geraete import (
|
||||||
|
BatchAntwort,
|
||||||
|
GeraeteKonfiguration,
|
||||||
|
Heartbeat,
|
||||||
|
HeartbeatAntwort,
|
||||||
|
MessungBatch,
|
||||||
|
VerwerfenEin,
|
||||||
|
)
|
||||||
|
from oamc.security.deps import DbDep, GeraetDep
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/geraete", tags=["geraete"])
|
||||||
|
turnier_router = APIRouter(prefix="/api/v1/turniere/aktiv", tags=["geraete"])
|
||||||
|
messung_router = APIRouter(prefix="/api/v1/zeitmessungen", tags=["geraete"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/heartbeat", response_model=HeartbeatAntwort)
|
||||||
|
def heartbeat(db: DbDep, geraet: GeraetDep, daten: Heartbeat):
|
||||||
|
geraet.letzte_meldung_am = datetime.now(UTC)
|
||||||
|
if daten.firmware_version:
|
||||||
|
geraet.firmware_version = daten.firmware_version
|
||||||
|
geraet.queue_laenge_gemeldet = daten.queue_laenge
|
||||||
|
aktiv = aktives_turnier(db)
|
||||||
|
return HeartbeatAntwort(
|
||||||
|
geraet_id=geraet.id,
|
||||||
|
bezeichnung=geraet.bezeichnung,
|
||||||
|
server_zeit=datetime.now(UTC),
|
||||||
|
aktives_turnier_id=aktiv.id if aktiv else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/konfiguration", response_model=GeraeteKonfiguration)
|
||||||
|
def konfiguration(db: DbDep, geraet: GeraetDep):
|
||||||
|
return GeraeteKonfiguration(**geraet_konfiguration(db, geraet))
|
||||||
|
|
||||||
|
|
||||||
|
@turnier_router.get("/startliste")
|
||||||
|
def aktive_startliste(db: DbDep, geraet: GeraetDep, response: Response):
|
||||||
|
aktiv = aktives_turnier(db)
|
||||||
|
if aktiv is None:
|
||||||
|
raise HTTPException(409, "Kein aktives Turnier gesetzt")
|
||||||
|
daten = startliste_fuer_anzeige(db, aktiv.id)
|
||||||
|
# ETag-faehig, offlinefest zwischenspeicherbar (Plan §6.2)
|
||||||
|
roh = json.dumps(daten, sort_keys=True, ensure_ascii=False)
|
||||||
|
etag = f'W/"{aktiv.id}-{len(daten)}-{abs(hash(roh)) & 0xFFFFFFFF:x}"'
|
||||||
|
response.headers["ETag"] = etag
|
||||||
|
response.headers["Cache-Control"] = "no-cache"
|
||||||
|
return {"turnier_id": aktiv.id, "turnier": aktiv.name, "starts": daten}
|
||||||
|
|
||||||
|
|
||||||
|
@messung_router.post("", response_model=BatchAntwort)
|
||||||
|
def messungen_batch(db: DbDep, geraet: GeraetDep, batch: MessungBatch):
|
||||||
|
"""Messungen (Batch, idempotent) — der Kernfall. Antwort quittiert jede
|
||||||
|
Messung einzeln (angenommen/duplikat/unzugeordnet/unplausibel)."""
|
||||||
|
|
||||||
|
return verarbeite_batch(db, geraet, batch)
|
||||||
|
|
||||||
|
|
||||||
|
@messung_router.post("/{messung_id}/verwerfen")
|
||||||
|
def messung_verwerfen_endpoint(
|
||||||
|
db: DbDep, geraet: GeraetDep, messung_id: uuid.UUID, daten: VerwerfenEin
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
m = messung_verwerfen(db, messung_id, daten.grund)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(404, str(exc)) from exc
|
||||||
|
return {"id": str(m.id), "status": m.status.value, "grund": m.verworfen_grund}
|
||||||
|
|
||||||
|
|
||||||
|
# --- SSE: Live-Anzeige am Parcours -------------------------------------
|
||||||
|
async def _anzeige_stream(request):
|
||||||
|
"""Sendet alle ~2 s den aktuellen Stand. Einseitiger Datenfluss, reconnectet
|
||||||
|
von selbst (Plan §5.2). Der Client faellt bei Verbindungsverlust auf die
|
||||||
|
zuletzt geladene Startliste zurueck (Plan §7)."""
|
||||||
|
|
||||||
|
letzte_signatur = None
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
try:
|
||||||
|
aktiv = aktives_turnier(db)
|
||||||
|
if aktiv is None:
|
||||||
|
payload = {"aktiv": False}
|
||||||
|
else:
|
||||||
|
letzte = db.scalars(
|
||||||
|
select(Zeitmessung)
|
||||||
|
.where(Zeitmessung.turnier_id == aktiv.id)
|
||||||
|
.order_by(Zeitmessung.empfangen_am.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
naechste = startliste_fuer_anzeige(db, aktiv.id)[:5]
|
||||||
|
payload = {
|
||||||
|
"aktiv": True,
|
||||||
|
"turnier": aktiv.name,
|
||||||
|
"letzte_messung": (
|
||||||
|
{
|
||||||
|
"startnummer": letzte.startnummer_gemeldet,
|
||||||
|
"dauer_sekunden": float(letzte.dauer_sekunden),
|
||||||
|
"status": letzte.status.value,
|
||||||
|
"empfangen_am": letzte.empfangen_am.isoformat(),
|
||||||
|
}
|
||||||
|
if letzte
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"naechste_starter": naechste,
|
||||||
|
"server_zeit": datetime.now(UTC).isoformat(),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
signatur = json.dumps(payload, sort_keys=True)
|
||||||
|
if signatur != letzte_signatur:
|
||||||
|
letzte_signatur = signatur
|
||||||
|
yield {"event": "anzeige", "data": json.dumps(payload, ensure_ascii=False)}
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
|
||||||
|
@turnier_router.get("/anzeige")
|
||||||
|
async def anzeige_sse(request: Request):
|
||||||
|
return EventSourceResponse(_anzeige_stream(request))
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Ingest-API — POST /api/v1/... (X-API-Key). Plan §6.1.
|
||||||
|
|
||||||
|
Idempotent, zweistufig (``?dry_run=true`` -> Validierungsvorschau).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
|
||||||
|
|
||||||
|
from oamc.importer.ingest import verarbeite_ergebnisse, verarbeite_starts
|
||||||
|
from oamc.importer.xlsx import lese_ergebnisliste, lese_nennliste
|
||||||
|
from oamc.models import Turnier, Veranstaltung
|
||||||
|
from oamc.models.enums import VeranstaltungStatus
|
||||||
|
from oamc.schemas.ingest import (
|
||||||
|
ErgebnisBulk,
|
||||||
|
ImportBericht,
|
||||||
|
StartBulk,
|
||||||
|
TurnierCreate,
|
||||||
|
VeranstaltungCreate,
|
||||||
|
)
|
||||||
|
from oamc.security.deps import DbDep, IngestKey
|
||||||
|
from oamc.wertung.service import turnier_auswerten
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1", tags=["ingest"], dependencies=[])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/veranstaltungen", status_code=201)
|
||||||
|
def veranstaltung_anlegen(db: DbDep, _key: IngestKey, daten: VeranstaltungCreate):
|
||||||
|
verein = None
|
||||||
|
if daten.ausrichter_verein:
|
||||||
|
from oamc.importer.ingest import finde_verein
|
||||||
|
|
||||||
|
verein = finde_verein(db, daten.ausrichter_verein)
|
||||||
|
v = Veranstaltung(
|
||||||
|
datum=daten.datum,
|
||||||
|
titel=daten.titel,
|
||||||
|
ort=daten.ort,
|
||||||
|
ausrichter_verein_id=verein.id if verein else None,
|
||||||
|
ist_doppelturnier=daten.ist_doppelturnier,
|
||||||
|
veranstaltungsleiter=daten.veranstaltungsleiter,
|
||||||
|
turnierleiter=daten.turnierleiter,
|
||||||
|
status=VeranstaltungStatus.GEPLANT,
|
||||||
|
)
|
||||||
|
db.add(v)
|
||||||
|
db.flush()
|
||||||
|
return {"id": v.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/turniere", status_code=201)
|
||||||
|
def turnier_anlegen(db: DbDep, _key: IngestKey, daten: TurnierCreate):
|
||||||
|
if db.get(Veranstaltung, daten.veranstaltung_id) is None:
|
||||||
|
raise HTTPException(404, "Veranstaltung nicht gefunden")
|
||||||
|
verein = None
|
||||||
|
if daten.veranstalter_verein:
|
||||||
|
from oamc.importer.ingest import finde_verein
|
||||||
|
|
||||||
|
verein = finde_verein(db, daten.veranstalter_verein)
|
||||||
|
t = Turnier(
|
||||||
|
veranstaltung_id=daten.veranstaltung_id,
|
||||||
|
name=daten.name,
|
||||||
|
veranstalter_verein_id=verein.id if verein else None,
|
||||||
|
zaehlt_meisterschaft=daten.zaehlt_meisterschaft,
|
||||||
|
)
|
||||||
|
db.add(t)
|
||||||
|
db.flush()
|
||||||
|
return {"id": t.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/turniere/{turnier_id}/starts", response_model=ImportBericht)
|
||||||
|
def starts_bulk(
|
||||||
|
db: DbDep,
|
||||||
|
_key: IngestKey,
|
||||||
|
turnier_id: int,
|
||||||
|
payload: StartBulk,
|
||||||
|
dry_run: bool = Query(default=False),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return verarbeite_starts(db, turnier_id, payload, dry_run=dry_run, angelegt_von="api")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/turniere/{turnier_id}/ergebnisse", response_model=ImportBericht)
|
||||||
|
def ergebnisse_bulk(
|
||||||
|
db: DbDep,
|
||||||
|
_key: IngestKey,
|
||||||
|
turnier_id: int,
|
||||||
|
payload: ErgebnisBulk,
|
||||||
|
dry_run: bool = Query(default=False),
|
||||||
|
):
|
||||||
|
"""Ergebnis-Bulk-Upload — der Kernfall (Plan §6.1)."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return verarbeite_ergebnisse(
|
||||||
|
db, turnier_id, payload, dry_run=dry_run, angelegt_von="api"
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/turniere/{turnier_id}/import/xlsx", response_model=ImportBericht)
|
||||||
|
async def import_xlsx(
|
||||||
|
db: DbDep,
|
||||||
|
_key: IngestKey,
|
||||||
|
turnier_id: int,
|
||||||
|
art: str = Form(description='"nennliste" oder "ergebnisliste"'),
|
||||||
|
idempotenz_schluessel: str = Form(...),
|
||||||
|
quelle: str = Form(default="xlsx-upload"),
|
||||||
|
blatt: str | None = Form(default=None),
|
||||||
|
dry_run: bool = Query(default=True),
|
||||||
|
datei: UploadFile = File(...),
|
||||||
|
):
|
||||||
|
"""Excel-Nennliste/Ergebnisliste als Datei (Plan §6.1).
|
||||||
|
|
||||||
|
Achtung: die Spaltenzuordnung (``SPALTEN_ALIAS`` in ``oamc.importer.xlsx``) ist
|
||||||
|
an die realen ADAC-Vorlagen anzupassen (Plan O-12). Default ``dry_run=true``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
roh = await datei.read()
|
||||||
|
try:
|
||||||
|
if art == "nennliste":
|
||||||
|
payload = lese_nennliste(
|
||||||
|
roh, quelle=quelle, idempotenz_schluessel=idempotenz_schluessel, blatt=blatt
|
||||||
|
)
|
||||||
|
return verarbeite_starts(db, turnier_id, payload, dry_run=dry_run, angelegt_von="xlsx")
|
||||||
|
if art == "ergebnisliste":
|
||||||
|
payload = lese_ergebnisliste(
|
||||||
|
roh, quelle=quelle, idempotenz_schluessel=idempotenz_schluessel, blatt=blatt
|
||||||
|
)
|
||||||
|
return verarbeite_ergebnisse(
|
||||||
|
db, turnier_id, payload, dry_run=dry_run, angelegt_von="xlsx"
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
raise HTTPException(422, 'art muss "nennliste" oder "ergebnisliste" sein')
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/turniere/{turnier_id}/finalisieren")
|
||||||
|
def turnier_finalisieren(db: DbDep, _key: IngestKey, turnier_id: int):
|
||||||
|
"""Wertung rechnen und Turnier auf ``final`` setzen. Die Meisterschaft wird
|
||||||
|
ueber die Admin-API neu berechnet (siehe /api/v1/admin)."""
|
||||||
|
|
||||||
|
t = db.get(Turnier, turnier_id)
|
||||||
|
if t is None:
|
||||||
|
raise HTTPException(404, "Turnier nicht gefunden")
|
||||||
|
bericht = turnier_auswerten(db, turnier_id, zeit_aus_messungen=True, schreiben=True)
|
||||||
|
from oamc.models.enums import TurnierStatus
|
||||||
|
|
||||||
|
t.status = TurnierStatus.FINAL
|
||||||
|
return {
|
||||||
|
"turnier_id": turnier_id,
|
||||||
|
"gewertete_starts": bericht.gewertete_starts,
|
||||||
|
"vorlaeufige_starts": bericht.vorlaeufige_starts,
|
||||||
|
"abweichungen": [a.__dict__ for a in bericht.abweichungen],
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""Read-API — oeffentlich, ohne Key (Plan §6.3).
|
||||||
|
|
||||||
|
Datenminimierung (Plan §11): keine Geburtsdaten, keine Kontaktdaten, keine
|
||||||
|
ADAC-Mitgliedsnummer. Nur Geburtsjahr, und das nur wo fachlich noetig.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from oamc.api.v1.serialisierung import (
|
||||||
|
ergebnis_zeile,
|
||||||
|
fahrer_kurz,
|
||||||
|
turnier_kurz,
|
||||||
|
veranstaltung_kurz,
|
||||||
|
)
|
||||||
|
from oamc.meisterschaft.berechnung import berechne_stand, endlauf_qualifikation
|
||||||
|
from oamc.models import (
|
||||||
|
Fahrer,
|
||||||
|
Meisterschaft,
|
||||||
|
Start,
|
||||||
|
Turnier,
|
||||||
|
Veranstaltung,
|
||||||
|
Verein,
|
||||||
|
)
|
||||||
|
from oamc.security.deps import DbDep
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1", tags=["read"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/veranstaltungen")
|
||||||
|
def liste_veranstaltungen(db: DbDep, saison: int | None = Query(default=None)):
|
||||||
|
stmt = select(Veranstaltung).order_by(Veranstaltung.datum.desc())
|
||||||
|
if saison:
|
||||||
|
stmt = stmt.where(func.extract("year", Veranstaltung.datum) == saison)
|
||||||
|
return [veranstaltung_kurz(v) for v in db.scalars(stmt)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/veranstaltungen/{veranstaltung_id}")
|
||||||
|
def eine_veranstaltung(db: DbDep, veranstaltung_id: int):
|
||||||
|
v = db.get(Veranstaltung, veranstaltung_id)
|
||||||
|
if v is None:
|
||||||
|
raise HTTPException(404, "Veranstaltung nicht gefunden")
|
||||||
|
return {**veranstaltung_kurz(v), "turniere": [turnier_kurz(t) for t in v.turniere]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/turniere/{turnier_id}/ergebnisse")
|
||||||
|
def turnier_ergebnisse(db: DbDep, turnier_id: int, wertungsklasse: str | None = None):
|
||||||
|
turnier = db.get(Turnier, turnier_id)
|
||||||
|
if turnier is None:
|
||||||
|
raise HTTPException(404, "Turnier nicht gefunden")
|
||||||
|
|
||||||
|
starts = db.scalars(
|
||||||
|
select(Start)
|
||||||
|
.where(Start.turnier_id == turnier_id)
|
||||||
|
.options(
|
||||||
|
selectinload(Start.fahrer).selectinload(Fahrer.verein),
|
||||||
|
selectinload(Start.ergebnis),
|
||||||
|
selectinload(Start.klasse),
|
||||||
|
selectinload(Start.wertungsklasse),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
gruppen: dict[str, list] = {}
|
||||||
|
for s in starts:
|
||||||
|
wk = s.wertungsklasse.bezeichnung if s.wertungsklasse else (
|
||||||
|
s.klasse.code if s.klasse else "ohne Wertungsklasse"
|
||||||
|
)
|
||||||
|
gruppen.setdefault(wk, []).append(s)
|
||||||
|
|
||||||
|
if wertungsklasse:
|
||||||
|
gruppen = {k: v for k, v in gruppen.items() if k.lower() == wertungsklasse.lower()}
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for name, gruppe in sorted(gruppen.items()):
|
||||||
|
gruppe.sort(key=lambda s: (s.ergebnis.platzierung if s.ergebnis and s.ergebnis.platzierung else 9999))
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"wertungsklasse": name,
|
||||||
|
"vorlaeufig": turnier.status.value not in ("final",),
|
||||||
|
"zeilen": [ergebnis_zeile(s) for s in gruppe],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"turnier": turnier_kurz(turnier), "wertungsklassen": result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fahrer")
|
||||||
|
def suche_fahrer(
|
||||||
|
db: DbDep,
|
||||||
|
q: str | None = Query(default=None, min_length=2),
|
||||||
|
verein: str | None = None,
|
||||||
|
limit: int = Query(default=50, le=200),
|
||||||
|
):
|
||||||
|
stmt = select(Fahrer).where(Fahrer.aktiv.is_(True)).options(selectinload(Fahrer.verein))
|
||||||
|
if q:
|
||||||
|
muster = f"%{q.lower()}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
func.lower(Fahrer.nachname).like(muster),
|
||||||
|
func.lower(Fahrer.vorname).like(muster),
|
||||||
|
func.lower(func.concat(Fahrer.nachname, " ", Fahrer.vorname)).like(muster),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if verein:
|
||||||
|
stmt = stmt.join(Verein, Verein.id == Fahrer.verein_id).where(
|
||||||
|
func.lower(Verein.name).like(f"%{verein.lower()}%")
|
||||||
|
)
|
||||||
|
stmt = stmt.order_by(Fahrer.nachname, Fahrer.vorname).limit(limit)
|
||||||
|
return [fahrer_kurz(f) for f in db.scalars(stmt)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fahrer/{fahrer_id}")
|
||||||
|
def ein_fahrer(db: DbDep, fahrer_id: int):
|
||||||
|
f = db.get(Fahrer, fahrer_id)
|
||||||
|
if f is None or not f.aktiv:
|
||||||
|
raise HTTPException(404, "Fahrer nicht gefunden")
|
||||||
|
return fahrer_kurz(f)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fahrer/{fahrer_id}/historie")
|
||||||
|
def fahrer_historie(db: DbDep, fahrer_id: int):
|
||||||
|
f = db.get(Fahrer, fahrer_id)
|
||||||
|
if f is None:
|
||||||
|
raise HTTPException(404, "Fahrer nicht gefunden")
|
||||||
|
starts = db.scalars(
|
||||||
|
select(Start)
|
||||||
|
.where(Start.fahrer_id == fahrer_id)
|
||||||
|
.options(
|
||||||
|
selectinload(Start.ergebnis),
|
||||||
|
selectinload(Start.klasse),
|
||||||
|
selectinload(Start.turnier).selectinload(Turnier.veranstaltung),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
eintraege = []
|
||||||
|
for s in sorted(starts, key=lambda s: s.turnier.veranstaltung.datum, reverse=True):
|
||||||
|
e = s.ergebnis
|
||||||
|
eintraege.append(
|
||||||
|
{
|
||||||
|
"datum": s.turnier.veranstaltung.datum.isoformat(),
|
||||||
|
"veranstaltung": s.turnier.veranstaltung.titel,
|
||||||
|
"turnier": s.turnier.name,
|
||||||
|
"klasse": s.klasse.code if s.klasse else None,
|
||||||
|
"startnummer": s.startnummer,
|
||||||
|
"summe_zeit": float(e.summe_zeit) if e and e.summe_zeit is not None else None,
|
||||||
|
"summe_fehler": e.summe_fehler if e else None,
|
||||||
|
"gesamt": float(e.gesamt) if e and e.gesamt is not None else None,
|
||||||
|
"platzierung": e.platzierung if e else None,
|
||||||
|
"status": s.status.value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
zeiten = [x["summe_zeit"] for x in eintraege if x["summe_zeit"]]
|
||||||
|
return {
|
||||||
|
"fahrer": fahrer_kurz(f),
|
||||||
|
"anzahl_starts": len(eintraege),
|
||||||
|
"bestzeit": min(zeiten) if zeiten else None,
|
||||||
|
"starts": eintraege,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meisterschaften")
|
||||||
|
def liste_meisterschaften(db: DbDep, saison: int | None = None):
|
||||||
|
stmt = select(Meisterschaft)
|
||||||
|
if saison:
|
||||||
|
stmt = stmt.where(Meisterschaft.saison_jahr == saison)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": m.id,
|
||||||
|
"bezeichnung": m.bezeichnung,
|
||||||
|
"saison": m.saison_jahr,
|
||||||
|
"kategorie": m.kategorie.value,
|
||||||
|
}
|
||||||
|
for m in db.scalars(stmt)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meisterschaften/{meisterschaft_id}/stand")
|
||||||
|
def meisterschaft_stand(db: DbDep, meisterschaft_id: int):
|
||||||
|
bericht = berechne_stand(db, meisterschaft_id)
|
||||||
|
return {
|
||||||
|
"meisterschaft_id": bericht.meisterschaft_id,
|
||||||
|
"saison": bericht.saison_jahr,
|
||||||
|
"kategorie": bericht.kategorie,
|
||||||
|
"laeufe_gesamt": bericht.laeufe_gesamt,
|
||||||
|
"laeufe_gewertet": bericht.laeufe_gewertet,
|
||||||
|
"hinweise": bericht.hinweise,
|
||||||
|
"zeilen": [
|
||||||
|
{
|
||||||
|
"platz": z.platz,
|
||||||
|
"fahrer": fahrer_kurz(db.get(Fahrer, z.fahrer_id)),
|
||||||
|
"punkte_gesamt": float(z.punkte_gesamt),
|
||||||
|
"anzahl_siege": z.anzahl_siege,
|
||||||
|
"gestrichene_laeufe": z.gestrichene_laeufe,
|
||||||
|
}
|
||||||
|
for z in bericht.zeilen
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meisterschaften/{meisterschaft_id}/endlauf-qualifikation")
|
||||||
|
def meisterschaft_quali(db: DbDep, meisterschaft_id: int, plaetze: int = 10):
|
||||||
|
vorschlaege = endlauf_qualifikation(db, meisterschaft_id, plaetze)
|
||||||
|
return {
|
||||||
|
"meisterschaft_id": meisterschaft_id,
|
||||||
|
"hinweis": "Vorschlag — die endgueltige Festlegung trifft der Bereichsleiter (Plan §3.4).",
|
||||||
|
"plaetze": plaetze,
|
||||||
|
"vorschlaege": [
|
||||||
|
{
|
||||||
|
"platz": v.platz,
|
||||||
|
"fahrer": fahrer_kurz(db.get(Fahrer, v.fahrer_id)),
|
||||||
|
"punkte": float(v.punkte),
|
||||||
|
"anzahl_siege": v.anzahl_siege,
|
||||||
|
"tie_break_relevant": v.tie_break_relevant,
|
||||||
|
}
|
||||||
|
for v in vorschlaege
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Gemeinsame JSON-Serialisierung fuer die Read-API und das Frontend.
|
||||||
|
|
||||||
|
Datenschutz (Plan §11): ``fahrer_kurz`` gibt nie Geburtsdatum,
|
||||||
|
ADAC-Mitgliedsnummer oder Kontaktdaten aus. Anonymisierte Fahrer erscheinen als
|
||||||
|
"Teilnehmer/in #id".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from oamc.models import Fahrer, Start, Turnier, Veranstaltung
|
||||||
|
|
||||||
|
|
||||||
|
def fahrer_kurz(f: Fahrer | None) -> dict | None:
|
||||||
|
if f is None:
|
||||||
|
return None
|
||||||
|
if f.anzeige_anonymisiert:
|
||||||
|
return {"id": f.id, "name": f.anzeigename, "anonymisiert": True}
|
||||||
|
return {
|
||||||
|
"id": f.id,
|
||||||
|
"name": f.voller_name,
|
||||||
|
"nachname": f.nachname,
|
||||||
|
"vorname": f.vorname,
|
||||||
|
"geburtsjahr": f.geburtsjahr,
|
||||||
|
"geschlecht": f.geschlecht.value if f.geschlecht else None,
|
||||||
|
"verein": f.verein.name if f.verein else None,
|
||||||
|
"anonymisiert": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def veranstaltung_kurz(v: Veranstaltung) -> dict:
|
||||||
|
return {
|
||||||
|
"id": v.id,
|
||||||
|
"datum": v.datum.isoformat(),
|
||||||
|
"titel": v.titel,
|
||||||
|
"ort": v.ort,
|
||||||
|
"ist_doppelturnier": v.ist_doppelturnier,
|
||||||
|
"status": v.status.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def turnier_kurz(t: Turnier) -> dict:
|
||||||
|
return {
|
||||||
|
"id": t.id,
|
||||||
|
"veranstaltung_id": t.veranstaltung_id,
|
||||||
|
"name": t.name,
|
||||||
|
"zaehlt_meisterschaft": t.zaehlt_meisterschaft,
|
||||||
|
"status": t.status.value,
|
||||||
|
"ist_aktiv": t.ist_aktiv,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ergebnis_zeile(s: Start) -> dict:
|
||||||
|
e = s.ergebnis
|
||||||
|
return {
|
||||||
|
"startnummer": s.startnummer,
|
||||||
|
"fahrer": fahrer_kurz(s.fahrer),
|
||||||
|
"klasse": s.klasse.code if s.klasse else None,
|
||||||
|
"fahrzeug": s.fahrzeug,
|
||||||
|
"status": s.status.value,
|
||||||
|
"summe_zeit": float(e.summe_zeit) if e and e.summe_zeit is not None else None,
|
||||||
|
"summe_fehler": e.summe_fehler if e else None,
|
||||||
|
"gesamt": float(e.gesamt) if e and e.gesamt is not None else None,
|
||||||
|
"platzierung": e.platzierung if e else None,
|
||||||
|
"zeit_vollstaendig": bool(e and e.zeit_vollstaendig),
|
||||||
|
"fehler_vollstaendig": bool(e and e.fehler_vollstaendig),
|
||||||
|
"vorlaeufig": not (e and e.zeit_vollstaendig and e.fehler_vollstaendig),
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Kommandozeile: ``oamc-turnier <befehl>``.
|
||||||
|
|
||||||
|
Befehle:
|
||||||
|
migrate Alembic-Migrationen bis head
|
||||||
|
seed Stammdaten Saison 2026 (idempotent)
|
||||||
|
benutzer-anlegen internen Benutzer anlegen
|
||||||
|
serve uvicorn im Vordergrund (Entwicklung)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJEKT_WURZEL = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate() -> int:
|
||||||
|
return subprocess.call(["alembic", "upgrade", "head"], cwd=str(PROJEKT_WURZEL))
|
||||||
|
|
||||||
|
|
||||||
|
def _seed() -> int:
|
||||||
|
from oamc.db import SessionLocal
|
||||||
|
from oamc.seeds import seed_saison_2026
|
||||||
|
|
||||||
|
with SessionLocal() as db:
|
||||||
|
zaehler = seed_saison_2026(db)
|
||||||
|
db.commit()
|
||||||
|
print("Seed Saison 2026:", zaehler)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _benutzer_anlegen(args: argparse.Namespace) -> int:
|
||||||
|
from oamc.db import SessionLocal
|
||||||
|
from oamc.models import Benutzer
|
||||||
|
from oamc.models.enums import BenutzerRolle
|
||||||
|
from oamc.security.passwoerter import hash_passwort
|
||||||
|
|
||||||
|
passwort = args.passwort or getpass.getpass("Passwort: ")
|
||||||
|
with SessionLocal() as db:
|
||||||
|
b = Benutzer(
|
||||||
|
benutzername=args.benutzername,
|
||||||
|
anzeigename=args.anzeigename or args.benutzername,
|
||||||
|
passwort_hash=hash_passwort(passwort),
|
||||||
|
rolle=BenutzerRolle(args.rolle),
|
||||||
|
)
|
||||||
|
db.add(b)
|
||||||
|
db.commit()
|
||||||
|
print(f"Benutzer {b.benutzername!r} ({b.rolle.value}) angelegt.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _serve(args: argparse.Namespace) -> int:
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"oamc.main:app", host=args.host, port=args.port, reload=args.reload, log_level="info"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="oamc-turnier")
|
||||||
|
sub = parser.add_subparsers(dest="befehl", required=True)
|
||||||
|
|
||||||
|
sub.add_parser("migrate", help="Alembic-Migrationen ausfuehren")
|
||||||
|
sub.add_parser("seed", help="Stammdaten Saison 2026 einspielen")
|
||||||
|
|
||||||
|
p_b = sub.add_parser("benutzer-anlegen", help="internen Benutzer anlegen")
|
||||||
|
p_b.add_argument("benutzername")
|
||||||
|
p_b.add_argument("--anzeigename")
|
||||||
|
p_b.add_argument("--rolle", default="auswerter", choices=["admin", "auswerter", "lesend"])
|
||||||
|
p_b.add_argument("--passwort", help="sonst interaktive Abfrage")
|
||||||
|
|
||||||
|
p_s = sub.add_parser("serve", help="uvicorn im Vordergrund")
|
||||||
|
p_s.add_argument("--host", default="127.0.0.1")
|
||||||
|
p_s.add_argument("--port", type=int, default=8000)
|
||||||
|
p_s.add_argument("--reload", action="store_true")
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if args.befehl == "migrate":
|
||||||
|
return _migrate()
|
||||||
|
if args.befehl == "seed":
|
||||||
|
return _seed()
|
||||||
|
if args.befehl == "benutzer-anlegen":
|
||||||
|
return _benutzer_anlegen(args)
|
||||||
|
if args.befehl == "serve":
|
||||||
|
return _serve(args)
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Zentrale Konfiguration, aus Umgebung / .env geladen (Pydantic Settings).
|
||||||
|
|
||||||
|
Alle Felder koennen ueber Umgebungsvariablen mit Prefix ``OAMC_`` gesetzt werden,
|
||||||
|
z. B. ``OAMC_DATABASE_URL``. Siehe ``.env.example``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import Field, field_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="OAMC_",
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Datenbank ---------------------------------------------------------
|
||||||
|
database_url: str = Field(
|
||||||
|
default="postgresql+psycopg://linus:linus@localhost:5432/oamc_dev",
|
||||||
|
description="SQLAlchemy-URL. Muss psycopg (v3) verwenden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Sicherheit ------------------------------------------------------
|
||||||
|
secret_key: str = Field(default="dev-only-insecure-key-change-me-please-32b")
|
||||||
|
ingest_api_keys: str = Field(
|
||||||
|
default="dev-ingest-key",
|
||||||
|
description="Kommagetrennte API-Keys fuer die Ingest-API (POST /api/v1/...).",
|
||||||
|
)
|
||||||
|
access_token_minutes: int = 60 * 12
|
||||||
|
|
||||||
|
# --- Betrieb --------------------------------------------------------
|
||||||
|
env: str = Field(default="dev", description="dev | prod")
|
||||||
|
log_level: str = "info"
|
||||||
|
public_base_url: str = "http://localhost:8000"
|
||||||
|
upload_dir: Path = Path("./var/uploads")
|
||||||
|
|
||||||
|
# --- Zeitmessung ---------------------------------------------------
|
||||||
|
mess_min_sekunden: float = 15.0
|
||||||
|
mess_max_sekunden: float = 600.0
|
||||||
|
|
||||||
|
@field_validator("database_url")
|
||||||
|
@classmethod
|
||||||
|
def _require_psycopg(cls, v: str) -> str:
|
||||||
|
if v.startswith("postgresql://"):
|
||||||
|
# bequemer Alias -> auf den psycopg-3-Treiber normalisieren
|
||||||
|
v = v.replace("postgresql://", "postgresql+psycopg://", 1)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ingest_api_key_set(self) -> set[str]:
|
||||||
|
return {k.strip() for k in self.ingest_api_keys.split(",") if k.strip()}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_prod(self) -> bool:
|
||||||
|
return self.env.lower() in {"prod", "production"}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Datenbank-Engine, Session-Factory und FastAPI-Dependency."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from oamc.config import get_settings
|
||||||
|
|
||||||
|
_settings = get_settings()
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
_settings.database_url,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
future=True,
|
||||||
|
echo=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False, future=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Iterator[Session]:
|
||||||
|
"""FastAPI-Dependency: eine Session pro Request, sauberer Rollback bei Fehlern."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Serverseitig gerendertes Frontend (Jinja2 + HTMX). Plan §5.2, §7.
|
||||||
|
|
||||||
|
Ergebnislisten sind Dokumente, keine App: kein Build-Schritt, kein Node,
|
||||||
|
suchmaschinenlesbar, Deep-Links auf jede Liste.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from oamc.frontend.templating import templates
|
||||||
|
|
||||||
|
__all__ = ["templates"]
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Interne Seiten (Login-Pflicht). AP 7."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Form, HTTPException, Request, status
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from oamc.frontend.templating import templates
|
||||||
|
from oamc.models import Benutzer, Ergebnis, Geraet, Start, Turnier, Veranstaltung, Zeitmessung
|
||||||
|
from oamc.models.enums import ZeitmessungStatus
|
||||||
|
from oamc.security.deps import BenutzerOpt, DbDep, require_auswerter
|
||||||
|
from oamc.security.passwoerter import pruefe_passwort
|
||||||
|
from oamc.security.tokens import jwt_erzeugen
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/intern", tags=["intern"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login")
|
||||||
|
def login_form(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "intern/login.html", {"fehler": None})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
def login(request: Request, db: DbDep, benutzername: str = Form(...), passwort: str = Form(...)):
|
||||||
|
b = db.scalars(
|
||||||
|
select(Benutzer).where(Benutzer.benutzername == benutzername, Benutzer.aktiv.is_(True))
|
||||||
|
).first()
|
||||||
|
if b is None or not pruefe_passwort(passwort, b.passwort_hash):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "intern/login.html", {"fehler": "Anmeldung fehlgeschlagen"}, status_code=401
|
||||||
|
)
|
||||||
|
b.letzter_login_am = datetime.now(UTC)
|
||||||
|
token = jwt_erzeugen(b.benutzername, b.rolle.value)
|
||||||
|
resp = RedirectResponse("/intern", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
resp.set_cookie(
|
||||||
|
"oamc_session", token, httponly=True, samesite="lax", max_age=60 * 60 * 12, path="/"
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logout")
|
||||||
|
def logout():
|
||||||
|
resp = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
resp.delete_cookie("oamc_session", path="/")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _benutzer_oder_login(b: Benutzer | None):
|
||||||
|
if b is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_307_TEMPORARY_REDIRECT, headers={"Location": "/intern/login"}
|
||||||
|
)
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def dashboard(request: Request, db: DbDep, b: BenutzerOpt):
|
||||||
|
_benutzer_oder_login(b)
|
||||||
|
veranstaltungen = db.scalars(
|
||||||
|
select(Veranstaltung).order_by(Veranstaltung.datum.desc()).limit(10)
|
||||||
|
).all()
|
||||||
|
offene = db.scalar(
|
||||||
|
select(Zeitmessung)
|
||||||
|
.where(Zeitmessung.status.in_([ZeitmessungStatus.UNZUGEORDNET, ZeitmessungStatus.UNPLAUSIBEL]))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"intern/dashboard.html",
|
||||||
|
{"benutzer": b, "veranstaltungen": veranstaltungen, "hat_offene_messungen": offene is not None},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/erfassung/{turnier_id}")
|
||||||
|
def erfassung(request: Request, db: DbDep, b: BenutzerOpt, turnier_id: int):
|
||||||
|
_benutzer_oder_login(b)
|
||||||
|
turnier = db.get(Turnier, turnier_id)
|
||||||
|
if turnier is None:
|
||||||
|
raise HTTPException(404, "Turnier nicht gefunden")
|
||||||
|
starts = db.scalars(
|
||||||
|
select(Start)
|
||||||
|
.where(Start.turnier_id == turnier_id)
|
||||||
|
.options(selectinload(Start.fahrer), selectinload(Start.ergebnis), selectinload(Start.klasse))
|
||||||
|
.order_by(Start.startnummer)
|
||||||
|
).all()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "intern/erfassung.html", {"benutzer": b, "turnier": turnier, "starts": starts}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/erfassung/{turnier_id}/start/{start_id}")
|
||||||
|
def erfassung_speichern(
|
||||||
|
db: DbDep,
|
||||||
|
b: BenutzerOpt,
|
||||||
|
turnier_id: int,
|
||||||
|
start_id: int,
|
||||||
|
request: Request,
|
||||||
|
summe_zeit: str = Form(default=""),
|
||||||
|
summe_fehler: str = Form(default=""),
|
||||||
|
zeit_fertig: bool = Form(default=False),
|
||||||
|
fehler_fertig: bool = Form(default=False),
|
||||||
|
):
|
||||||
|
benutzer = _benutzer_oder_login(b)
|
||||||
|
require_auswerter(benutzer)
|
||||||
|
start = db.get(Start, start_id)
|
||||||
|
if start is None or start.turnier_id != turnier_id:
|
||||||
|
raise HTTPException(404, "Start nicht gefunden")
|
||||||
|
erg = start.ergebnis or Ergebnis(start_id=start.id)
|
||||||
|
if start.ergebnis is None:
|
||||||
|
start.ergebnis = erg
|
||||||
|
db.add(erg)
|
||||||
|
if summe_zeit.strip():
|
||||||
|
erg.summe_zeit = summe_zeit.replace(",", ".")
|
||||||
|
erg.zeit_vollstaendig = zeit_fertig
|
||||||
|
if summe_fehler.strip():
|
||||||
|
erg.summe_fehler = int(summe_fehler)
|
||||||
|
erg.fehler_vollstaendig = fehler_fertig
|
||||||
|
erg.erfasst_von = benutzer.benutzername
|
||||||
|
db.flush()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"partials/erfassung_zeile.html",
|
||||||
|
{"s": start, "turnier_id": turnier_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/messungen")
|
||||||
|
def messungen(request: Request, db: DbDep, b: BenutzerOpt):
|
||||||
|
_benutzer_oder_login(b)
|
||||||
|
offen = db.scalars(
|
||||||
|
select(Zeitmessung)
|
||||||
|
.where(
|
||||||
|
Zeitmessung.status.in_(
|
||||||
|
[ZeitmessungStatus.UNZUGEORDNET, ZeitmessungStatus.UNPLAUSIBEL]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Zeitmessung.gemessen_am)
|
||||||
|
).all()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "intern/messungen.html", {"benutzer": b, "messungen": offen}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/geraete")
|
||||||
|
def geraete(request: Request, db: DbDep, b: BenutzerOpt):
|
||||||
|
_benutzer_oder_login(b)
|
||||||
|
jetzt = datetime.now(UTC)
|
||||||
|
liste = []
|
||||||
|
for g in db.scalars(select(Geraet).order_by(Geraet.bezeichnung)):
|
||||||
|
alter = (jetzt - g.letzte_meldung_am).total_seconds() if g.letzte_meldung_am else None
|
||||||
|
liste.append({"g": g, "sekunden": round(alter) if alter is not None else None,
|
||||||
|
"lebt": alter is not None and alter < 60})
|
||||||
|
return templates.TemplateResponse(request, "intern/geraete.html", {"benutzer": b, "geraete": liste})
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""Oeffentliche Seiten (ohne Login) und Exporte."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import PlainTextResponse, StreamingResponse
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from oamc.api.v1.serialisierung import ergebnis_zeile
|
||||||
|
from oamc.config import get_settings
|
||||||
|
from oamc.frontend.templating import templates
|
||||||
|
from oamc.meisterschaft.berechnung import berechne_stand
|
||||||
|
from oamc.models import Fahrer, Meisterschaft, Start, Turnier, Veranstaltung
|
||||||
|
from oamc.security.deps import DbDep
|
||||||
|
|
||||||
|
router = APIRouter(tags=["frontend"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", include_in_schema=False)
|
||||||
|
def startseite(request: Request, db: DbDep):
|
||||||
|
heute = date.today()
|
||||||
|
naechste = db.scalars(
|
||||||
|
select(Veranstaltung)
|
||||||
|
.where(Veranstaltung.datum >= heute)
|
||||||
|
.order_by(Veranstaltung.datum)
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
letzte = db.scalars(
|
||||||
|
select(Veranstaltung)
|
||||||
|
.where(Veranstaltung.datum < heute)
|
||||||
|
.order_by(Veranstaltung.datum.desc())
|
||||||
|
.limit(5)
|
||||||
|
).all()
|
||||||
|
meisterschaften = db.scalars(select(Meisterschaft).order_by(Meisterschaft.id)).all()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"public/start.html",
|
||||||
|
{"naechste": naechste, "letzte": letzte, "meisterschaften": meisterschaften},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/termine")
|
||||||
|
def termine(request: Request, db: DbDep, saison: int | None = None):
|
||||||
|
stmt = select(Veranstaltung).order_by(Veranstaltung.datum)
|
||||||
|
if saison:
|
||||||
|
stmt = stmt.where(func.extract("year", Veranstaltung.datum) == saison)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "public/termine.html", {"veranstaltungen": db.scalars(stmt).all(), "saison": saison}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/termine.ics", response_class=PlainTextResponse)
|
||||||
|
def termine_ics(db: DbDep):
|
||||||
|
s = get_settings()
|
||||||
|
zeilen = [
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//OAMC Reinheim//Turnierauswertung//DE",
|
||||||
|
"CALSCALE:GREGORIAN",
|
||||||
|
"X-WR-CALNAME:OAMC Motorrad-Turniere",
|
||||||
|
]
|
||||||
|
for v in db.scalars(select(Veranstaltung).order_by(Veranstaltung.datum)):
|
||||||
|
d = v.datum.strftime("%Y%m%d")
|
||||||
|
zeilen += [
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
f"UID:veranstaltung-{v.id}@{s.public_base_url}",
|
||||||
|
f"DTSTART;VALUE=DATE:{d}",
|
||||||
|
f"SUMMARY:{v.titel}",
|
||||||
|
f"LOCATION:{v.ort or ''}",
|
||||||
|
f"DESCRIPTION:Status {v.status.value}"
|
||||||
|
+ ("" if not v.ist_doppelturnier else " — Doppelturnier"),
|
||||||
|
"END:VEVENT",
|
||||||
|
]
|
||||||
|
zeilen.append("END:VCALENDAR")
|
||||||
|
return PlainTextResponse("\r\n".join(zeilen), media_type="text/calendar")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ergebnisse")
|
||||||
|
def ergebnis_uebersicht(request: Request, db: DbDep):
|
||||||
|
v = db.scalars(select(Veranstaltung).order_by(Veranstaltung.datum.desc())).all()
|
||||||
|
return templates.TemplateResponse(request, "public/ergebnisse_liste.html", {"veranstaltungen": v})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ergebnisse/turnier/{turnier_id}.csv")
|
||||||
|
def ergebnis_turnier_csv(db: DbDep, turnier_id: int):
|
||||||
|
daten = _turnier_ergebnis_daten(db, turnier_id)
|
||||||
|
buf = io.StringIO()
|
||||||
|
w = csv.writer(buf, delimiter=";")
|
||||||
|
w.writerow(["Wertungsklasse", "Platz", "StartNr", "Name", "Verein", "Klasse", "Zeit", "Fehler", "Gesamt"])
|
||||||
|
for wk in daten["wertungsklassen"]:
|
||||||
|
for z in wk["zeilen"]:
|
||||||
|
f = z["fahrer"] or {}
|
||||||
|
w.writerow(
|
||||||
|
[
|
||||||
|
wk["wertungsklasse"],
|
||||||
|
z["platzierung"] or "",
|
||||||
|
z["startnummer"],
|
||||||
|
f.get("name", ""),
|
||||||
|
f.get("verein", ""),
|
||||||
|
z["klasse"] or "",
|
||||||
|
z["summe_zeit"] or "",
|
||||||
|
z["summe_fehler"] if z["summe_fehler"] is not None else "",
|
||||||
|
z["gesamt"] if z["gesamt"] is not None else "",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
buf.seek(0)
|
||||||
|
name = f"ergebnis-turnier-{turnier_id}.csv"
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([buf.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{name}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ergebnisse/turnier/{turnier_id}")
|
||||||
|
def ergebnis_turnier(request: Request, db: DbDep, turnier_id: int):
|
||||||
|
daten = _turnier_ergebnis_daten(db, turnier_id)
|
||||||
|
return templates.TemplateResponse(request, "public/ergebnisse_turnier.html", daten)
|
||||||
|
|
||||||
|
|
||||||
|
def _turnier_ergebnis_daten(db: DbDep, turnier_id: int) -> dict:
|
||||||
|
turnier = db.get(Turnier, turnier_id)
|
||||||
|
if turnier is None:
|
||||||
|
raise HTTPException(404, "Turnier nicht gefunden")
|
||||||
|
starts = db.scalars(
|
||||||
|
select(Start)
|
||||||
|
.where(Start.turnier_id == turnier_id)
|
||||||
|
.options(
|
||||||
|
selectinload(Start.fahrer).selectinload(Fahrer.verein),
|
||||||
|
selectinload(Start.ergebnis),
|
||||||
|
selectinload(Start.klasse),
|
||||||
|
selectinload(Start.wertungsklasse),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
gruppen: dict[str, list] = {}
|
||||||
|
for s in starts:
|
||||||
|
key = s.wertungsklasse.bezeichnung if s.wertungsklasse else (
|
||||||
|
s.klasse.code if s.klasse else "ohne Wertungsklasse"
|
||||||
|
)
|
||||||
|
gruppen.setdefault(key, []).append(s)
|
||||||
|
wk_liste = []
|
||||||
|
for name, gruppe in sorted(gruppen.items()):
|
||||||
|
gruppe.sort(key=lambda s: (s.ergebnis.platzierung if s.ergebnis and s.ergebnis.platzierung else 9999))
|
||||||
|
wk_liste.append({"wertungsklasse": name, "zeilen": [ergebnis_zeile(s) for s in gruppe]})
|
||||||
|
return {
|
||||||
|
"turnier": turnier,
|
||||||
|
"veranstaltung": turnier.veranstaltung,
|
||||||
|
"wertungsklassen": wk_liste,
|
||||||
|
"final": turnier.status.value == "final",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fahrer")
|
||||||
|
def fahrer_liste(request: Request, db: DbDep, q: str | None = None):
|
||||||
|
stmt = (
|
||||||
|
select(Fahrer)
|
||||||
|
.where(Fahrer.aktiv.is_(True))
|
||||||
|
.options(selectinload(Fahrer.verein))
|
||||||
|
.order_by(Fahrer.nachname, Fahrer.vorname)
|
||||||
|
)
|
||||||
|
if q and len(q) >= 2:
|
||||||
|
m = f"%{q.lower()}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
func.lower(Fahrer.nachname).like(m),
|
||||||
|
func.lower(Fahrer.vorname).like(m),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fahrer = db.scalars(stmt.limit(300)).all()
|
||||||
|
tmpl = "partials/fahrer_tabelle.html" if request.headers.get("HX-Request") else "public/fahrer_liste.html"
|
||||||
|
return templates.TemplateResponse(request, tmpl, {"fahrer": fahrer, "q": q or ""})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fahrer/{fahrer_id}")
|
||||||
|
def fahrer_profil(request: Request, db: DbDep, fahrer_id: int):
|
||||||
|
f = db.get(Fahrer, fahrer_id)
|
||||||
|
if f is None or not f.aktiv:
|
||||||
|
raise HTTPException(404, "Fahrer nicht gefunden")
|
||||||
|
starts = db.scalars(
|
||||||
|
select(Start)
|
||||||
|
.where(Start.fahrer_id == fahrer_id)
|
||||||
|
.options(
|
||||||
|
selectinload(Start.ergebnis),
|
||||||
|
selectinload(Start.klasse),
|
||||||
|
selectinload(Start.turnier).selectinload(Turnier.veranstaltung),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
starts.sort(key=lambda s: s.turnier.veranstaltung.datum, reverse=True)
|
||||||
|
zeiten = [
|
||||||
|
float(s.ergebnis.summe_zeit)
|
||||||
|
for s in starts
|
||||||
|
if s.ergebnis and s.ergebnis.summe_zeit is not None
|
||||||
|
]
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"public/fahrer_profil.html",
|
||||||
|
{
|
||||||
|
"fahrer": f,
|
||||||
|
"starts": starts,
|
||||||
|
"bestzeit": min(zeiten) if zeiten else None,
|
||||||
|
"anzahl": len(starts),
|
||||||
|
# noindex fuer Personenprofile (Plan §11)
|
||||||
|
"noindex": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meisterschaft")
|
||||||
|
def meisterschaft_uebersicht(request: Request, db: DbDep):
|
||||||
|
ms = db.scalars(select(Meisterschaft).order_by(Meisterschaft.id)).all()
|
||||||
|
return templates.TemplateResponse(request, "public/meisterschaft_liste.html", {"meisterschaften": ms})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meisterschaft/{meisterschaft_id}")
|
||||||
|
def meisterschaft_detail(request: Request, db: DbDep, meisterschaft_id: int):
|
||||||
|
if db.get(Meisterschaft, meisterschaft_id) is None:
|
||||||
|
raise HTTPException(404, "Meisterschaft nicht gefunden")
|
||||||
|
bericht = berechne_stand(db, meisterschaft_id)
|
||||||
|
fahrer_map = {
|
||||||
|
f.id: f for f in db.scalars(select(Fahrer).where(Fahrer.id.in_([z.fahrer_id for z in bericht.zeilen])))
|
||||||
|
}
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"public/meisterschaft_detail.html",
|
||||||
|
{"bericht": bericht, "fahrer_map": fahrer_map},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/anzeige")
|
||||||
|
def anzeige(request: Request):
|
||||||
|
"""Kiosk-Anzeige am Parcours. Holt Daten per SSE, faellt bei Verbindungsverlust
|
||||||
|
auf die zuletzt geladene Startliste zurueck (localStorage)."""
|
||||||
|
|
||||||
|
return templates.TemplateResponse(request, "anzeige/anzeige.html", {})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/robots.txt", response_class=PlainTextResponse)
|
||||||
|
def robots():
|
||||||
|
# Ergebnislisten duerfen auffindbar sein, Personenprofile Minderjaehriger nicht.
|
||||||
|
return "User-agent: *\nDisallow: /fahrer/\nDisallow: /intern/\n"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Jinja2-Umgebung und Filter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates"
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def _zeit(wert) -> str:
|
||||||
|
if wert is None:
|
||||||
|
return "–"
|
||||||
|
return f"{float(wert):.2f}".replace(".", ",")
|
||||||
|
|
||||||
|
|
||||||
|
def _platz(wert) -> str:
|
||||||
|
return "–" if wert is None else str(wert)
|
||||||
|
|
||||||
|
|
||||||
|
templates.env.filters["zeit"] = _zeit
|
||||||
|
templates.env.filters["platz"] = _platz
|
||||||
|
templates.env.globals["projekt"] = "OAMC Turnierauswertung"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Geraete-/Zeitmessungs-Logik (AP 5, Plan §3.6, §6.2)."""
|
||||||
|
|
||||||
|
from oamc.geraete.service import (
|
||||||
|
aktives_turnier,
|
||||||
|
geraet_konfiguration,
|
||||||
|
messung_verwerfen,
|
||||||
|
startliste_fuer_anzeige,
|
||||||
|
verarbeite_batch,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"aktives_turnier",
|
||||||
|
"geraet_konfiguration",
|
||||||
|
"messung_verwerfen",
|
||||||
|
"startliste_fuer_anzeige",
|
||||||
|
"verarbeite_batch",
|
||||||
|
]
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Verarbeitung von Zeitmessungen und Daten fuer die Anzeige am Parcours.
|
||||||
|
|
||||||
|
Grundsaetze (Plan §3.6):
|
||||||
|
* Idempotent ueber die vom Geraet vergebene UUID — doppelt gesendete Messungen
|
||||||
|
erzeugen keine Dubletten.
|
||||||
|
* Die Dauer kommt fertig vom Geraet; der Server rechnet keine Differenz.
|
||||||
|
* Eine Messung ohne zuordenbare Startnummer wird NICHT verworfen, sondern als
|
||||||
|
``unzugeordnet`` gespeichert.
|
||||||
|
* Plausibilitaetsverstoesse werden MARKIERT (``unplausibel``), nicht abgelehnt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from oamc.config import Settings, get_settings
|
||||||
|
from oamc.models import Geraet, Start, Turnier, Zeitmessung
|
||||||
|
from oamc.models.enums import ZeitmessungStatus
|
||||||
|
from oamc.schemas.geraete import BatchAntwort, MessungBatch, MessungQuittung
|
||||||
|
|
||||||
|
|
||||||
|
def aktives_turnier(db: Session) -> Turnier | None:
|
||||||
|
return db.scalars(select(Turnier).where(Turnier.ist_aktiv.is_(True))).first()
|
||||||
|
|
||||||
|
|
||||||
|
def geraet_konfiguration(db: Session, geraet: Geraet) -> dict:
|
||||||
|
s = get_settings()
|
||||||
|
aktiv = aktives_turnier(db)
|
||||||
|
extra = geraet.konfiguration or {}
|
||||||
|
return {
|
||||||
|
"aktives_turnier_id": aktiv.id if aktiv else None,
|
||||||
|
"entprellung_ms": int(extra.get("entprellung_ms", 300)),
|
||||||
|
"messpunkte": list(extra.get("messpunkte", ["durchgang-1"])),
|
||||||
|
"plausibilitaet_min_s": float(extra.get("plausibilitaet_min_s", s.mess_min_sekunden)),
|
||||||
|
"plausibilitaet_max_s": float(extra.get("plausibilitaet_max_s", s.mess_max_sekunden)),
|
||||||
|
"extra": {k: v for k, v in extra.items() if k not in {
|
||||||
|
"entprellung_ms", "messpunkte", "plausibilitaet_min_s", "plausibilitaet_max_s"
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _startnummer_aufloesen(db: Session, turnier_id: int, startnummer: int | None) -> int | None:
|
||||||
|
if startnummer is None:
|
||||||
|
return None
|
||||||
|
s = db.scalars(
|
||||||
|
select(Start).where(Start.turnier_id == turnier_id, Start.startnummer == startnummer)
|
||||||
|
).first()
|
||||||
|
return s.id if s else None
|
||||||
|
|
||||||
|
|
||||||
|
def verarbeite_batch(
|
||||||
|
db: Session, geraet: Geraet, batch: MessungBatch, settings: Settings | None = None
|
||||||
|
) -> BatchAntwort:
|
||||||
|
s = settings or get_settings()
|
||||||
|
quittungen: list[MessungQuittung] = []
|
||||||
|
zaehler = {"angenommen": 0, "duplikate": 0, "unzugeordnet": 0, "unplausibel": 0}
|
||||||
|
|
||||||
|
for m in batch.messungen:
|
||||||
|
# Idempotenz: existiert die UUID schon, wird sie nur quittiert.
|
||||||
|
bestehend = db.get(Zeitmessung, m.id)
|
||||||
|
if bestehend is not None:
|
||||||
|
zaehler["duplikate"] += 1
|
||||||
|
quittungen.append(
|
||||||
|
MessungQuittung(
|
||||||
|
id=m.id, status="duplikat", start_id=bestehend.start_id,
|
||||||
|
hinweis="bereits gespeichert",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
turnier = db.get(Turnier, m.turnier_id)
|
||||||
|
if turnier is None:
|
||||||
|
quittungen.append(
|
||||||
|
MessungQuittung(id=m.id, status="fehler", hinweis=f"Turnier {m.turnier_id} unbekannt")
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_id = _startnummer_aufloesen(db, m.turnier_id, m.startnummer_gemeldet)
|
||||||
|
dauer = Decimal(m.dauer_sekunden)
|
||||||
|
unplausibel = not (Decimal(str(s.mess_min_sekunden)) <= dauer <= Decimal(str(s.mess_max_sekunden)))
|
||||||
|
|
||||||
|
if unplausibel:
|
||||||
|
status = ZeitmessungStatus.UNPLAUSIBEL
|
||||||
|
zaehler["unplausibel"] += 1
|
||||||
|
hinweis = f"Dauer {dauer}s ausserhalb {s.mess_min_sekunden}-{s.mess_max_sekunden}s"
|
||||||
|
elif start_id is None:
|
||||||
|
status = ZeitmessungStatus.UNZUGEORDNET
|
||||||
|
zaehler["unzugeordnet"] += 1
|
||||||
|
hinweis = "Startnummer nicht aufloesbar — zur manuellen Zuordnung"
|
||||||
|
else:
|
||||||
|
status = ZeitmessungStatus.GUELTIG
|
||||||
|
zaehler["angenommen"] += 1
|
||||||
|
hinweis = None
|
||||||
|
|
||||||
|
jetzt = datetime.now(UTC)
|
||||||
|
nachgeliefert = m.gemessen_am < jetzt.replace(microsecond=0) and (
|
||||||
|
(jetzt - m.gemessen_am).total_seconds() > 120
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
Zeitmessung(
|
||||||
|
id=m.id,
|
||||||
|
geraet_id=geraet.id,
|
||||||
|
turnier_id=m.turnier_id,
|
||||||
|
start_id=start_id,
|
||||||
|
startnummer_gemeldet=m.startnummer_gemeldet,
|
||||||
|
messpunkt=m.messpunkt,
|
||||||
|
dauer_sekunden=dauer,
|
||||||
|
gemessen_am=m.gemessen_am,
|
||||||
|
monotonic_ns=m.monotonic_ns,
|
||||||
|
nachgeliefert=nachgeliefert,
|
||||||
|
status=status,
|
||||||
|
verworfen_grund=None,
|
||||||
|
roh=m.roh,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
quittungen.append(
|
||||||
|
MessungQuittung(
|
||||||
|
id=m.id,
|
||||||
|
status={
|
||||||
|
ZeitmessungStatus.GUELTIG: "angenommen",
|
||||||
|
ZeitmessungStatus.UNZUGEORDNET: "unzugeordnet",
|
||||||
|
ZeitmessungStatus.UNPLAUSIBEL: "unplausibel",
|
||||||
|
}[status],
|
||||||
|
start_id=start_id,
|
||||||
|
hinweis=hinweis,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
geraet.letzte_meldung_am = datetime.now(UTC)
|
||||||
|
return BatchAntwort(
|
||||||
|
quittungen=quittungen,
|
||||||
|
angenommen=zaehler["angenommen"],
|
||||||
|
duplikate=zaehler["duplikate"],
|
||||||
|
unzugeordnet=zaehler["unzugeordnet"],
|
||||||
|
unplausibel=zaehler["unplausibel"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def messung_verwerfen(db: Session, messung_id, grund: str) -> Zeitmessung:
|
||||||
|
m = db.get(Zeitmessung, messung_id)
|
||||||
|
if m is None:
|
||||||
|
raise ValueError("Messung nicht gefunden")
|
||||||
|
m.status = ZeitmessungStatus.VERWORFEN
|
||||||
|
m.verworfen_grund = grund # bleibt mit Grund gespeichert (Plan §3.6e)
|
||||||
|
db.flush()
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def startliste_fuer_anzeige(db: Session, turnier_id: int) -> list[dict]:
|
||||||
|
starts = db.scalars(
|
||||||
|
select(Start)
|
||||||
|
.where(Start.turnier_id == turnier_id)
|
||||||
|
.options(selectinload(Start.fahrer), selectinload(Start.klasse))
|
||||||
|
.order_by(Start.nennreihenfolge.nulls_last(), Start.startnummer)
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"startnummer": s.startnummer,
|
||||||
|
"name": s.fahrer.anzeigename if s.fahrer else "?",
|
||||||
|
"klasse": s.klasse.code if s.klasse else None,
|
||||||
|
"fahrzeug": s.fahrzeug,
|
||||||
|
"status": s.status.value,
|
||||||
|
}
|
||||||
|
for s in starts
|
||||||
|
]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Import: API-Bulk-Uploads und Excel-Dateien (AP 4, AP 8)."""
|
||||||
|
|
||||||
|
from oamc.importer.ingest import (
|
||||||
|
finde_klasse,
|
||||||
|
finde_verein,
|
||||||
|
verarbeite_ergebnisse,
|
||||||
|
verarbeite_starts,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"finde_klasse",
|
||||||
|
"finde_verein",
|
||||||
|
"verarbeite_ergebnisse",
|
||||||
|
"verarbeite_starts",
|
||||||
|
]
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
"""Bulk-Ingest: Nennliste (starts) und Ergebnisse.
|
||||||
|
|
||||||
|
Regeln (Plan §6.1):
|
||||||
|
* Idempotent ueber ``idempotenz_schluessel`` (import_job).
|
||||||
|
* Zweistufig: ``dry_run=True`` liefert eine Validierungsvorschau, erst danach
|
||||||
|
wird geschrieben.
|
||||||
|
* ``gesamt_erwartet``/``platzierung_erwartet`` werden nachgerechnet, Abweichungen
|
||||||
|
als Warnung gemeldet — nie uebernommen.
|
||||||
|
* Jeder Aufruf landet mit Roh-Payload in ``import_job``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from oamc.matching import MatchStatus, match_oder_neu, normalisiere
|
||||||
|
from oamc.models import (
|
||||||
|
Ergebnis,
|
||||||
|
ImportJob,
|
||||||
|
Klasse,
|
||||||
|
Start,
|
||||||
|
Turnier,
|
||||||
|
Verein,
|
||||||
|
Wertungsklasse,
|
||||||
|
)
|
||||||
|
from oamc.models.enums import ImportStatus, StartStatus
|
||||||
|
from oamc.schemas.ingest import (
|
||||||
|
AbweichungInfo,
|
||||||
|
ErgebnisBulk,
|
||||||
|
FahrerMatchInfo,
|
||||||
|
ImportBericht,
|
||||||
|
StartBulk,
|
||||||
|
)
|
||||||
|
from oamc.wertung.service import turnier_auswerten
|
||||||
|
|
||||||
|
|
||||||
|
def finde_verein(db: Session, name: str | None) -> Verein | None:
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
n = normalisiere(name)
|
||||||
|
for v in db.scalars(select(Verein)).all():
|
||||||
|
if normalisiere(v.name) == n or (v.kurzname and normalisiere(v.kurzname) == n):
|
||||||
|
return v
|
||||||
|
# enthaltend (z. B. "Reinheim" in "OAMC Reinheim e.V. im ADAC")
|
||||||
|
for v in db.scalars(select(Verein)).all():
|
||||||
|
if n and (n in normalisiere(v.name) or n in normalisiere(v.kurzname or "")):
|
||||||
|
return v
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def finde_klasse(db: Session, code: str, saison_jahr: int) -> Klasse | None:
|
||||||
|
if not code:
|
||||||
|
return None
|
||||||
|
ziel = code.strip().upper().replace(" ", " ")
|
||||||
|
for k in db.scalars(select(Klasse).where(Klasse.saison_jahr == saison_jahr)).all():
|
||||||
|
if k.code.upper() == ziel or k.code.upper().replace(" ", "") == ziel.replace(" ", ""):
|
||||||
|
return k
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _saison_fuer_turnier(db: Session, turnier: Turnier) -> int:
|
||||||
|
return turnier.veranstaltung.datum.year
|
||||||
|
|
||||||
|
|
||||||
|
def _idempotenz_treffer(db: Session, schluessel: str | None) -> ImportJob | None:
|
||||||
|
if not schluessel:
|
||||||
|
return None
|
||||||
|
return db.scalars(
|
||||||
|
select(ImportJob).where(
|
||||||
|
ImportJob.idempotenz_schluessel == schluessel,
|
||||||
|
ImportJob.status.in_([ImportStatus.ANGENOMMEN, ImportStatus.TEILWEISE]),
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _wertungsklasse(db: Session, turnier_id: int, bezeichnung: str | None) -> Wertungsklasse | None:
|
||||||
|
if not bezeichnung:
|
||||||
|
return None
|
||||||
|
wk = db.scalars(
|
||||||
|
select(Wertungsklasse).where(
|
||||||
|
Wertungsklasse.turnier_id == turnier_id,
|
||||||
|
func.lower(Wertungsklasse.bezeichnung) == bezeichnung.strip().lower(),
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if wk is None:
|
||||||
|
wk = Wertungsklasse(turnier_id=turnier_id, bezeichnung=bezeichnung.strip(), klasse_ids=[])
|
||||||
|
db.add(wk)
|
||||||
|
db.flush()
|
||||||
|
return wk
|
||||||
|
|
||||||
|
|
||||||
|
def verarbeite_starts(
|
||||||
|
db: Session, turnier_id: int, payload: StartBulk, *, dry_run: bool, angelegt_von: str | None
|
||||||
|
) -> ImportBericht:
|
||||||
|
turnier = db.get(Turnier, turnier_id)
|
||||||
|
if turnier is None:
|
||||||
|
raise ValueError(f"Turnier {turnier_id} nicht gefunden")
|
||||||
|
saison_jahr = _saison_fuer_turnier(db, turnier)
|
||||||
|
|
||||||
|
vorhandener = _idempotenz_treffer(db, payload.idempotenz_schluessel)
|
||||||
|
if vorhandener is not None:
|
||||||
|
return ImportBericht(
|
||||||
|
import_job_id=vorhandener.id, dry_run=dry_run, idempotent_uebersprungen=True
|
||||||
|
)
|
||||||
|
|
||||||
|
bericht = ImportBericht(dry_run=dry_run)
|
||||||
|
for eintrag in payload.starts:
|
||||||
|
verein = finde_verein(db, eintrag.fahrer.verein)
|
||||||
|
match = match_oder_neu(
|
||||||
|
db,
|
||||||
|
nachname=eintrag.fahrer.nachname,
|
||||||
|
vorname=eintrag.fahrer.vorname,
|
||||||
|
geburtsjahr=eintrag.fahrer.geburtsjahr,
|
||||||
|
geschlecht=eintrag.fahrer.geschlecht,
|
||||||
|
verein_id=verein.id if verein else None,
|
||||||
|
anlegen=not dry_run,
|
||||||
|
)
|
||||||
|
bericht.fahrer_matches.append(
|
||||||
|
FahrerMatchInfo(
|
||||||
|
startnummer=eintrag.startnummer,
|
||||||
|
eingabe=f"{eintrag.fahrer.nachname} {eintrag.fahrer.vorname}",
|
||||||
|
status=match.status.value,
|
||||||
|
fahrer_id=match.fahrer.id if match.fahrer else None,
|
||||||
|
vorschlaege=[v.__dict__ for v in match.vorschlaege],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if match.status == MatchStatus.NEU:
|
||||||
|
bericht.neue_fahrer += 1
|
||||||
|
|
||||||
|
klasse = finde_klasse(db, eintrag.klasse, saison_jahr)
|
||||||
|
if klasse is None:
|
||||||
|
bericht.warnungen.append(
|
||||||
|
f"Startnr. {eintrag.startnummer}: Klasse {eintrag.klasse!r} unbekannt"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
bericht.angelegt += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
wk = _wertungsklasse(db, turnier_id, eintrag.wertungsklasse)
|
||||||
|
vorhanden = db.scalars(
|
||||||
|
select(Start).where(
|
||||||
|
Start.turnier_id == turnier_id, Start.startnummer == eintrag.startnummer
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if vorhanden is None:
|
||||||
|
db.add(
|
||||||
|
Start(
|
||||||
|
turnier_id=turnier_id,
|
||||||
|
fahrer_id=match.fahrer.id,
|
||||||
|
klasse_id=klasse.id,
|
||||||
|
wertungsklasse_id=wk.id if wk else None,
|
||||||
|
startnummer=eintrag.startnummer,
|
||||||
|
fahrzeug=eintrag.fahrzeug,
|
||||||
|
nenngeld_bezahlt=eintrag.nenngeld_bezahlt,
|
||||||
|
nennreihenfolge=eintrag.nennreihenfolge,
|
||||||
|
status=eintrag.status,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bericht.angelegt += 1
|
||||||
|
else:
|
||||||
|
vorhanden.fahrer_id = match.fahrer.id
|
||||||
|
vorhanden.klasse_id = klasse.id
|
||||||
|
vorhanden.wertungsklasse_id = wk.id if wk else vorhanden.wertungsklasse_id
|
||||||
|
vorhanden.fahrzeug = eintrag.fahrzeug or vorhanden.fahrzeug
|
||||||
|
bericht.aktualisiert += 1
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
job = ImportJob(
|
||||||
|
quelle=payload.quelle,
|
||||||
|
idempotenz_schluessel=payload.idempotenz_schluessel,
|
||||||
|
roh_payload=payload.model_dump(mode="json"),
|
||||||
|
ergebnis_bericht=bericht.model_dump(mode="json"),
|
||||||
|
status=ImportStatus.ANGENOMMEN,
|
||||||
|
angelegt_von=angelegt_von,
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.flush()
|
||||||
|
bericht.import_job_id = job.id
|
||||||
|
return bericht
|
||||||
|
|
||||||
|
|
||||||
|
def verarbeite_ergebnisse(
|
||||||
|
db: Session,
|
||||||
|
turnier_id: int,
|
||||||
|
payload: ErgebnisBulk,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
angelegt_von: str | None,
|
||||||
|
) -> ImportBericht:
|
||||||
|
turnier = db.get(Turnier, turnier_id)
|
||||||
|
if turnier is None:
|
||||||
|
raise ValueError(f"Turnier {turnier_id} nicht gefunden")
|
||||||
|
saison_jahr = _saison_fuer_turnier(db, turnier)
|
||||||
|
|
||||||
|
vorhandener = _idempotenz_treffer(db, payload.idempotenz_schluessel)
|
||||||
|
if vorhandener is not None:
|
||||||
|
return ImportBericht(
|
||||||
|
import_job_id=vorhandener.id, dry_run=dry_run, idempotent_uebersprungen=True
|
||||||
|
)
|
||||||
|
|
||||||
|
bericht = ImportBericht(dry_run=dry_run)
|
||||||
|
|
||||||
|
for eintrag in payload.ergebnisse:
|
||||||
|
start = db.scalars(
|
||||||
|
select(Start).where(
|
||||||
|
Start.turnier_id == turnier_id, Start.startnummer == eintrag.startnummer
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if start is None:
|
||||||
|
if eintrag.fahrer is None or eintrag.klasse is None:
|
||||||
|
bericht.warnungen.append(
|
||||||
|
f"Startnr. {eintrag.startnummer}: kein Start und keine Fahrer-/Klassenangabe "
|
||||||
|
"zum Anlegen"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
verein = finde_verein(db, eintrag.fahrer.verein)
|
||||||
|
match = match_oder_neu(
|
||||||
|
db,
|
||||||
|
nachname=eintrag.fahrer.nachname,
|
||||||
|
vorname=eintrag.fahrer.vorname,
|
||||||
|
geburtsjahr=eintrag.fahrer.geburtsjahr,
|
||||||
|
geschlecht=eintrag.fahrer.geschlecht,
|
||||||
|
verein_id=verein.id if verein else None,
|
||||||
|
anlegen=not dry_run,
|
||||||
|
)
|
||||||
|
bericht.fahrer_matches.append(
|
||||||
|
FahrerMatchInfo(
|
||||||
|
startnummer=eintrag.startnummer,
|
||||||
|
eingabe=f"{eintrag.fahrer.nachname} {eintrag.fahrer.vorname}",
|
||||||
|
status=match.status.value,
|
||||||
|
fahrer_id=match.fahrer.id if match.fahrer else None,
|
||||||
|
vorschlaege=[v.__dict__ for v in match.vorschlaege],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if match.status == MatchStatus.NEU:
|
||||||
|
bericht.neue_fahrer += 1
|
||||||
|
klasse = finde_klasse(db, eintrag.klasse, saison_jahr)
|
||||||
|
if klasse is None:
|
||||||
|
bericht.warnungen.append(
|
||||||
|
f"Startnr. {eintrag.startnummer}: Klasse {eintrag.klasse!r} unbekannt"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if dry_run:
|
||||||
|
bericht.angelegt += 1
|
||||||
|
continue
|
||||||
|
wk = _wertungsklasse(db, turnier_id, eintrag.wertungsklasse)
|
||||||
|
start = Start(
|
||||||
|
turnier_id=turnier_id,
|
||||||
|
fahrer_id=match.fahrer.id,
|
||||||
|
klasse_id=klasse.id,
|
||||||
|
wertungsklasse_id=wk.id if wk else None,
|
||||||
|
startnummer=eintrag.startnummer,
|
||||||
|
status=StartStatus.GESTARTET,
|
||||||
|
)
|
||||||
|
db.add(start)
|
||||||
|
db.flush()
|
||||||
|
bericht.angelegt += 1
|
||||||
|
else:
|
||||||
|
bericht.aktualisiert += 1
|
||||||
|
if eintrag.wertungsklasse and not dry_run:
|
||||||
|
wk = _wertungsklasse(db, turnier_id, eintrag.wertungsklasse)
|
||||||
|
start.wertungsklasse_id = wk.id if wk else start.wertungsklasse_id
|
||||||
|
if start.status == StartStatus.GENANNT and not dry_run:
|
||||||
|
start.status = StartStatus.GESTARTET
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
continue
|
||||||
|
|
||||||
|
erg = start.ergebnis or Ergebnis(start_id=start.id)
|
||||||
|
if start.ergebnis is None:
|
||||||
|
start.ergebnis = erg
|
||||||
|
db.add(erg)
|
||||||
|
if eintrag.summe_zeit is not None:
|
||||||
|
erg.summe_zeit = eintrag.summe_zeit
|
||||||
|
erg.zeit_vollstaendig = eintrag.zeit_vollstaendig
|
||||||
|
if eintrag.summe_fehler is not None:
|
||||||
|
erg.summe_fehler = eintrag.summe_fehler
|
||||||
|
erg.fehler_vollstaendig = eintrag.fehler_vollstaendig
|
||||||
|
erg.erfasst_von = angelegt_von
|
||||||
|
# Erwartungswerte fuer die Nachrechnung ablegen (werden von der Engine
|
||||||
|
# gegen die Berechnung gestellt, nicht als Wahrheit uebernommen).
|
||||||
|
if eintrag.gesamt_erwartet is not None:
|
||||||
|
erg.gesamt = eintrag.gesamt_erwartet
|
||||||
|
if eintrag.platzierung_erwartet is not None:
|
||||||
|
erg.platzierung = eintrag.platzierung_erwartet
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
db.rollback()
|
||||||
|
return bericht
|
||||||
|
|
||||||
|
# Nachrechnen + Abweichungen melden
|
||||||
|
auswertung = turnier_auswerten(db, turnier_id, zeit_aus_messungen=False, schreiben=True)
|
||||||
|
for a in auswertung.abweichungen:
|
||||||
|
bericht.abweichungen.append(
|
||||||
|
AbweichungInfo(
|
||||||
|
startnummer=a.startnummer, feld=a.feld, erwartet=a.erwartet, berechnet=a.berechnet
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if auswertung.abweichungen:
|
||||||
|
bericht.warnungen.append(
|
||||||
|
f"{len(auswertung.abweichungen)} Abweichung(en) zwischen Import und Neuberechnung"
|
||||||
|
)
|
||||||
|
|
||||||
|
job = ImportJob(
|
||||||
|
quelle=payload.quelle,
|
||||||
|
idempotenz_schluessel=payload.idempotenz_schluessel,
|
||||||
|
roh_payload=payload.model_dump(mode="json"),
|
||||||
|
ergebnis_bericht=bericht.model_dump(mode="json"),
|
||||||
|
status=ImportStatus.ANGENOMMEN if not bericht.warnungen else ImportStatus.TEILWEISE,
|
||||||
|
angelegt_von=angelegt_von,
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.flush()
|
||||||
|
bericht.import_job_id = job.id
|
||||||
|
return bericht
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""Excel-Import fuer Nennlisten/Ergebnislisten (AP 4 / AP 8).
|
||||||
|
|
||||||
|
Die realen ``.xlsx``-Vorlagen liegen noch nicht vor (Plan O-12). Dieser Leser
|
||||||
|
arbeitet spaltenbasiert ueber eine **konfigurierbare Kopfzeilen-Zuordnung** und
|
||||||
|
kann sowohl als Nennliste (-> StartBulk) als auch als Ergebnisliste
|
||||||
|
(-> ErgebnisBulk) interpretiert werden. Sobald die echten Vorlagen da sind, ist
|
||||||
|
nur ``SPALTEN_ALIAS`` anzupassen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
from oamc.schemas.ingest import (
|
||||||
|
ErgebnisBulk,
|
||||||
|
ErgebnisEintrag,
|
||||||
|
FahrerRef,
|
||||||
|
StartBulk,
|
||||||
|
StartCreate,
|
||||||
|
)
|
||||||
|
|
||||||
|
# moegliche Kopfzeilen-Bezeichnungen -> internes Feld
|
||||||
|
SPALTEN_ALIAS: dict[str, str] = {
|
||||||
|
"start-nr.": "startnummer",
|
||||||
|
"startnr": "startnummer",
|
||||||
|
"startnummer": "startnummer",
|
||||||
|
"voller name": "voller_name",
|
||||||
|
"name": "voller_name",
|
||||||
|
"nachname": "nachname",
|
||||||
|
"vorname": "vorname",
|
||||||
|
"regionalclub": "verein",
|
||||||
|
"verein": "verein",
|
||||||
|
"club": "verein",
|
||||||
|
"klasse": "klasse",
|
||||||
|
"wertungsklasse": "wertungsklasse",
|
||||||
|
"geschlecht": "geschlecht",
|
||||||
|
"geburtsjahr": "geburtsjahr",
|
||||||
|
"jahrgang": "geburtsjahr",
|
||||||
|
"fahrzeug": "fahrzeug",
|
||||||
|
"summe zeit": "summe_zeit",
|
||||||
|
"zeit": "summe_zeit",
|
||||||
|
"summe fehler": "summe_fehler",
|
||||||
|
"fehler": "summe_fehler",
|
||||||
|
"gesamt": "gesamt_erwartet",
|
||||||
|
"platzierung": "platzierung_erwartet",
|
||||||
|
"platz": "platzierung_erwartet",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _num(wert) -> Decimal | None:
|
||||||
|
if wert in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Decimal(str(wert).replace(",", "."))
|
||||||
|
except InvalidOperation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _int(wert) -> int | None:
|
||||||
|
d = _num(wert)
|
||||||
|
return int(d) if d is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _split_name(zeile: dict) -> tuple[str, str]:
|
||||||
|
if zeile.get("nachname") or zeile.get("vorname"):
|
||||||
|
return str(zeile.get("nachname", "")).strip(), str(zeile.get("vorname", "")).strip()
|
||||||
|
voll = str(zeile.get("voller_name", "")).strip()
|
||||||
|
# Ergebnisliste fuehrt "Nachname Vorname" (Plan §3.3)
|
||||||
|
teile = voll.split()
|
||||||
|
if len(teile) >= 2:
|
||||||
|
return teile[0], " ".join(teile[1:])
|
||||||
|
return voll, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _zeilen(daten: bytes, blatt: str | None):
|
||||||
|
wb = load_workbook(io.BytesIO(daten), read_only=True, data_only=True)
|
||||||
|
ws = wb[blatt] if blatt else wb.active
|
||||||
|
rows = ws.iter_rows(values_only=True)
|
||||||
|
kopf_roh = next(rows)
|
||||||
|
kopf = [
|
||||||
|
SPALTEN_ALIAS.get(str(c).strip().lower()) if c is not None else None for c in kopf_roh
|
||||||
|
]
|
||||||
|
for row in rows:
|
||||||
|
if row is None or all(c in (None, "") for c in row):
|
||||||
|
continue
|
||||||
|
yield {feld: row[i] for i, feld in enumerate(kopf) if feld and i < len(row)}
|
||||||
|
|
||||||
|
|
||||||
|
def lese_nennliste(daten: bytes, *, quelle: str, idempotenz_schluessel: str | None,
|
||||||
|
blatt: str | None = None) -> StartBulk:
|
||||||
|
starts: list[StartCreate] = []
|
||||||
|
for z in _zeilen(daten, blatt):
|
||||||
|
nummer = _int(z.get("startnummer"))
|
||||||
|
if nummer is None:
|
||||||
|
continue
|
||||||
|
nach, vor = _split_name(z)
|
||||||
|
starts.append(
|
||||||
|
StartCreate(
|
||||||
|
startnummer=nummer,
|
||||||
|
fahrer=FahrerRef(
|
||||||
|
nachname=nach, vorname=vor,
|
||||||
|
geburtsjahr=_int(z.get("geburtsjahr")),
|
||||||
|
verein=(str(z["verein"]).strip() if z.get("verein") else None),
|
||||||
|
),
|
||||||
|
klasse=str(z.get("klasse", "")).strip(),
|
||||||
|
wertungsklasse=(str(z["wertungsklasse"]).strip() if z.get("wertungsklasse") else None),
|
||||||
|
fahrzeug=(str(z["fahrzeug"]).strip() if z.get("fahrzeug") else None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return StartBulk(quelle=quelle, idempotenz_schluessel=idempotenz_schluessel, starts=starts)
|
||||||
|
|
||||||
|
|
||||||
|
def lese_ergebnisliste(daten: bytes, *, quelle: str, idempotenz_schluessel: str,
|
||||||
|
blatt: str | None = None) -> ErgebnisBulk:
|
||||||
|
eintraege: list[ErgebnisEintrag] = []
|
||||||
|
for z in _zeilen(daten, blatt):
|
||||||
|
nummer = _int(z.get("startnummer"))
|
||||||
|
if nummer is None:
|
||||||
|
continue
|
||||||
|
nach, vor = _split_name(z)
|
||||||
|
zeit = _num(z.get("summe_zeit"))
|
||||||
|
fehler = _int(z.get("summe_fehler"))
|
||||||
|
if zeit is None and fehler is None:
|
||||||
|
continue
|
||||||
|
eintraege.append(
|
||||||
|
ErgebnisEintrag(
|
||||||
|
startnummer=nummer,
|
||||||
|
fahrer=FahrerRef(
|
||||||
|
nachname=nach, vorname=vor,
|
||||||
|
geburtsjahr=_int(z.get("geburtsjahr")),
|
||||||
|
verein=(str(z["verein"]).strip() if z.get("verein") else None),
|
||||||
|
),
|
||||||
|
klasse=(str(z["klasse"]).strip() if z.get("klasse") else None),
|
||||||
|
wertungsklasse=(str(z["wertungsklasse"]).strip() if z.get("wertungsklasse") else None),
|
||||||
|
summe_zeit=zeit,
|
||||||
|
summe_fehler=fehler,
|
||||||
|
gesamt_erwartet=_num(z.get("gesamt_erwartet")),
|
||||||
|
platzierung_erwartet=_int(z.get("platzierung_erwartet")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ErgebnisBulk(
|
||||||
|
quelle=quelle, idempotenz_schluessel=idempotenz_schluessel, ergebnisse=eintraege
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""FastAPI-Anwendung. Wird von uvicorn hinter nginx betrieben (Plan §5.2/§5.3)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from oamc import __version__
|
||||||
|
from oamc.api.v1 import api_v1
|
||||||
|
from oamc.config import get_settings
|
||||||
|
from oamc.frontend import intern as frontend_intern
|
||||||
|
from oamc.frontend import public as frontend_public
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||||
|
|
||||||
|
_BESCHREIBUNG = """
|
||||||
|
Auswertung von ADAC Motorrad-Turnieren des OAMC Reinheim.
|
||||||
|
|
||||||
|
* **Read** (`GET /api/v1/...`) — oeffentlich, ohne Key
|
||||||
|
* **Ingest** (`POST /api/v1/...`) — Header `X-API-Key`
|
||||||
|
* **Geraete** (`/api/v1/geraete/...`, `/api/v1/zeitmessungen`) — `Authorization: Bearer <geraete-token>`
|
||||||
|
* **Admin** (`/api/v1/admin/...`) — interne Session
|
||||||
|
|
||||||
|
Fachliche Grundlagen: `plan.md`. Wertungsregeln sind aus PDFs abgeleitet und vor
|
||||||
|
Produktivgang gegen die ADAC-Turnierordnung 2026 zu verifizieren (Plan §10).
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
settings = get_settings()
|
||||||
|
app = FastAPI(
|
||||||
|
title="OAMC Turnierauswertung",
|
||||||
|
version=__version__,
|
||||||
|
description=_BESCHREIBUNG,
|
||||||
|
docs_url="/docs",
|
||||||
|
redoc_url=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
|
app.include_router(api_v1)
|
||||||
|
app.include_router(frontend_public.router)
|
||||||
|
app.include_router(frontend_intern.router)
|
||||||
|
|
||||||
|
@app.get("/healthz", include_in_schema=False)
|
||||||
|
def healthz() -> JSONResponse:
|
||||||
|
return JSONResponse({"status": "ok", "version": __version__, "env": settings.env})
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Fahrer-Matching beim Import (Plan §6.4) — der heikelste Teil.
|
||||||
|
|
||||||
|
1. Exakter Match auf (Nachname, Vorname, Geburtsjahr), normalisiert.
|
||||||
|
2. Unscharfer Match (pg_trgm) -> Vorschlag, nie automatisch.
|
||||||
|
3. Kein Match -> neuer Fahrer, im Report als "NEU" markiert.
|
||||||
|
4. Merge-Funktion fuer nachtraeglich erkannte Dubletten: ``fahrer_zusammenfuehren``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from oamc.matching.fahrer import (
|
||||||
|
MatchErgebnis,
|
||||||
|
MatchStatus,
|
||||||
|
fahrer_zusammenfuehren,
|
||||||
|
finde_aehnliche,
|
||||||
|
finde_exakt,
|
||||||
|
match_oder_neu,
|
||||||
|
normalisiere,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MatchErgebnis",
|
||||||
|
"MatchStatus",
|
||||||
|
"normalisiere",
|
||||||
|
"finde_exakt",
|
||||||
|
"finde_aehnliche",
|
||||||
|
"match_oder_neu",
|
||||||
|
"fahrer_zusammenfuehren",
|
||||||
|
]
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Normalisierung, exakter/unscharfer Match, Merge."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import unicodedata
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from sqlalchemy import func, select, text, update
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from oamc.models import Fahrer, Start
|
||||||
|
|
||||||
|
|
||||||
|
def normalisiere(wert: str | None) -> str:
|
||||||
|
"""Unicode NFKD, Kleinschreibung, Bindestriche/Mehrfach-Whitespace weg.
|
||||||
|
|
||||||
|
"Gál-Szász" -> "gal szasz". So werden Namensvarianten beim Import
|
||||||
|
zusammengefuehrt, ohne Information im Datenbestand zu verlieren.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not wert:
|
||||||
|
return ""
|
||||||
|
zerlegt = unicodedata.normalize("NFKD", wert)
|
||||||
|
ohne_akzente = "".join(c for c in zerlegt if not unicodedata.combining(c))
|
||||||
|
ersetzt = ohne_akzente.replace("-", " ").replace(".", " ")
|
||||||
|
return " ".join(ersetzt.lower().split())
|
||||||
|
|
||||||
|
|
||||||
|
class MatchStatus(str, enum.Enum):
|
||||||
|
EXAKT = "exakt"
|
||||||
|
UNSCHARF = "unscharf" # Vorschlag — Bestaetigung noetig
|
||||||
|
NEU = "neu"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class KandidatVorschlag:
|
||||||
|
fahrer_id: int
|
||||||
|
voller_name: str
|
||||||
|
geburtsjahr: int | None
|
||||||
|
verein: str | None
|
||||||
|
aehnlichkeit: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatchErgebnis:
|
||||||
|
status: MatchStatus
|
||||||
|
fahrer: Fahrer | None = None
|
||||||
|
vorschlaege: list[KandidatVorschlag] = field(default_factory=list)
|
||||||
|
neu_angelegt: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def finde_exakt(
|
||||||
|
db: Session, nachname: str, vorname: str, geburtsjahr: int | None
|
||||||
|
) -> Fahrer | None:
|
||||||
|
n, v = normalisiere(nachname), normalisiere(vorname)
|
||||||
|
# Vorfilter grob ueber die ersten Namensbuchstaben, exakter Vergleich dann in
|
||||||
|
# Python (Normalisierung deckt Akzente/Bindestriche ab; keine DB-Extension noetig).
|
||||||
|
praefix = normalisiere(nachname)[:3]
|
||||||
|
kandidaten = db.scalars(
|
||||||
|
select(Fahrer).where(func.lower(Fahrer.nachname).like(f"{praefix}%"))
|
||||||
|
).all()
|
||||||
|
if not kandidaten:
|
||||||
|
kandidaten = db.scalars(select(Fahrer)).all()
|
||||||
|
for f in kandidaten:
|
||||||
|
if (
|
||||||
|
normalisiere(f.nachname) == n
|
||||||
|
and normalisiere(f.vorname) == v
|
||||||
|
and (geburtsjahr is None or f.geburtsjahr is None or f.geburtsjahr == geburtsjahr)
|
||||||
|
):
|
||||||
|
return f
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def finde_aehnliche(
|
||||||
|
db: Session,
|
||||||
|
nachname: str,
|
||||||
|
vorname: str,
|
||||||
|
*,
|
||||||
|
limit: int = 5,
|
||||||
|
schwelle: float = 0.35,
|
||||||
|
) -> list[KandidatVorschlag]:
|
||||||
|
"""Trigramm-Aehnlichkeit auf ``lower(nachname || ' ' || vorname)`` (GIN-Index)."""
|
||||||
|
|
||||||
|
suchbegriff = f"{nachname} {vorname}".lower().strip()
|
||||||
|
voller_name = func.lower(func.concat(Fahrer.nachname, " ", Fahrer.vorname))
|
||||||
|
sim = func.similarity(voller_name, suchbegriff)
|
||||||
|
stmt = (
|
||||||
|
select(Fahrer, sim.label("sim"))
|
||||||
|
.where(sim >= schwelle)
|
||||||
|
.order_by(text("sim DESC"))
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
treffer = db.execute(stmt).all()
|
||||||
|
return [
|
||||||
|
KandidatVorschlag(
|
||||||
|
fahrer_id=f.id,
|
||||||
|
voller_name=f.voller_name,
|
||||||
|
geburtsjahr=f.geburtsjahr,
|
||||||
|
verein=f.verein.name if f.verein else None,
|
||||||
|
aehnlichkeit=round(float(sim), 3),
|
||||||
|
)
|
||||||
|
for f, sim in treffer
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def match_oder_neu(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
nachname: str,
|
||||||
|
vorname: str,
|
||||||
|
geburtsjahr: int | None = None,
|
||||||
|
geschlecht=None,
|
||||||
|
verein_id: int | None = None,
|
||||||
|
anlegen: bool = True,
|
||||||
|
) -> MatchErgebnis:
|
||||||
|
exakt = finde_exakt(db, nachname, vorname, geburtsjahr)
|
||||||
|
if exakt is not None:
|
||||||
|
return MatchErgebnis(status=MatchStatus.EXAKT, fahrer=exakt)
|
||||||
|
|
||||||
|
vorschlaege = finde_aehnliche(db, nachname, vorname)
|
||||||
|
if vorschlaege and not anlegen:
|
||||||
|
return MatchErgebnis(status=MatchStatus.UNSCHARF, vorschlaege=vorschlaege)
|
||||||
|
|
||||||
|
if not anlegen:
|
||||||
|
return MatchErgebnis(status=MatchStatus.NEU)
|
||||||
|
|
||||||
|
neu = Fahrer(
|
||||||
|
nachname=nachname.strip(),
|
||||||
|
vorname=vorname.strip(),
|
||||||
|
geburtsjahr=geburtsjahr,
|
||||||
|
geschlecht=geschlecht,
|
||||||
|
verein_id=verein_id,
|
||||||
|
)
|
||||||
|
db.add(neu)
|
||||||
|
db.flush()
|
||||||
|
return MatchErgebnis(
|
||||||
|
status=MatchStatus.NEU, fahrer=neu, vorschlaege=vorschlaege, neu_angelegt=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fahrer_zusammenfuehren(db: Session, ziel_id: int, quelle_id: int) -> int:
|
||||||
|
"""Alle Starts von ``quelle_id`` auf ``ziel_id`` umhaengen, Quelle deaktivieren.
|
||||||
|
|
||||||
|
Rueckgabe: Anzahl umgehaengter Starts. Bewusst kein hartes DELETE — die Quelle
|
||||||
|
bleibt als inaktiver Datensatz mit Verweis erhalten (Nachvollziehbarkeit).
|
||||||
|
"""
|
||||||
|
|
||||||
|
if ziel_id == quelle_id:
|
||||||
|
raise ValueError("Ziel und Quelle sind identisch")
|
||||||
|
ziel = db.get(Fahrer, ziel_id)
|
||||||
|
quelle = db.get(Fahrer, quelle_id)
|
||||||
|
if ziel is None or quelle is None:
|
||||||
|
raise ValueError("Ziel- oder Quell-Fahrer nicht gefunden")
|
||||||
|
|
||||||
|
anzahl = db.execute(
|
||||||
|
update(Start).where(Start.fahrer_id == quelle_id).values(fahrer_id=ziel_id)
|
||||||
|
).rowcount
|
||||||
|
quelle.aktiv = False
|
||||||
|
quelle.notiz = ((quelle.notiz or "") + f"\n[merge] zusammengefuehrt nach Fahrer {ziel_id}").strip()
|
||||||
|
db.flush()
|
||||||
|
return anzahl or 0
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Meisterschafts-/Pokalwertung (HTH) — AP 3.
|
||||||
|
|
||||||
|
BLOCKIERT durch offene Frage O-1: die echte Punktetabelle (Platz -> Punkte) ist
|
||||||
|
noch nicht bekannt. Bis dahin arbeitet dieses Modul mit
|
||||||
|
``PLATZHALTER_PUNKTETABELLE`` (klar als solche markiert) bzw. mit der Tabelle aus
|
||||||
|
``meisterschaft.regelwerk['punkte']``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from oamc.meisterschaft.berechnung import (
|
||||||
|
PLATZHALTER_PUNKTETABELLE,
|
||||||
|
StandZeile,
|
||||||
|
berechne_stand,
|
||||||
|
endlauf_qualifikation,
|
||||||
|
punkte_fuer_platz,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PLATZHALTER_PUNKTETABELLE",
|
||||||
|
"StandZeile",
|
||||||
|
"berechne_stand",
|
||||||
|
"endlauf_qualifikation",
|
||||||
|
"punkte_fuer_platz",
|
||||||
|
]
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""Meisterschaftsstand, Streichresultate, Tie-Break, Endlaufqualifikation.
|
||||||
|
|
||||||
|
Regeln (Plan §3.4):
|
||||||
|
* Erwachsene: ein Gesamtklassement A + S (klassenuebergreifend).
|
||||||
|
* Jugend: Pokalwertung klassenweise J 1 - J 5.
|
||||||
|
* Streichresultate nach Tabelle in ``saison.streichresultate_tabelle``
|
||||||
|
("Turniere in der Saison" -> "davon gewertet").
|
||||||
|
* Nur Laeufe mit ``turnier.zaehlt_meisterschaft``.
|
||||||
|
* Tie-Break bei Punktgleichheit: Majoritaet der Siege.
|
||||||
|
* Endlauf: die besten 10 Erwachsenen bzw. 10 Jugendlichen — die Anwendung
|
||||||
|
*schlaegt vor*, entscheidet nicht (Bereichsleiter).
|
||||||
|
|
||||||
|
Die Punktetabelle ist offene Frage **O-1**. ``PLATZHALTER_PUNKTETABELLE`` ist
|
||||||
|
NICHT die echte Tabelle und erzeugt bei Verwendung einen Hinweis im Bericht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from oamc.models import (
|
||||||
|
Ergebnis,
|
||||||
|
Klasse,
|
||||||
|
Meisterschaft,
|
||||||
|
Saison,
|
||||||
|
Start,
|
||||||
|
Turnier,
|
||||||
|
Veranstaltung,
|
||||||
|
)
|
||||||
|
from oamc.models.enums import Kategorie, StartStatus
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PLATZHALTER — bis O-1 geklaert ist. Monoton fallend, Platz 1 = 25 Punkte,
|
||||||
|
# ab Platz 15 ein Punkt. Frei erfunden, nur damit AP 3 test- und lauffaehig ist.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
PLATZHALTER_PUNKTETABELLE: dict[int, int] = {
|
||||||
|
1: 25, 2: 20, 3: 16, 4: 13, 5: 11, 6: 10, 7: 9, 8: 8,
|
||||||
|
9: 7, 10: 6, 11: 5, 12: 4, 13: 3, 14: 2,
|
||||||
|
}
|
||||||
|
PLATZHALTER_PUNKTE_AB_15 = 1
|
||||||
|
|
||||||
|
|
||||||
|
def punkte_fuer_platz(platz: int | None, tabelle: dict[int, int] | None = None) -> Decimal:
|
||||||
|
if platz is None or platz < 1:
|
||||||
|
return Decimal(0)
|
||||||
|
tab = tabelle or PLATZHALTER_PUNKTETABELLE
|
||||||
|
if platz in tab:
|
||||||
|
return Decimal(tab[platz])
|
||||||
|
# unterhalb der Tabelle: kleinster definierter Wert bzw. Platzhalter
|
||||||
|
if tabelle:
|
||||||
|
return Decimal(min(tabelle.values())) if tabelle else Decimal(0)
|
||||||
|
return Decimal(PLATZHALTER_PUNKTE_AB_15)
|
||||||
|
|
||||||
|
|
||||||
|
def gewertete_laeufe(anzahl_turniere: int, tabelle: dict[str, int] | None) -> int:
|
||||||
|
"""Wie viele der ``anzahl_turniere`` zaehlen (Rest = Streichresultate)."""
|
||||||
|
|
||||||
|
if not tabelle:
|
||||||
|
return anzahl_turniere
|
||||||
|
# exakter Eintrag?
|
||||||
|
key = str(anzahl_turniere)
|
||||||
|
if key in tabelle:
|
||||||
|
return int(tabelle[key])
|
||||||
|
# sonst: naechstkleinerer definierter Schluessel
|
||||||
|
kleinere = sorted((int(k) for k in tabelle), reverse=True)
|
||||||
|
for k in kleinere:
|
||||||
|
if k <= anzahl_turniere:
|
||||||
|
return int(tabelle[str(k)])
|
||||||
|
return anzahl_turniere
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StandZeile:
|
||||||
|
fahrer_id: int
|
||||||
|
punkte_gesamt: Decimal
|
||||||
|
punkte_je_lauf: dict[int, Decimal] # turnier_id -> Punkte (nach Streichung)
|
||||||
|
gestrichene_laeufe: list[int]
|
||||||
|
anzahl_siege: int
|
||||||
|
platz: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MeisterschaftsBericht:
|
||||||
|
meisterschaft_id: int
|
||||||
|
saison_jahr: int
|
||||||
|
kategorie: str
|
||||||
|
laeufe_gesamt: int
|
||||||
|
laeufe_gewertet: int
|
||||||
|
zeilen: list[StandZeile]
|
||||||
|
hinweise: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _relevante_starts(db: Session, m: Meisterschaft, turnier_ids: list[int]) -> list[Start]:
|
||||||
|
stmt = (
|
||||||
|
select(Start)
|
||||||
|
.join(Ergebnis, Ergebnis.start_id == Start.id)
|
||||||
|
.join(Klasse, Klasse.id == Start.klasse_id)
|
||||||
|
.where(
|
||||||
|
Start.turnier_id.in_(turnier_ids),
|
||||||
|
Ergebnis.platzierung.is_not(None),
|
||||||
|
Start.status == StartStatus.GESTARTET,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if m.kategorie == Kategorie.JUGEND:
|
||||||
|
if m.klasse_id is not None:
|
||||||
|
stmt = stmt.where(Start.klasse_id == m.klasse_id)
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(Klasse.kategorie == Kategorie.JUGEND)
|
||||||
|
else:
|
||||||
|
# Erwachsene: A + S zusammen (O-3: zaehlen A-Fahrer mit? -> annehmen: ja)
|
||||||
|
stmt = stmt.where(Klasse.kategorie.in_([Kategorie.STAMM, Kategorie.ANFAENGER]))
|
||||||
|
return list(db.scalars(stmt))
|
||||||
|
|
||||||
|
|
||||||
|
def berechne_stand(db: Session, meisterschaft_id: int) -> MeisterschaftsBericht:
|
||||||
|
m = db.get(Meisterschaft, meisterschaft_id)
|
||||||
|
if m is None:
|
||||||
|
raise ValueError(f"Meisterschaft {meisterschaft_id} nicht gefunden")
|
||||||
|
saison = db.get(Saison, m.saison_jahr)
|
||||||
|
regelwerk = m.regelwerk or {}
|
||||||
|
punkte_tab_raw = regelwerk.get("punkte")
|
||||||
|
punkte_tab = {int(k): int(v) for k, v in punkte_tab_raw.items()} if punkte_tab_raw else None
|
||||||
|
streich_tab = (saison.streichresultate_tabelle if saison else None) or regelwerk.get("streich")
|
||||||
|
|
||||||
|
hinweise: list[str] = []
|
||||||
|
if punkte_tab is None:
|
||||||
|
hinweise.append(
|
||||||
|
"O-1 offen: PLATZHALTER-Punktetabelle verwendet — nicht fuer offizielle Staende!"
|
||||||
|
)
|
||||||
|
if regelwerk.get("_hinweis"):
|
||||||
|
hinweise.append(str(regelwerk["_hinweis"]))
|
||||||
|
|
||||||
|
turniere = list(
|
||||||
|
db.scalars(
|
||||||
|
select(Turnier)
|
||||||
|
.join(Veranstaltung, Veranstaltung.id == Turnier.veranstaltung_id)
|
||||||
|
.where(
|
||||||
|
Veranstaltung.datum.is_not(None),
|
||||||
|
Turnier.zaehlt_meisterschaft.is_(True),
|
||||||
|
Veranstaltung.status.in_(["vorlaeufig", "final"]),
|
||||||
|
)
|
||||||
|
.order_by(Veranstaltung.datum, Turnier.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# nur Turniere der Saison der Meisterschaft
|
||||||
|
turniere = [t for t in turniere if t.veranstaltung.datum.year == m.saison_jahr]
|
||||||
|
turnier_ids = [t.id for t in turniere]
|
||||||
|
|
||||||
|
roh: dict[int, dict[int, Decimal]] = {} # fahrer_id -> {turnier_id: punkte}
|
||||||
|
siege: dict[int, int] = {}
|
||||||
|
if turnier_ids:
|
||||||
|
for s in _relevante_starts(db, m, turnier_ids):
|
||||||
|
p = punkte_fuer_platz(s.ergebnis.platzierung, punkte_tab)
|
||||||
|
bucket = roh.setdefault(s.fahrer_id, {})
|
||||||
|
# Mehrfachstart in derselben Kategorie am selben Tag: bestes Ergebnis zaehlt
|
||||||
|
bucket[s.turnier_id] = max(bucket.get(s.turnier_id, Decimal(0)), p)
|
||||||
|
if s.ergebnis.platzierung == 1:
|
||||||
|
siege[s.fahrer_id] = siege.get(s.fahrer_id, 0) + 1
|
||||||
|
|
||||||
|
n_gewertet = gewertete_laeufe(len(turnier_ids), streich_tab)
|
||||||
|
|
||||||
|
zeilen: list[StandZeile] = []
|
||||||
|
for fahrer_id, je_lauf in roh.items():
|
||||||
|
sortiert = sorted(je_lauf.items(), key=lambda kv: kv[1], reverse=True)
|
||||||
|
behalten = dict(sortiert[:n_gewertet])
|
||||||
|
gestrichen = [tid for tid, _ in sortiert[n_gewertet:]]
|
||||||
|
zeilen.append(
|
||||||
|
StandZeile(
|
||||||
|
fahrer_id=fahrer_id,
|
||||||
|
punkte_gesamt=sum(behalten.values(), Decimal(0)),
|
||||||
|
punkte_je_lauf=behalten,
|
||||||
|
gestrichene_laeufe=gestrichen,
|
||||||
|
anzahl_siege=siege.get(fahrer_id, 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
zeilen.sort(key=lambda z: (-z.punkte_gesamt, -z.anzahl_siege, z.fahrer_id))
|
||||||
|
for i, z in enumerate(zeilen, start=1):
|
||||||
|
z.platz = i
|
||||||
|
|
||||||
|
return MeisterschaftsBericht(
|
||||||
|
meisterschaft_id=meisterschaft_id,
|
||||||
|
saison_jahr=m.saison_jahr,
|
||||||
|
kategorie=m.kategorie.value,
|
||||||
|
laeufe_gesamt=len(turnier_ids),
|
||||||
|
laeufe_gewertet=n_gewertet,
|
||||||
|
zeilen=zeilen,
|
||||||
|
hinweise=hinweise,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QualiVorschlag:
|
||||||
|
fahrer_id: int
|
||||||
|
platz: int
|
||||||
|
punkte: Decimal
|
||||||
|
anzahl_siege: int
|
||||||
|
tie_break_relevant: bool
|
||||||
|
|
||||||
|
|
||||||
|
def endlauf_qualifikation(
|
||||||
|
db: Session, meisterschaft_id: int, plaetze: int = 10
|
||||||
|
) -> list[QualiVorschlag]:
|
||||||
|
"""Vorschlag Top-N fuer den Endlauf. Bei Punktgleichheit an der Grenze wird
|
||||||
|
``tie_break_relevant`` gesetzt — die endgueltige Festlegung trifft der
|
||||||
|
Bereichsleiter (Plan §3.4)."""
|
||||||
|
|
||||||
|
bericht = berechne_stand(db, meisterschaft_id)
|
||||||
|
zeilen = bericht.zeilen[: plaetze + 5] # etwas Puffer fuer die Grenzbetrachtung
|
||||||
|
vorschlaege: list[QualiVorschlag] = []
|
||||||
|
grenz_punkte = bericht.zeilen[plaetze - 1].punkte_gesamt if len(bericht.zeilen) >= plaetze else None
|
||||||
|
for z in zeilen:
|
||||||
|
vorschlaege.append(
|
||||||
|
QualiVorschlag(
|
||||||
|
fahrer_id=z.fahrer_id,
|
||||||
|
platz=z.platz or 0,
|
||||||
|
punkte=z.punkte_gesamt,
|
||||||
|
anzahl_siege=z.anzahl_siege,
|
||||||
|
tie_break_relevant=grenz_punkte is not None and z.punkte_gesamt == grenz_punkte,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return vorschlaege
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Alle ORM-Modelle. Import hier zentral, damit Alembic-Autogenerate sie sieht."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from oamc.models.audit import ImportJob
|
||||||
|
from oamc.models.base import Base
|
||||||
|
from oamc.models.benutzer import Benutzer
|
||||||
|
from oamc.models.enums import (
|
||||||
|
BenutzerRolle,
|
||||||
|
GeraetTyp,
|
||||||
|
Geschlecht,
|
||||||
|
ImportStatus,
|
||||||
|
Kategorie,
|
||||||
|
StartStatus,
|
||||||
|
TurnierStatus,
|
||||||
|
VeranstaltungStatus,
|
||||||
|
ZeitmessungStatus,
|
||||||
|
)
|
||||||
|
from oamc.models.fahrer import Fahrer
|
||||||
|
from oamc.models.klasse import Klasse
|
||||||
|
from oamc.models.meisterschaft import Meisterschaft, MeisterschaftStand
|
||||||
|
from oamc.models.organisation import Saison, Verein
|
||||||
|
from oamc.models.start import Ergebnis, Start
|
||||||
|
from oamc.models.veranstaltung import Turnier, Veranstaltung, Wertungsklasse
|
||||||
|
from oamc.models.zeitmessung import Geraet, Zeitmessung
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"Verein",
|
||||||
|
"Saison",
|
||||||
|
"Klasse",
|
||||||
|
"Fahrer",
|
||||||
|
"Veranstaltung",
|
||||||
|
"Turnier",
|
||||||
|
"Wertungsklasse",
|
||||||
|
"Start",
|
||||||
|
"Ergebnis",
|
||||||
|
"Geraet",
|
||||||
|
"Zeitmessung",
|
||||||
|
"Meisterschaft",
|
||||||
|
"MeisterschaftStand",
|
||||||
|
"ImportJob",
|
||||||
|
"Benutzer",
|
||||||
|
"Kategorie",
|
||||||
|
"Geschlecht",
|
||||||
|
"VeranstaltungStatus",
|
||||||
|
"TurnierStatus",
|
||||||
|
"StartStatus",
|
||||||
|
"GeraetTyp",
|
||||||
|
"ZeitmessungStatus",
|
||||||
|
"ImportStatus",
|
||||||
|
"BenutzerRolle",
|
||||||
|
]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Import-Audit-Trail (Plan §4, §6.1).
|
||||||
|
|
||||||
|
Jeder API-/Datei-Import landet hier mit Roh-Payload — bei Streit ueber eine
|
||||||
|
Platzierung ist rekonstruierbar, was wann ankam.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from oamc.models.base import Base, sa_enum
|
||||||
|
from oamc.models.enums import ImportStatus
|
||||||
|
|
||||||
|
|
||||||
|
class ImportJob(Base):
|
||||||
|
__tablename__ = "import_job"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
quelle: Mapped[str] = mapped_column(String(120), nullable=False) # "excel-auswertung-v3"
|
||||||
|
idempotenz_schluessel: Mapped[str | None] = mapped_column(String(200), index=True)
|
||||||
|
dateiname: Mapped[str | None] = mapped_column(String(300))
|
||||||
|
roh_payload: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
ergebnis_bericht: Mapped[dict | None] = mapped_column(JSONB) # neue/gematchte Fahrer, Warnungen
|
||||||
|
status: Mapped[ImportStatus] = mapped_column(
|
||||||
|
sa_enum(ImportStatus, "import_status"), nullable=False
|
||||||
|
)
|
||||||
|
fehler: Mapped[str | None] = mapped_column(Text)
|
||||||
|
angelegt_von: Mapped[str | None] = mapped_column(String(120))
|
||||||
|
angelegt_am: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Deklarative Basis und gemeinsame Mixins."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, MetaData, func
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
def sa_enum(py_enum: type, name: str) -> SAEnum:
|
||||||
|
"""Postgres-Enum, das die ``value`` der Python-Enum-Member speichert (nicht den
|
||||||
|
Namen) — lesbare Dumps, stabile Migrationen."""
|
||||||
|
|
||||||
|
return SAEnum(py_enum, name=name, values_callable=lambda e: [m.value for m in e])
|
||||||
|
|
||||||
|
# Feste Namenskonvention -> stabile Constraint-Namen in Alembic-Migrationen.
|
||||||
|
NAMING_CONVENTION = {
|
||||||
|
"ix": "ix_%(column_0_label)s",
|
||||||
|
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||||
|
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||||
|
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||||
|
"pk": "pk_%(table_name)s",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
"""``erstellt_am`` / ``geaendert_am`` — von der DB gepflegt."""
|
||||||
|
|
||||||
|
erstellt_am: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
geaendert_am: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Interne Benutzer (Login) — Admin / Auswerter / Lesend (Plan §4)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from oamc.models.base import Base, TimestampMixin, sa_enum
|
||||||
|
from oamc.models.enums import BenutzerRolle
|
||||||
|
|
||||||
|
|
||||||
|
class Benutzer(Base, TimestampMixin):
|
||||||
|
__tablename__ = "benutzer"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
benutzername: Mapped[str] = mapped_column(String(80), nullable=False, unique=True)
|
||||||
|
anzeigename: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
passwort_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
rolle: Mapped[BenutzerRolle] = mapped_column(
|
||||||
|
sa_enum(BenutzerRolle, "benutzer_rolle"), nullable=False, default=BenutzerRolle.LESEND
|
||||||
|
)
|
||||||
|
aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
letzter_login_am: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def darf_schreiben(self) -> bool:
|
||||||
|
return self.rolle in (BenutzerRolle.ADMIN, BenutzerRolle.AUSWERTER)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ist_admin(self) -> bool:
|
||||||
|
return self.rolle == BenutzerRolle.ADMIN
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Fachliche Aufzaehlungstypen.
|
||||||
|
|
||||||
|
Bewusst als String-Enums in der DB (lesbare Dumps, einfache Migration). Die
|
||||||
|
*Wertungsformel* je Kategorie steht NICHT hier fest verdrahtet, sondern wird in
|
||||||
|
``oamc.wertung.engine`` pro Kategorie/Saison aufgeloest (Plan §3.3).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class Kategorie(str, enum.Enum):
|
||||||
|
"""Grobklassierung eines Fahrers/Starts. Bestimmt die Default-Wertungsformel."""
|
||||||
|
|
||||||
|
JUGEND = "J"
|
||||||
|
STAMM = "S"
|
||||||
|
ANFAENGER = "A"
|
||||||
|
|
||||||
|
|
||||||
|
class Geschlecht(str, enum.Enum):
|
||||||
|
MAENNLICH = "M"
|
||||||
|
WEIBLICH = "W"
|
||||||
|
DIVERS = "D"
|
||||||
|
|
||||||
|
|
||||||
|
class VeranstaltungStatus(str, enum.Enum):
|
||||||
|
GEPLANT = "geplant"
|
||||||
|
LAUFEND = "laufend"
|
||||||
|
VORLAEUFIG = "vorlaeufig"
|
||||||
|
FINAL = "final"
|
||||||
|
|
||||||
|
|
||||||
|
class TurnierStatus(str, enum.Enum):
|
||||||
|
GEPLANT = "geplant"
|
||||||
|
LAUFEND = "laufend"
|
||||||
|
VORLAEUFIG = "vorlaeufig"
|
||||||
|
FINAL = "final"
|
||||||
|
|
||||||
|
|
||||||
|
class StartStatus(str, enum.Enum):
|
||||||
|
GENANNT = "genannt"
|
||||||
|
GESTARTET = "gestartet"
|
||||||
|
DNF = "DNF" # did not finish
|
||||||
|
DNS = "DNS" # did not start
|
||||||
|
DSQ = "DSQ" # disqualifiziert
|
||||||
|
|
||||||
|
|
||||||
|
class GeraetTyp(str, enum.Enum):
|
||||||
|
LICHTSCHRANKE = "lichtschranke"
|
||||||
|
ANZEIGE = "anzeige"
|
||||||
|
|
||||||
|
|
||||||
|
class ZeitmessungStatus(str, enum.Enum):
|
||||||
|
GUELTIG = "gueltig"
|
||||||
|
VERWORFEN = "verworfen"
|
||||||
|
UNZUGEORDNET = "unzugeordnet"
|
||||||
|
UNPLAUSIBEL = "unplausibel"
|
||||||
|
|
||||||
|
|
||||||
|
class ImportStatus(str, enum.Enum):
|
||||||
|
ANGENOMMEN = "angenommen"
|
||||||
|
VORSCHAU = "vorschau"
|
||||||
|
FEHLER = "fehler"
|
||||||
|
TEILWEISE = "teilweise"
|
||||||
|
|
||||||
|
|
||||||
|
class BenutzerRolle(str, enum.Enum):
|
||||||
|
ADMIN = "admin"
|
||||||
|
AUSWERTER = "auswerter"
|
||||||
|
LESEND = "lesend"
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Fahrer-Stammdaten — einmalig, nicht pro Turnier (Plan §4).
|
||||||
|
|
||||||
|
Loest die heutige \"BAUSTELLE\" ``m/m-fahrer.htm`` ab. Dublettenerkennung beim
|
||||||
|
Import: siehe ``oamc.matching.fahrer``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Date, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from oamc.models.base import Base, TimestampMixin, sa_enum
|
||||||
|
from oamc.models.enums import Geschlecht
|
||||||
|
|
||||||
|
|
||||||
|
class Fahrer(Base, TimestampMixin):
|
||||||
|
__tablename__ = "fahrer"
|
||||||
|
# Hinweis: der GIN-Trigramm-Index ``ix_fahrer_name_trgm`` (unscharfe Suche beim
|
||||||
|
# Import, Plan §6.4) wird als Ausdrucksindex in der Migration angelegt und in
|
||||||
|
# ``migrations/env.py`` von der Autogenerate-Vergleichung ausgenommen.
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
nachname: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
vorname: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
geburtsjahr: Mapped[int | None] = mapped_column(Integer, index=True)
|
||||||
|
# nullable: nur fuer die J-1-Pruefung "am Veranstaltungstag 7 Jahre" noetig.
|
||||||
|
# Datenminimierung oeffentlich: nur das Jahr wird angezeigt (Plan §11).
|
||||||
|
geburtsdatum: Mapped[date | None] = mapped_column(Date)
|
||||||
|
geschlecht: Mapped[Geschlecht | None] = mapped_column(sa_enum(Geschlecht, "geschlecht"))
|
||||||
|
verein_id: Mapped[int | None] = mapped_column(ForeignKey("verein.id"), index=True)
|
||||||
|
|
||||||
|
adac_mitgliedsnr: Mapped[str | None] = mapped_column(String(40)) # nie oeffentlich
|
||||||
|
jugendausweis_gueltig_bis: Mapped[date | None] = mapped_column(Date)
|
||||||
|
|
||||||
|
# Datenschutz: Opt-out auf Anzeige als "Teilnehmer N" / Kuerzel (Plan §11)
|
||||||
|
anzeige_anonymisiert: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
notiz: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
verein: Mapped[Verein | None] = relationship(back_populates="fahrer") # noqa: F821
|
||||||
|
starts: Mapped[list[Start]] = relationship(back_populates="fahrer") # noqa: F821
|
||||||
|
|
||||||
|
@property
|
||||||
|
def voller_name(self) -> str:
|
||||||
|
return f"{self.nachname} {self.vorname}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def anzeigename(self) -> str:
|
||||||
|
if self.anzeige_anonymisiert:
|
||||||
|
return f"Teilnehmer/in #{self.id}"
|
||||||
|
return self.voller_name
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Klassendefinition pro Saison (Plan §3.2).
|
||||||
|
|
||||||
|
Die Jahrgangstabelle ist jahresabhaengig und liegt deshalb in der DB, nicht im
|
||||||
|
Code. ``kategorie`` bestimmt die Default-Wertungsformel (J: nur Fehler; S/A:
|
||||||
|
Zeit + Fehler), ist aber pro Saison in ``wertungsregel`` ueberschreibbar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, Numeric, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from oamc.models.base import Base, sa_enum
|
||||||
|
from oamc.models.enums import Kategorie
|
||||||
|
|
||||||
|
|
||||||
|
class Klasse(Base):
|
||||||
|
__tablename__ = "klasse"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("saison_jahr", "code", name="klasse_saison_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
saison_jahr: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("saison.jahr", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
code: Mapped[str] = mapped_column(String(10), nullable=False) # "J 1", "S 6", "A 4"
|
||||||
|
kategorie: Mapped[Kategorie] = mapped_column(sa_enum(Kategorie, "kategorie"), nullable=False)
|
||||||
|
bezeichnung: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
# Nur Jugend: erlaubte Geburtsjahrgaenge, z. B. [2019, 2018, 2017]
|
||||||
|
jahrgaenge: Mapped[list[int] | None] = mapped_column(ARRAY(Integer))
|
||||||
|
ccm_max: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
kw_max: Mapped[float | None] = mapped_column(Numeric(5, 2))
|
||||||
|
fahrzeugtyp: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
# Fuer Klasse J 1: Mindestalter am Veranstaltungstag (Plan §3.2: "bereits 7 Jahre")
|
||||||
|
mindestalter_am_veranstaltungstag: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
sortierung: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
notiz: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
saison: Mapped[Saison] = relationship(back_populates="klassen") # noqa: F821
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
|
||||||
|
return f"<Klasse {self.saison_jahr}/{self.code}>"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Meisterschafts-/Pokalwertung (HTH) — Plan §3.4.
|
||||||
|
|
||||||
|
* Erwachsene: ein Gesamtklassement A + S (klassenuebergreifend)
|
||||||
|
* Jugend: Pokalwertung klassenweise J 1 - J 5
|
||||||
|
* Streichresultate nach Tabelle in ``saison.streichresultate_tabelle``
|
||||||
|
* Tie-Break: Majoritaet der Siege
|
||||||
|
* Die Anwendung *schlaegt* die Endlaufqualifikation *vor*, entscheidet nicht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from oamc.models.base import Base, sa_enum
|
||||||
|
from oamc.models.enums import Kategorie
|
||||||
|
|
||||||
|
|
||||||
|
class Meisterschaft(Base):
|
||||||
|
__tablename__ = "meisterschaft"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
saison_jahr: Mapped[int] = mapped_column(ForeignKey("saison.jahr"), nullable=False, index=True)
|
||||||
|
bezeichnung: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
kategorie: Mapped[Kategorie] = mapped_column(
|
||||||
|
sa_enum(Kategorie, "meisterschaft_kategorie"), nullable=False
|
||||||
|
)
|
||||||
|
# nur bei Jugend gesetzt (klassenweise J1..J5); bei Erwachsenen NULL
|
||||||
|
klasse_id: Mapped[int | None] = mapped_column(ForeignKey("klasse.id"))
|
||||||
|
# Punktetabelle (Platz -> Punkte), Streichregel-Override, Endlauf-Parameter.
|
||||||
|
# Solange O-1 offen ist, steht hier die (klar markierte) Platzhalter-Tabelle.
|
||||||
|
regelwerk: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
|
||||||
|
staende: Mapped[list[MeisterschaftStand]] = relationship(
|
||||||
|
back_populates="meisterschaft", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MeisterschaftStand(Base):
|
||||||
|
"""Materialisiert — nach jedem Turnier neu berechnet."""
|
||||||
|
|
||||||
|
__tablename__ = "meisterschaft_stand"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
meisterschaft_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("meisterschaft.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
fahrer_id: Mapped[int] = mapped_column(ForeignKey("fahrer.id"), nullable=False, index=True)
|
||||||
|
punkte_gesamt: Mapped[float] = mapped_column(Numeric(9, 2), nullable=False, default=0)
|
||||||
|
# {turnier_id: punkte}
|
||||||
|
punkte_je_lauf: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
# Liste der turnier_ids, die als Streichresultat NICHT zaehlen
|
||||||
|
gestrichene_laeufe: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
|
anzahl_siege: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # Tie-Break
|
||||||
|
platz: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
berechnet_am: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
meisterschaft: Mapped[Meisterschaft] = relationship(back_populates="staende")
|
||||||
|
fahrer: Mapped[Fahrer] = relationship() # noqa: F821
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Stammorganisation: Regionalclubs/Ortsclubs und Saison (Regelversionierung)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Integer, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from oamc.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Verein(Base, TimestampMixin):
|
||||||
|
"""Regional-/Ortsclub. Namensvarianten (\"Hainstadt\" = Ort statt Club) werden
|
||||||
|
beim Import gegen ``kurzname``/``name`` gematcht (Plan §6.4)."""
|
||||||
|
|
||||||
|
__tablename__ = "verein"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(200), nullable=False, unique=True)
|
||||||
|
kurzname: Mapped[str | None] = mapped_column(String(60))
|
||||||
|
adac_gau: Mapped[str | None] = mapped_column(String(120))
|
||||||
|
aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
notiz: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
fahrer: Mapped[list[Fahrer]] = relationship(back_populates="verein") # noqa: F821
|
||||||
|
|
||||||
|
|
||||||
|
class Saison(Base):
|
||||||
|
"""Ein Wettkampfjahr. Macht Klassen, Jahrgaenge, Streichresultate und
|
||||||
|
Punktetabelle versionierbar (Plan §4 Kernprinzipien)."""
|
||||||
|
|
||||||
|
__tablename__ = "saison"
|
||||||
|
|
||||||
|
jahr: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
|
||||||
|
turnierordnung_version: Mapped[str | None] = mapped_column(String(60))
|
||||||
|
# Tabelle "Turniere in der Saison" -> "davon gewertet" (Plan §3.4).
|
||||||
|
# z. B. {"12": 8, "11": 8, ... , "4": 2}
|
||||||
|
streichresultate_tabelle: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
nenngeld_cent: Mapped[int] = mapped_column(Integer, nullable=False, default=1000)
|
||||||
|
aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
klassen: Mapped[list[Klasse]] = relationship( # noqa: F821
|
||||||
|
back_populates="saison", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nenngeld_euro(self) -> Decimal:
|
||||||
|
return (Decimal(self.nenngeld_cent) / 100).quantize(Decimal("0.01"))
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Start (eine Teilnahme) und Ergebnis (1:1 zum Start).
|
||||||
|
|
||||||
|
Schluessel eines Ergebnisses ist (Turnier, Start), NICHT (Turnier, Fahrer) — ein
|
||||||
|
Fahrer kann am selben Tag mehrfach starten (Plan §3.3 Mehrfachstarts).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from oamc.models.base import Base, sa_enum
|
||||||
|
from oamc.models.enums import StartStatus
|
||||||
|
|
||||||
|
|
||||||
|
class Start(Base):
|
||||||
|
__tablename__ = "start"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("turnier_id", "startnummer", name="start_turnier_startnummer"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
turnier_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("turnier.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
fahrer_id: Mapped[int] = mapped_column(ForeignKey("fahrer.id"), nullable=False, index=True)
|
||||||
|
klasse_id: Mapped[int] = mapped_column(ForeignKey("klasse.id"), nullable=False)
|
||||||
|
wertungsklasse_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("wertungsklasse.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
startnummer: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
fahrzeug: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
nenngeld_bezahlt: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
status: Mapped[StartStatus] = mapped_column(
|
||||||
|
sa_enum(StartStatus, "start_status"), nullable=False, default=StartStatus.GENANNT
|
||||||
|
)
|
||||||
|
nennreihenfolge: Mapped[int | None] = mapped_column(Integer) # nummerierte Nennungsliste
|
||||||
|
|
||||||
|
turnier: Mapped[Turnier] = relationship(back_populates="starts") # noqa: F821
|
||||||
|
fahrer: Mapped[Fahrer] = relationship(back_populates="starts") # noqa: F821
|
||||||
|
klasse: Mapped[Klasse] = relationship() # noqa: F821
|
||||||
|
wertungsklasse: Mapped[Wertungsklasse | None] = relationship( # noqa: F821
|
||||||
|
back_populates="starts"
|
||||||
|
)
|
||||||
|
ergebnis: Mapped[Ergebnis | None] = relationship(
|
||||||
|
back_populates="start", cascade="all, delete-orphan", uselist=False
|
||||||
|
)
|
||||||
|
zeitmessungen: Mapped[list[Zeitmessung]] = relationship( # noqa: F821
|
||||||
|
back_populates="start"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gewertet(self) -> bool:
|
||||||
|
return self.status in (StartStatus.GESTARTET,)
|
||||||
|
|
||||||
|
|
||||||
|
class Ergebnis(Base):
|
||||||
|
"""1:1 zum Start — getrennte Tabelle fuer saubere Nachtraege/Korrekturen.
|
||||||
|
|
||||||
|
Zwei Quellen (Plan §3.6): ``summe_zeit`` aus ``zeitmessung`` summiert,
|
||||||
|
``summe_fehler`` manuell erfasst. Ein "halbes" Ergebnis ist der Normalzustand
|
||||||
|
waehrend der Veranstaltung. Gewertet wird erst, wenn beide ``*_vollstaendig``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "ergebnis"
|
||||||
|
|
||||||
|
start_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("start.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
summe_zeit: Mapped[Decimal | None] = mapped_column(Numeric(7, 2)) # aus zeitmessung summiert
|
||||||
|
summe_fehler: Mapped[int | None] = mapped_column(Integer) # manuell erfasst
|
||||||
|
gesamt: Mapped[Decimal | None] = mapped_column(Numeric(9, 2)) # berechnet
|
||||||
|
platzierung: Mapped[int | None] = mapped_column(Integer) # berechnet
|
||||||
|
|
||||||
|
zeit_vollstaendig: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
fehler_vollstaendig: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
erfasst_von: Mapped[str | None] = mapped_column(String(120))
|
||||||
|
erfasst_am: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
korrigiert_am: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
korrektur_grund: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
start: Mapped[Start] = relationship(back_populates="ergebnis")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wertbar(self) -> bool:
|
||||||
|
return bool(self.zeit_vollstaendig and self.fehler_vollstaendig)
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Renntag -> Wertungslauf -> zusammengefasste Wertungsklasse (Plan §3.1).
|
||||||
|
|
||||||
|
``veranstaltung`` (Renntag) : ``turnier`` (Wertungslauf) = 1:n. Ein Doppelturnier
|
||||||
|
hat zwei Turniere, jeder Fahrer wird in beiden getrennt gewertet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Date, ForeignKey, Index, Integer, String, Text, text
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from oamc.models.base import Base, TimestampMixin, sa_enum
|
||||||
|
from oamc.models.enums import TurnierStatus, VeranstaltungStatus
|
||||||
|
|
||||||
|
|
||||||
|
class Veranstaltung(Base, TimestampMixin):
|
||||||
|
__tablename__ = "veranstaltung"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
datum: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||||
|
titel: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||||
|
ort: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
ausrichter_verein_id: Mapped[int | None] = mapped_column(ForeignKey("verein.id"))
|
||||||
|
ist_doppelturnier: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
veranstaltungsleiter: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
turnierleiter: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
status: Mapped[VeranstaltungStatus] = mapped_column(
|
||||||
|
sa_enum(VeranstaltungStatus, "veranstaltung_status"),
|
||||||
|
nullable=False,
|
||||||
|
default=VeranstaltungStatus.GEPLANT,
|
||||||
|
)
|
||||||
|
notiz: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
turniere: Mapped[list[Turnier]] = relationship(
|
||||||
|
back_populates="veranstaltung", cascade="all, delete-orphan", order_by="Turnier.id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Turnier(Base, TimestampMixin):
|
||||||
|
__tablename__ = "turnier"
|
||||||
|
__table_args__ = (
|
||||||
|
# Hoechstens EIN Turnier darf "aktiv" sein -> Geraete-API /turniere/aktiv/*
|
||||||
|
Index(
|
||||||
|
"uq_turnier_ist_aktiv",
|
||||||
|
"ist_aktiv",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("ist_aktiv"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
veranstaltung_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("veranstaltung.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), nullable=False) # "Reinheim" / "Hainstadt"
|
||||||
|
veranstalter_verein_id: Mapped[int | None] = mapped_column(ForeignKey("verein.id"))
|
||||||
|
# Nicht jede Veranstaltung zaehlt zur Meisterschaft ("o. M.", Plan §3.4).
|
||||||
|
zaehlt_meisterschaft: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
# Genau ein Turnier kann als "aktiv" markiert sein -> Geraete-API-Endpunkte
|
||||||
|
# /turniere/aktiv/*. Partieller Unique-Index in der Migration.
|
||||||
|
ist_aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
status: Mapped[TurnierStatus] = mapped_column(
|
||||||
|
sa_enum(TurnierStatus, "turnier_status"),
|
||||||
|
nullable=False,
|
||||||
|
default=TurnierStatus.GEPLANT,
|
||||||
|
)
|
||||||
|
|
||||||
|
veranstaltung: Mapped[Veranstaltung] = relationship(back_populates="turniere")
|
||||||
|
wertungsklassen: Mapped[list[Wertungsklasse]] = relationship(
|
||||||
|
back_populates="turnier", cascade="all, delete-orphan", order_by="Wertungsklasse.sortierung"
|
||||||
|
)
|
||||||
|
starts: Mapped[list[Start]] = relationship( # noqa: F821
|
||||||
|
back_populates="turnier", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Wertungsklasse(Base):
|
||||||
|
"""Pro Turnier zusammengefasste Klassen (Plan §3.2): z. B. "S 5, S 9, S 3, S 6, S 7".
|
||||||
|
Das Zusammenlegen ist eine Entscheidung pro Turnier, keine Regel im Code."""
|
||||||
|
|
||||||
|
__tablename__ = "wertungsklasse"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
turnier_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("turnier.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
bezeichnung: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
klasse_ids: Mapped[list[int]] = mapped_column(ARRAY(Integer), nullable=False, default=list)
|
||||||
|
sortierung: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
turnier: Mapped[Turnier] = relationship(back_populates="wertungsklassen")
|
||||||
|
starts: Mapped[list[Start]] = relationship(back_populates="wertungsklasse") # noqa: F821
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Messgeraet am Parcours und Einzelmessung (Plan §3.6, §4).
|
||||||
|
|
||||||
|
Grundsaetze:
|
||||||
|
* Rohmessungen statt Summen speichern — die Summe bildet die Anwendung.
|
||||||
|
* Die ``id`` vergibt das GERAET (UUID) -> Server ist idempotent.
|
||||||
|
* Die Dauer kommt fertig vom Geraet (monotone Zeit), der Server rechnet keine
|
||||||
|
Differenz aus Wanduhr-Zeitstempeln.
|
||||||
|
* Eine Messung ohne zuordenbaren Start geht NICHT verloren (``start_id`` nullable,
|
||||||
|
Status ``unzugeordnet``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from oamc.models.base import Base, TimestampMixin, sa_enum
|
||||||
|
from oamc.models.enums import GeraetTyp, ZeitmessungStatus
|
||||||
|
|
||||||
|
|
||||||
|
class Geraet(Base, TimestampMixin):
|
||||||
|
"""Lichtschranke oder Anzeige. Eigener Geraete-Token (nicht der Auswerter-Login),
|
||||||
|
einzeln sperrbar (Plan §3.6b)."""
|
||||||
|
|
||||||
|
__tablename__ = "geraet"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
bezeichnung: Mapped[str] = mapped_column(String(120), nullable=False, unique=True)
|
||||||
|
typ: Mapped[GeraetTyp] = mapped_column(sa_enum(GeraetTyp, "geraet_typ"), nullable=False)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
letzte_meldung_am: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
firmware_version: Mapped[str | None] = mapped_column(String(60))
|
||||||
|
queue_laenge_gemeldet: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
notiz: Mapped[str | None] = mapped_column(Text)
|
||||||
|
# Zentral gepflegte Konfiguration, die der Mess-Pi per API abholt.
|
||||||
|
konfiguration: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
|
||||||
|
zeitmessungen: Mapped[list[Zeitmessung]] = relationship(back_populates="geraet")
|
||||||
|
|
||||||
|
|
||||||
|
class Zeitmessung(Base):
|
||||||
|
__tablename__ = "zeitmessung"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_zeitmessung_turnier_status", "turnier_id", "status"),
|
||||||
|
Index("ix_zeitmessung_start", "start_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# vom Geraet vergeben (UUIDv4/v7) -> Idempotenz
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True)
|
||||||
|
geraet_id: Mapped[int] = mapped_column(ForeignKey("geraet.id"), nullable=False)
|
||||||
|
turnier_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("turnier.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
# nullable — unzugeordnete Messungen gehen nicht verloren
|
||||||
|
start_id: Mapped[int | None] = mapped_column(ForeignKey("start.id", ondelete="SET NULL"))
|
||||||
|
startnummer_gemeldet: Mapped[int | None] = mapped_column(Integer) # Hinweis, kein FK
|
||||||
|
messpunkt: Mapped[str] = mapped_column(String(80), nullable=False, default="durchgang-1")
|
||||||
|
dauer_sekunden: Mapped[Decimal] = mapped_column(Numeric(7, 2), nullable=False) # vom Geraet
|
||||||
|
|
||||||
|
gemessen_am: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
monotonic_ns: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
empfangen_am: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
nachgeliefert: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
status: Mapped[ZeitmessungStatus] = mapped_column(
|
||||||
|
sa_enum(ZeitmessungStatus, "zeitmessung_status"),
|
||||||
|
nullable=False,
|
||||||
|
default=ZeitmessungStatus.GUELTIG,
|
||||||
|
)
|
||||||
|
verworfen_grund: Mapped[str | None] = mapped_column(Text)
|
||||||
|
roh: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
|
||||||
|
geraet: Mapped[Geraet] = relationship(back_populates="zeitmessungen")
|
||||||
|
start: Mapped[Start | None] = relationship(back_populates="zeitmessungen") # noqa: F821
|
||||||
|
|
||||||
|
@property
|
||||||
|
def zaehlt_in_summe(self) -> bool:
|
||||||
|
return self.status == ZeitmessungStatus.GUELTIG and self.start_id is not None
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Pydantic-v2-Schemata: ein Schema fuer API-Validierung UND OpenAPI-Doku."""
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Schemata der Geraete-API (Mess-Pi, Bearer-Token). Plan §6.2."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class Heartbeat(BaseModel):
|
||||||
|
firmware_version: str | None = None
|
||||||
|
queue_laenge: int = Field(default=0, ge=0)
|
||||||
|
freitext: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HeartbeatAntwort(BaseModel):
|
||||||
|
geraet_id: int
|
||||||
|
bezeichnung: str
|
||||||
|
server_zeit: datetime
|
||||||
|
aktives_turnier_id: int | None
|
||||||
|
|
||||||
|
|
||||||
|
class GeraeteKonfiguration(BaseModel):
|
||||||
|
aktives_turnier_id: int | None
|
||||||
|
entprellung_ms: int = 300
|
||||||
|
messpunkte: list[str] = ["durchgang-1"]
|
||||||
|
plausibilitaet_min_s: float
|
||||||
|
plausibilitaet_max_s: float
|
||||||
|
# frei konfigurierbare Zusatzwerte aus geraet.konfiguration
|
||||||
|
extra: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class MessungEin(BaseModel):
|
||||||
|
id: uuid.UUID = Field(description="vom Geraet vergeben (UUIDv4/v7) -> Idempotenz")
|
||||||
|
turnier_id: int
|
||||||
|
startnummer_gemeldet: int | None = None
|
||||||
|
messpunkt: str = "durchgang-1"
|
||||||
|
dauer_sekunden: Decimal = Field(gt=0, decimal_places=2, description="vom Geraet berechnet")
|
||||||
|
gemessen_am: datetime
|
||||||
|
monotonic_ns: int | None = None
|
||||||
|
roh: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessungBatch(BaseModel):
|
||||||
|
geraet: str | None = Field(default=None, description="Kennung, informativ")
|
||||||
|
messungen: list[MessungEin] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class MessungQuittung(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
status: str # angenommen | duplikat | unzugeordnet | unplausibel | fehler
|
||||||
|
start_id: int | None = None
|
||||||
|
hinweis: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BatchAntwort(BaseModel):
|
||||||
|
quittungen: list[MessungQuittung]
|
||||||
|
angenommen: int
|
||||||
|
duplikate: int
|
||||||
|
unzugeordnet: int
|
||||||
|
unplausibel: int
|
||||||
|
|
||||||
|
|
||||||
|
class VerwerfenEin(BaseModel):
|
||||||
|
grund: str = Field(min_length=1)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Schemata der Ingest-API (POST /api/v1/..., X-API-Key). Plan §6.1."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
from oamc.models.enums import Geschlecht, StartStatus
|
||||||
|
|
||||||
|
|
||||||
|
class FahrerRef(BaseModel):
|
||||||
|
"""Fahrerangabe in einem Bulk-Upload — wird gegen die Stammdaten gematcht."""
|
||||||
|
|
||||||
|
nachname: str
|
||||||
|
vorname: str
|
||||||
|
geburtsjahr: int | None = Field(default=None, ge=1930, le=date.today().year)
|
||||||
|
geschlecht: Geschlecht | None = None
|
||||||
|
verein: str | None = None
|
||||||
|
fahrer_id: int | None = Field(default=None, description="wenn bekannt: direkter Treffer")
|
||||||
|
|
||||||
|
|
||||||
|
class VeranstaltungCreate(BaseModel):
|
||||||
|
datum: date
|
||||||
|
titel: str
|
||||||
|
ort: str | None = None
|
||||||
|
ausrichter_verein: str | None = None
|
||||||
|
ist_doppelturnier: bool = False
|
||||||
|
veranstaltungsleiter: str | None = None
|
||||||
|
turnierleiter: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TurnierCreate(BaseModel):
|
||||||
|
veranstaltung_id: int
|
||||||
|
name: str
|
||||||
|
veranstalter_verein: str | None = None
|
||||||
|
zaehlt_meisterschaft: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class StartCreate(BaseModel):
|
||||||
|
startnummer: int = Field(ge=1)
|
||||||
|
fahrer: FahrerRef
|
||||||
|
klasse: str = Field(description='Klassencode, z. B. "J 2", "S 6"')
|
||||||
|
wertungsklasse: str | None = None
|
||||||
|
fahrzeug: str | None = None
|
||||||
|
nenngeld_bezahlt: bool = False
|
||||||
|
nennreihenfolge: int | None = None
|
||||||
|
status: StartStatus = StartStatus.GENANNT
|
||||||
|
|
||||||
|
|
||||||
|
class StartBulk(BaseModel):
|
||||||
|
quelle: str = "manuell"
|
||||||
|
idempotenz_schluessel: str | None = None
|
||||||
|
starts: list[StartCreate]
|
||||||
|
|
||||||
|
|
||||||
|
class ErgebnisEintrag(BaseModel):
|
||||||
|
startnummer: int
|
||||||
|
fahrer: FahrerRef | None = None
|
||||||
|
klasse: str | None = None
|
||||||
|
wertungsklasse: str | None = None
|
||||||
|
summe_zeit: Decimal | None = Field(default=None, ge=0, decimal_places=2)
|
||||||
|
summe_fehler: int | None = Field(default=None, ge=0)
|
||||||
|
zeit_vollstaendig: bool = True
|
||||||
|
fehler_vollstaendig: bool = True
|
||||||
|
# optional: Server rechnet nach und meldet Abweichung als Warnung
|
||||||
|
gesamt_erwartet: Decimal | None = None
|
||||||
|
platzierung_erwartet: int | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _mindestens_ein_wert(self):
|
||||||
|
if self.summe_zeit is None and self.summe_fehler is None:
|
||||||
|
raise ValueError("Weder summe_zeit noch summe_fehler angegeben")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ErgebnisBulk(BaseModel):
|
||||||
|
quelle: str = Field(examples=["excel-auswertung-v3"])
|
||||||
|
idempotenz_schluessel: str = Field(
|
||||||
|
examples=["2026-05-31-reinheim-final"],
|
||||||
|
description="Ein doppelt gesendeter Upload legt keine Dubletten an.",
|
||||||
|
)
|
||||||
|
ergebnisse: list[ErgebnisEintrag]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Vorschau-/Ergebnisberichte -----------------------------------------
|
||||||
|
class FahrerMatchInfo(BaseModel):
|
||||||
|
startnummer: int
|
||||||
|
eingabe: str
|
||||||
|
status: str # exakt | unscharf | neu
|
||||||
|
fahrer_id: int | None = None
|
||||||
|
vorschlaege: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
class AbweichungInfo(BaseModel):
|
||||||
|
startnummer: int | None
|
||||||
|
feld: str
|
||||||
|
erwartet: object
|
||||||
|
berechnet: object
|
||||||
|
|
||||||
|
|
||||||
|
class ImportBericht(BaseModel):
|
||||||
|
import_job_id: int | None = None
|
||||||
|
dry_run: bool
|
||||||
|
angelegt: int = 0
|
||||||
|
aktualisiert: int = 0
|
||||||
|
neue_fahrer: int = 0
|
||||||
|
fahrer_matches: list[FahrerMatchInfo] = []
|
||||||
|
abweichungen: list[AbweichungInfo] = []
|
||||||
|
warnungen: list[str] = []
|
||||||
|
idempotent_uebersprungen: bool = False
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Authentifizierung: getrennte Profile fuer Ingest-API-Key, Geraete-Token und
|
||||||
|
interne Benutzer-Session (Plan §6.1, §6.2).
|
||||||
|
|
||||||
|
Der Geraete-Token ist NICHT der Auswerter-Login und einzeln sperrbar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from oamc.security.passwoerter import hash_passwort, pruefe_passwort
|
||||||
|
from oamc.security.tokens import (
|
||||||
|
erzeuge_geraete_token,
|
||||||
|
hash_token,
|
||||||
|
jwt_erzeugen,
|
||||||
|
jwt_lesen,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"hash_passwort",
|
||||||
|
"pruefe_passwort",
|
||||||
|
"erzeuge_geraete_token",
|
||||||
|
"hash_token",
|
||||||
|
"jwt_erzeugen",
|
||||||
|
"jwt_lesen",
|
||||||
|
]
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""FastAPI-Dependencies fuer die drei Auth-Profile."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from oamc.config import get_settings
|
||||||
|
from oamc.db import get_db
|
||||||
|
from oamc.models import Benutzer, Geraet
|
||||||
|
from oamc.models.enums import BenutzerRolle
|
||||||
|
from oamc.security.tokens import jwt_lesen, token_stimmt
|
||||||
|
|
||||||
|
DbDep = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Ingest-API-Key (POST /api/v1/...) --------------------------------------
|
||||||
|
def require_ingest_key(
|
||||||
|
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
|
||||||
|
) -> str:
|
||||||
|
keys = get_settings().ingest_api_key_set
|
||||||
|
if not x_api_key or x_api_key not in keys:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltiger oder fehlender X-API-Key")
|
||||||
|
return x_api_key
|
||||||
|
|
||||||
|
|
||||||
|
IngestKey = Annotated[str, Depends(require_ingest_key)]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Geraete-Token (Mess-Pi) ---------------------------------------------
|
||||||
|
def require_geraet(
|
||||||
|
db: DbDep,
|
||||||
|
authorization: Annotated[str | None, Header()] = None,
|
||||||
|
) -> Geraet:
|
||||||
|
if not authorization or not authorization.lower().startswith("bearer "):
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Bearer-Geraete-Token fehlt")
|
||||||
|
roh = authorization.split(" ", 1)[1].strip()
|
||||||
|
geraete = db.scalars(select(Geraet).where(Geraet.aktiv.is_(True))).all()
|
||||||
|
for g in geraete:
|
||||||
|
if token_stimmt(roh, g.token_hash):
|
||||||
|
g.letzte_meldung_am = datetime.now(UTC)
|
||||||
|
return g
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Geraete-Token ungueltig oder gesperrt")
|
||||||
|
|
||||||
|
|
||||||
|
GeraetDep = Annotated[Geraet, Depends(require_geraet)]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Interne Benutzer-Session (Cookie-JWT) ------------------------------
|
||||||
|
def aktueller_benutzer(request: Request, db: DbDep) -> Benutzer | None:
|
||||||
|
token = request.cookies.get("oamc_session")
|
||||||
|
if not token:
|
||||||
|
auth = request.headers.get("authorization", "")
|
||||||
|
if auth.lower().startswith("bearer "):
|
||||||
|
token = auth.split(" ", 1)[1]
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
daten = jwt_lesen(token)
|
||||||
|
if not daten:
|
||||||
|
return None
|
||||||
|
b = db.scalars(
|
||||||
|
select(Benutzer).where(Benutzer.benutzername == daten.get("sub"), Benutzer.aktiv.is_(True))
|
||||||
|
).first()
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
BenutzerOpt = Annotated["Benutzer | None", Depends(aktueller_benutzer)]
|
||||||
|
|
||||||
|
|
||||||
|
def require_benutzer(b: BenutzerOpt) -> Benutzer:
|
||||||
|
if b is None:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Anmeldung erforderlich")
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def require_auswerter(b: Annotated[Benutzer, Depends(require_benutzer)]) -> Benutzer:
|
||||||
|
if not b.darf_schreiben:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Auswerter- oder Admin-Rolle erforderlich")
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(b: Annotated[Benutzer, Depends(require_benutzer)]) -> Benutzer:
|
||||||
|
if b.rolle != BenutzerRolle.ADMIN:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rolle erforderlich")
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
Auswerter = Annotated[Benutzer, Depends(require_auswerter)]
|
||||||
|
Admin = Annotated[Benutzer, Depends(require_admin)]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Passwort-Hashing fuer interne Benutzer.
|
||||||
|
|
||||||
|
Direkt ``bcrypt`` (ohne passlib — dessen bcrypt-Backend ist mit bcrypt >= 4.1
|
||||||
|
inkompatibel). Lange Passwoerter werden vorher per SHA-256 auf 44 Base64-Zeichen
|
||||||
|
verdichtet, damit die 72-Byte-Grenze von bcrypt nie still abschneidet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
|
||||||
|
def _vorbereiten(klartext: str) -> bytes:
|
||||||
|
digest = hashlib.sha256(klartext.encode("utf-8")).digest()
|
||||||
|
return base64.b64encode(digest) # 44 Bytes, < 72
|
||||||
|
|
||||||
|
|
||||||
|
def hash_passwort(klartext: str) -> str:
|
||||||
|
return bcrypt.hashpw(_vorbereiten(klartext), bcrypt.gensalt()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_passwort(klartext: str, hash_wert: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(_vorbereiten(klartext), hash_wert.encode("ascii"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Geraete-Token (undurchsichtig, gehasht gespeichert) und Benutzer-JWT."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from oamc.config import get_settings
|
||||||
|
|
||||||
|
_TOKEN_PREFIX = "oamc_dev_" # nur ein Erkennungsmerkmal, kein Geheimnis
|
||||||
|
|
||||||
|
|
||||||
|
def erzeuge_geraete_token() -> tuple[str, str]:
|
||||||
|
"""Gibt ``(klartext_token, token_hash)`` zurueck. Der Klartext wird EINMALIG
|
||||||
|
dem Geraet mitgegeben und nie gespeichert."""
|
||||||
|
|
||||||
|
roh = _TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||||
|
return roh, hash_token(roh)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(roh: str) -> str:
|
||||||
|
return hashlib.sha256(roh.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def token_stimmt(roh: str, gespeicherter_hash: str) -> bool:
|
||||||
|
return hmac.compare_digest(hash_token(roh), gespeicherter_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def jwt_erzeugen(subject: str, rolle: str, **extra: Any) -> str:
|
||||||
|
s = get_settings()
|
||||||
|
jetzt = datetime.now(UTC)
|
||||||
|
payload = {
|
||||||
|
"sub": subject,
|
||||||
|
"rolle": rolle,
|
||||||
|
"iat": jetzt,
|
||||||
|
"exp": jetzt + timedelta(minutes=s.access_token_minutes),
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, s.secret_key, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def jwt_lesen(token: str) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, get_settings().secret_key, algorithms=["HS256"])
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Stammdaten-Seed (AP 1).
|
||||||
|
|
||||||
|
``seed_saison_2026`` legt Saison 2026, Klassen J 1-J 5 + S/A, Vereine und die
|
||||||
|
HTH-Meisterschaften an. Idempotent: mehrfaches Ausfuehren aendert nichts.
|
||||||
|
|
||||||
|
Offene Fragen, die den Seed betreffen:
|
||||||
|
* O-1 Punktetabelle Meisterschaft -> Platzhalter in ``meisterschaft.regelwerk``
|
||||||
|
* O-2 Zuordnung S 1-S 9 / A 1-A 4 -> Fahrzeugtyp -> ``fahrzeugtyp`` bleibt NULL
|
||||||
|
"""
|
||||||
|
|
||||||
|
from oamc.seeds.saison_2026 import seed_saison_2026
|
||||||
|
|
||||||
|
__all__ = ["seed_saison_2026"]
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""Seed fuer Saison 2026 — abgeleitet aus plan.md §3.2 und §3.4."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from oamc.meisterschaft.berechnung import PLATZHALTER_PUNKTETABELLE
|
||||||
|
from oamc.models import Klasse, Meisterschaft, Saison, Verein
|
||||||
|
from oamc.models.enums import Kategorie
|
||||||
|
|
||||||
|
# "Turniere in der Saison" -> "davon gewertet" (Plan §3.4)
|
||||||
|
STREICHRESULTATE_2026 = {
|
||||||
|
"12": 8, "11": 8, "10": 8, "9": 7, "8": 6, "7": 5, "6": 4, "5": 3, "4": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Jugendklassen: Einteilung strikt nach Jahrgang (Plan §3.2)
|
||||||
|
JUGENDKLASSEN = [
|
||||||
|
("J 1", [2019, 2018, 2017], 110, 5.5, "Kindermotorrad", 7),
|
||||||
|
("J 2", [2016, 2015], 110, 5.5, "Kindermotorrad", None),
|
||||||
|
("J 3", [2014, 2013], 125, 11.0, "Mofa / Motorrad / Roller", None),
|
||||||
|
("J 4", [2012, 2011], 125, 11.0, "Mofa / Motorrad / Roller", None),
|
||||||
|
("J 5", [2010, 2009, 2008], 125, 11.0, "Mofa / Motorrad / Roller (bis 18. Geburtstag)", None),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Erwachsenenklassen: konkrete Fahrzeugzuordnung ist offene Frage O-2.
|
||||||
|
STAMMKLASSEN = [f"S {i}" for i in range(1, 10)]
|
||||||
|
ANFAENGERKLASSEN = [f"A {i}" for i in range(1, 5)]
|
||||||
|
|
||||||
|
VEREINE = [
|
||||||
|
("OAMC Reinheim e.V. im ADAC", "OAMC Reinheim", "ADAC Hessen-Thueringen"),
|
||||||
|
("MSC Hainstadt", "Hainstadt", "ADAC Hessen-Thueringen"),
|
||||||
|
("MSC Schotten", "Schotten", "ADAC Hessen-Thueringen"),
|
||||||
|
("PMS Kassel", "Kassel", "ADAC Hessen-Thueringen"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create(db: Session, model, defaults=None, **kwargs):
|
||||||
|
obj = db.scalars(select(model).filter_by(**kwargs)).first()
|
||||||
|
if obj is not None:
|
||||||
|
return obj, False
|
||||||
|
obj = model(**kwargs, **(defaults or {}))
|
||||||
|
db.add(obj)
|
||||||
|
db.flush()
|
||||||
|
return obj, True
|
||||||
|
|
||||||
|
|
||||||
|
def seed_saison_2026(db: Session) -> dict[str, int]:
|
||||||
|
"""Idempotenter Seed. Rueckgabe: Zaehler der neu angelegten Objekte."""
|
||||||
|
|
||||||
|
zaehler = {"saison": 0, "verein": 0, "klasse": 0, "meisterschaft": 0}
|
||||||
|
|
||||||
|
saison, neu = _get_or_create(
|
||||||
|
db,
|
||||||
|
Saison,
|
||||||
|
jahr=2026,
|
||||||
|
defaults={
|
||||||
|
"turnierordnung_version": "MT-D2025 (zu verifizieren, Plan §10)",
|
||||||
|
"streichresultate_tabelle": STREICHRESULTATE_2026,
|
||||||
|
"nenngeld_cent": 1000,
|
||||||
|
"aktiv": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
zaehler["saison"] += int(neu)
|
||||||
|
|
||||||
|
for name, kurz, gau in VEREINE:
|
||||||
|
_, neu = _get_or_create(
|
||||||
|
db, Verein, name=name, defaults={"kurzname": kurz, "adac_gau": gau, "aktiv": True}
|
||||||
|
)
|
||||||
|
zaehler["verein"] += int(neu)
|
||||||
|
|
||||||
|
sortierung = 0
|
||||||
|
for code, jahrgaenge, ccm, kw, typ, mindestalter in JUGENDKLASSEN:
|
||||||
|
sortierung += 1
|
||||||
|
_, neu = _get_or_create(
|
||||||
|
db,
|
||||||
|
Klasse,
|
||||||
|
saison_jahr=2026,
|
||||||
|
code=code,
|
||||||
|
defaults={
|
||||||
|
"kategorie": Kategorie.JUGEND,
|
||||||
|
"bezeichnung": f"Jugend {code} (Jahrgang {'/'.join(map(str, jahrgaenge))})",
|
||||||
|
"jahrgaenge": jahrgaenge,
|
||||||
|
"ccm_max": ccm,
|
||||||
|
"kw_max": kw,
|
||||||
|
"fahrzeugtyp": typ,
|
||||||
|
"mindestalter_am_veranstaltungstag": mindestalter,
|
||||||
|
"sortierung": sortierung,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
zaehler["klasse"] += int(neu)
|
||||||
|
|
||||||
|
for code in STAMMKLASSEN:
|
||||||
|
sortierung += 1
|
||||||
|
_, neu = _get_or_create(
|
||||||
|
db, Klasse, saison_jahr=2026, code=code,
|
||||||
|
defaults={
|
||||||
|
"kategorie": Kategorie.STAMM,
|
||||||
|
"bezeichnung": f"Stammfahrer {code}",
|
||||||
|
"fahrzeugtyp": None,
|
||||||
|
"sortierung": sortierung,
|
||||||
|
"notiz": "O-2 offen: Fahrzeugtyp/Hubraum/kW noch zuzuordnen",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
zaehler["klasse"] += int(neu)
|
||||||
|
|
||||||
|
for code in ANFAENGERKLASSEN:
|
||||||
|
sortierung += 1
|
||||||
|
_, neu = _get_or_create(
|
||||||
|
db, Klasse, saison_jahr=2026, code=code,
|
||||||
|
defaults={
|
||||||
|
"kategorie": Kategorie.ANFAENGER,
|
||||||
|
"bezeichnung": f"Anfaenger {code}",
|
||||||
|
"fahrzeugtyp": None,
|
||||||
|
"sortierung": sortierung,
|
||||||
|
"notiz": "O-2 offen: Fahrzeugtyp/Hubraum/kW noch zuzuordnen",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
zaehler["klasse"] += int(neu)
|
||||||
|
|
||||||
|
platzhalter_regelwerk = {
|
||||||
|
"punkte": {str(k): v for k, v in PLATZHALTER_PUNKTETABELLE.items()},
|
||||||
|
"_hinweis": "O-1 offen: PLATZHALTER-Punktetabelle, nicht offiziell!",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, neu = _get_or_create(
|
||||||
|
db, Meisterschaft, saison_jahr=2026, bezeichnung="HTH Erwachsene 2026",
|
||||||
|
defaults={"kategorie": Kategorie.STAMM, "regelwerk": platzhalter_regelwerk},
|
||||||
|
)
|
||||||
|
zaehler["meisterschaft"] += int(neu)
|
||||||
|
|
||||||
|
jugendklassen = db.scalars(
|
||||||
|
select(Klasse).where(Klasse.saison_jahr == 2026, Klasse.kategorie == Kategorie.JUGEND)
|
||||||
|
).all()
|
||||||
|
for k in jugendklassen:
|
||||||
|
_, neu = _get_or_create(
|
||||||
|
db, Meisterschaft, saison_jahr=2026, bezeichnung=f"HTH Jugend {k.code} 2026",
|
||||||
|
defaults={
|
||||||
|
"kategorie": Kategorie.JUGEND,
|
||||||
|
"klasse_id": k.id,
|
||||||
|
"regelwerk": platzhalter_regelwerk,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
zaehler["meisterschaft"] += int(neu)
|
||||||
|
|
||||||
|
return zaehler
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/* OAMC Turnierauswertung — handgeschrieben, keine Build-Pipeline (Plan §5.2) */
|
||||||
|
:root {
|
||||||
|
--fg: #1a1a1a; --bg: #fbfbfa; --muted: #666; --line: #e2e2df;
|
||||||
|
--akzent: #0b5; --akzent-dark: #084; --warn: #b45309; --warn-bg: #fef3c7;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; color: var(--fg); background: var(--bg);
|
||||||
|
font: 16px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||||
|
a { color: var(--akzent-dark); }
|
||||||
|
.wrap { max-width: 60rem; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
|
||||||
|
.topbar { display: flex; flex-wrap: wrap; gap: 1rem; align-items: baseline;
|
||||||
|
padding: .8rem 1rem; background: #fff; border-bottom: 1px solid var(--line); }
|
||||||
|
.topbar .brand { font-weight: 800; text-decoration: none; color: var(--fg); }
|
||||||
|
.topbar nav { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
.foot { border-top: 1px solid var(--line); padding: 1rem; text-align: center;
|
||||||
|
color: var(--muted); font-size: .85rem; }
|
||||||
|
h1 { font-size: 1.7rem; margin: .2em 0 .6em; }
|
||||||
|
h2 { font-size: 1.25rem; margin: 1.4em 0 .4em; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: .5rem 0 1.5rem;
|
||||||
|
font-variant-numeric: tabular-nums; display: block; overflow-x: auto; }
|
||||||
|
th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid var(--line); }
|
||||||
|
th { font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
|
||||||
|
td.num, th.num { text-align: right; }
|
||||||
|
tr.vorlaeufig td { color: var(--muted); font-style: italic; }
|
||||||
|
.badge { display: inline-block; font-size: .72rem; padding: .05em .5em; border-radius: 1em;
|
||||||
|
background: #eee; color: #444; vertical-align: middle; }
|
||||||
|
.hinweis { background: var(--warn-bg); color: var(--warn); padding: .6rem .9rem;
|
||||||
|
border-radius: .4rem; border: 1px solid #fcd34d; }
|
||||||
|
.cards { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); }
|
||||||
|
.card { background: #fff; border: 1px solid var(--line); border-radius: .5rem; padding: 1rem; }
|
||||||
|
.card h2 { margin-top: 0; }
|
||||||
|
ul.plain { list-style: none; padding: 0; } ul.plain li { margin: .3rem 0; }
|
||||||
|
.tiles { display: flex; gap: .8rem; flex-wrap: wrap; margin: 1rem 0; }
|
||||||
|
.tiles a { background: #fff; border: 1px solid var(--line); border-radius: .4rem;
|
||||||
|
padding: .6rem 1rem; text-decoration: none; }
|
||||||
|
form.schmal, form.inline { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||||||
|
form.schmal { flex-direction: column; align-items: stretch; max-width: 22rem; }
|
||||||
|
label { display: block; }
|
||||||
|
input, button { font: inherit; padding: .4rem .5rem; border: 1px solid var(--line);
|
||||||
|
border-radius: .3rem; background: #fff; }
|
||||||
|
button { background: var(--akzent-dark); color: #fff; border-color: var(--akzent-dark);
|
||||||
|
cursor: pointer; }
|
||||||
|
.mini { font-size: .75rem; color: var(--muted); display: inline; }
|
||||||
|
.ok { color: var(--akzent-dark); } .warn { color: var(--warn); }
|
||||||
|
kbd { background: #eee; border: 1px solid #ccc; border-radius: .2rem; padding: 0 .3em; }
|
||||||
|
input[type=search] { width: 100%; max-width: 22rem; margin-bottom: .5rem; }
|
||||||
|
@media print {
|
||||||
|
.topbar, .foot, form { display: none; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,79 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Anzeige – OAMC Turnier</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
body { margin: 0; background: #0b0b0c; color: #f4f4f5;
|
||||||
|
font: 16px/1.4 system-ui, sans-serif; }
|
||||||
|
.kiosk { min-height: 100vh; display: flex; flex-direction: column; padding: 3vh 4vw; }
|
||||||
|
.status { font-size: 2vw; opacity: .7; display: flex; justify-content: space-between; }
|
||||||
|
.status .offline { color: #f87171; font-weight: 700; }
|
||||||
|
.aktuell { flex: 1; display: flex; flex-direction: column; justify-content: center; }
|
||||||
|
.nr { font-size: 12vw; font-weight: 800; line-height: 1; }
|
||||||
|
.name { font-size: 6vw; font-weight: 700; }
|
||||||
|
.meta { font-size: 3vw; opacity: .8; }
|
||||||
|
.zeit { font-size: 9vw; font-weight: 800; color: #4ade80; }
|
||||||
|
.naechste { font-size: 2.4vw; opacity: .85; }
|
||||||
|
.naechste li { margin: .2em 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="kiosk">
|
||||||
|
<div class="status">
|
||||||
|
<span id="turnier">–</span>
|
||||||
|
<span id="verbindung"></span>
|
||||||
|
</div>
|
||||||
|
<div class="aktuell">
|
||||||
|
<div class="nr" id="nr">–</div>
|
||||||
|
<div class="zeit" id="zeit"></div>
|
||||||
|
<div class="meta" id="meta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="naechste">
|
||||||
|
<strong>Nächste Starter</strong>
|
||||||
|
<ol id="naechste"></ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const LS_KEY = "oamc_anzeige_letzte";
|
||||||
|
function render(d) {
|
||||||
|
document.getElementById("turnier").textContent = d.aktiv ? ("Turnier " + d.turnier) : "kein aktives Turnier";
|
||||||
|
if (d.letzte_messung) {
|
||||||
|
document.getElementById("nr").textContent = "Nr. " + (d.letzte_messung.startnummer ?? "?");
|
||||||
|
document.getElementById("zeit").textContent = d.letzte_messung.dauer_sekunden.toFixed(2).replace(".", ",") + " s";
|
||||||
|
document.getElementById("meta").textContent = d.letzte_messung.status;
|
||||||
|
}
|
||||||
|
const ol = document.getElementById("naechste");
|
||||||
|
ol.innerHTML = "";
|
||||||
|
(d.naechste_starter || []).forEach(s => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.textContent = `Nr. ${s.startnummer} – ${s.name} (${s.klasse ?? "?"})`;
|
||||||
|
ol.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(LS_KEY);
|
||||||
|
if (cached) render(JSON.parse(cached));
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
let es;
|
||||||
|
function connect() {
|
||||||
|
es = new EventSource("/api/v1/turniere/aktiv/anzeige");
|
||||||
|
es.addEventListener("anzeige", ev => {
|
||||||
|
document.getElementById("verbindung").textContent = "";
|
||||||
|
const d = JSON.parse(ev.data);
|
||||||
|
render(d);
|
||||||
|
try { localStorage.setItem(LS_KEY, JSON.stringify(d)); } catch (e) {}
|
||||||
|
});
|
||||||
|
es.onerror = () => {
|
||||||
|
document.getElementById("verbindung").innerHTML = '<span class="offline">OFFLINE – letzter Stand</span>';
|
||||||
|
es.close();
|
||||||
|
setTimeout(connect, 3000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
{% if noindex %}<meta name="robots" content="noindex">{% endif %}
|
||||||
|
<title>{% block title %}{{ projekt }}{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
<script src="/static/js/htmx.min.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<a href="/" class="brand">OAMC Turnier</a>
|
||||||
|
<nav>
|
||||||
|
<a href="/termine">Termine</a>
|
||||||
|
<a href="/ergebnisse">Ergebnisse</a>
|
||||||
|
<a href="/fahrer">Fahrer</a>
|
||||||
|
<a href="/meisterschaft">Meisterschaft</a>
|
||||||
|
{% if request.cookies.get('oamc_session') %}<a href="/intern">Intern</a>{% endif %}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="wrap">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
<footer class="foot">
|
||||||
|
<p>OAMC Reinheim e.V. im ADAC · Turnierauswertung · <a href="/docs">API</a> · <a href="/termine.ics">iCal</a></p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Intern · {{ projekt }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Intern</h1>
|
||||||
|
<p>Angemeldet als <strong>{{ benutzer.anzeigename }}</strong> ({{ benutzer.rolle.value }}) ·
|
||||||
|
<a href="/intern/logout">abmelden</a></p>
|
||||||
|
|
||||||
|
{% if hat_offene_messungen %}
|
||||||
|
<p class="hinweis"><a href="/intern/messungen">Es gibt unzugeordnete oder unplausible
|
||||||
|
Lichtschranken-Messungen →</a></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<nav class="tiles">
|
||||||
|
<a href="/intern/messungen">Messungen zuordnen</a>
|
||||||
|
<a href="/intern/geraete">Geräte</a>
|
||||||
|
<a href="/docs">API-Doku</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h2>Veranstaltungen</h2>
|
||||||
|
<ul class="plain">
|
||||||
|
{% for v in veranstaltungen %}
|
||||||
|
<li>{{ v.datum.strftime('%d.%m.%Y') }} – {{ v.titel }}
|
||||||
|
{% for t in v.turniere %}
|
||||||
|
· <a href="/intern/erfassung/{{ t.id }}">Erfassung {{ t.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li>Keine Veranstaltungen. Anlegen über die Ingest-API (<code>POST /api/v1/veranstaltungen</code>).</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Erfassung {{ turnier.name }} · {{ projekt }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Ergebniserfassung – Turnier {{ turnier.name }}</h1>
|
||||||
|
<p>Zeit im Format <code>159,16</code> · Fehler als Vielfaches von 10 · Haken = Wert endgültig.
|
||||||
|
Speichern per <kbd>Enter</kbd> im jeweiligen Feld.</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Nr.</th><th>Fahrer</th><th>Klasse</th><th>Zeit</th><th>Fehler</th>
|
||||||
|
<th>Gesamt</th><th>Platz</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% set turnier_id = turnier.id %}
|
||||||
|
{% for s in starts %}
|
||||||
|
{% include "partials/erfassung_zeile.html" with context %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Geräte · {{ projekt }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Geräte am Parcours</h1>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Bezeichnung</th><th>Typ</th><th>aktiv</th><th>Heartbeat</th>
|
||||||
|
<th class="num">Queue</th><th>Firmware</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in geraete %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.g.bezeichnung }}</td>
|
||||||
|
<td>{{ row.g.typ.value }}</td>
|
||||||
|
<td>{{ 'ja' if row.g.aktiv else 'gesperrt' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if row.sekunden is none %}nie
|
||||||
|
{% elif row.lebt %}<span class="ok">vor {{ row.sekunden }} s</span>
|
||||||
|
{% else %}<span class="warn">vor {{ row.sekunden }} s</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ row.g.queue_laenge_gemeldet if row.g.queue_laenge_gemeldet is not none else '–' }}</td>
|
||||||
|
<td>{{ row.g.firmware_version or '–' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6">Keine Geräte registriert. Anlegen via
|
||||||
|
<code>POST /api/v1/admin/geraete</code>.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Anmeldung · {{ projekt }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Interne Anmeldung</h1>
|
||||||
|
{% if fehler %}<p class="hinweis">{{ fehler }}</p>{% endif %}
|
||||||
|
<form method="post" action="/intern/login" class="schmal">
|
||||||
|
<label>Benutzername <input name="benutzername" required autofocus></label>
|
||||||
|
<label>Passwort <input name="passwort" type="password" required></label>
|
||||||
|
<button type="submit">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Messungen zuordnen · {{ projekt }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Messungen zuordnen</h1>
|
||||||
|
<p>Unzugeordnete und unplausible Lichtschranken-Messungen. Eine gemessene Zeit ist nicht
|
||||||
|
reproduzierbar – sie wird nur mit Grund verworfen, nie stillschweigend.</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Zeitpunkt</th><th>Turnier</th><th>gemeldete Nr.</th><th>Messpunkt</th>
|
||||||
|
<th class="num">Dauer</th><th>Status</th><th>Aktion</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in messungen %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ m.gemessen_am.strftime('%H:%M:%S') }}</td>
|
||||||
|
<td>{{ m.turnier_id }}</td>
|
||||||
|
<td>{{ m.startnummer_gemeldet or '–' }}</td>
|
||||||
|
<td>{{ m.messpunkt }}</td>
|
||||||
|
<td class="num">{{ '%.2f' % m.dauer_sekunden }}</td>
|
||||||
|
<td><span class="badge">{{ m.status.value }}</span></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/api/v1/admin/zeitmessungen/{{ m.id }}/zuordnen" class="inline">
|
||||||
|
<input type="number" name="startnummer" placeholder="Nr." style="width:4rem" required>
|
||||||
|
<button type="submit">zuordnen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7">Keine offenen Messungen. 👍</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="mini">Hinweis: Die Zuordnen-Aktion nutzt die Admin-API (JSON). Für die
|
||||||
|
produktive Bedienung wird hier ein HTMX-Formular mit JSON-Encoding ergänzt.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<tr id="start-{{ s.id }}">
|
||||||
|
<td>{{ s.startnummer }}</td>
|
||||||
|
<td>{{ s.fahrer.voller_name if s.fahrer else '?' }}</td>
|
||||||
|
<td>{{ s.klasse.code if s.klasse else '–' }}</td>
|
||||||
|
<td>
|
||||||
|
<form hx-post="/intern/erfassung/{{ turnier_id }}/start/{{ s.id }}"
|
||||||
|
hx-target="#start-{{ s.id }}" hx-swap="outerHTML">
|
||||||
|
<input type="text" name="summe_zeit" inputmode="decimal" style="width:6rem"
|
||||||
|
value="{{ ('%.2f' % s.ergebnis.summe_zeit)|replace('.', ',') if s.ergebnis and s.ergebnis.summe_zeit is not none else '' }}">
|
||||||
|
<label class="mini"><input type="checkbox" name="zeit_fertig" value="true"
|
||||||
|
{{ 'checked' if s.ergebnis and s.ergebnis.zeit_vollstaendig }}> fertig</label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" name="summe_fehler" step="10" min="0" style="width:5rem"
|
||||||
|
value="{{ s.ergebnis.summe_fehler if s.ergebnis and s.ergebnis.summe_fehler is not none else '' }}">
|
||||||
|
<label class="mini"><input type="checkbox" name="fehler_fertig" value="true"
|
||||||
|
{{ 'checked' if s.ergebnis and s.ergebnis.fehler_vollstaendig }}> fertig</label>
|
||||||
|
<button type="submit">OK</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>{{ ('%.2f' % s.ergebnis.gesamt)|replace('.', ',') if s.ergebnis and s.ergebnis.gesamt is not none else '–' }}</td>
|
||||||
|
<td>{{ s.ergebnis.platzierung if s.ergebnis and s.ergebnis.platzierung is not none else '–' }}</td>
|
||||||
|
</tr>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<table id="fahrer-tabelle">
|
||||||
|
<thead><tr><th>Name</th><th>Jg.</th><th>Verein</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for f in fahrer %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/fahrer/{{ f.id }}">{{ f.nachname }} {{ f.vorname }}</a></td>
|
||||||
|
<td>{{ f.geburtsjahr or '–' }}</td>
|
||||||
|
<td>{{ f.verein.name if f.verein else '–' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="3">Keine Treffer.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Ergebnisse · {{ projekt }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Ergebnisse</h1>
|
||||||
|
{% for v in veranstaltungen %}
|
||||||
|
<section class="card">
|
||||||
|
<h2>{{ v.datum.strftime('%d.%m.%Y') }} – {{ v.titel }}</h2>
|
||||||
|
<ul class="plain">
|
||||||
|
{% for t in v.turniere %}
|
||||||
|
<li><a href="/ergebnisse/turnier/{{ t.id }}">Turnier {{ t.name }}</a>
|
||||||
|
<span class="badge">{{ t.status.value }}</span>
|
||||||
|
· <a href="/ergebnisse/turnier/{{ t.id }}.csv">CSV</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<p>Noch keine Ergebnisse.</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ veranstaltung.titel }} – {{ turnier.name }} · {{ projekt }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ veranstaltung.titel }}</h1>
|
||||||
|
<p>{{ veranstaltung.datum.strftime('%d.%m.%Y') }} · Turnier <strong>{{ turnier.name }}</strong>
|
||||||
|
<span class="badge">{{ turnier.status.value }}</span>
|
||||||
|
· <a href="/ergebnisse/turnier/{{ turnier.id }}.csv">CSV-Export</a></p>
|
||||||
|
|
||||||
|
{% if not final %}
|
||||||
|
<p class="hinweis">Vorläufiger Stand – solange Zeit oder Fehlerpunkte fehlen, ist die
|
||||||
|
Platzierung nicht endgültig.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for wk in wertungsklassen %}
|
||||||
|
<h2>{{ wk.wertungsklasse }}</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>Platz</th><th>Nr.</th><th>Name</th><th>Verein</th><th>Klasse</th>
|
||||||
|
<th class="num">Zeit</th><th class="num">Fehler</th><th class="num">Gesamt</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for z in wk.zeilen %}
|
||||||
|
<tr class="{{ 'vorlaeufig' if z.vorlaeufig else '' }}">
|
||||||
|
<td>{{ z.platzierung | platz }}</td>
|
||||||
|
<td>{{ z.startnummer }}</td>
|
||||||
|
<td>{% if z.fahrer %}<a href="/fahrer/{{ z.fahrer.id }}">{{ z.fahrer.name }}</a>{% endif %}
|
||||||
|
{% if z.status != 'gestartet' %}<span class="badge">{{ z.status }}</span>{% endif %}</td>
|
||||||
|
<td>{{ z.fahrer.verein if z.fahrer and z.fahrer.verein else '–' }}</td>
|
||||||
|
<td>{{ z.klasse or '–' }}</td>
|
||||||
|
<td class="num">{{ z.summe_zeit | zeit }}</td>
|
||||||
|
<td class="num">{{ z.summe_fehler if z.summe_fehler is not none else '–' }}</td>
|
||||||
|
<td class="num"><strong>{{ z.gesamt | zeit }}</strong></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>Für dieses Turnier liegen keine Startdaten vor.</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Fahrer · {{ projekt }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Fahrer</h1>
|
||||||
|
<input type="search" name="q" placeholder="Name suchen…" value="{{ q }}"
|
||||||
|
hx-get="/fahrer" hx-trigger="keyup changed delay:300ms" hx-target="#fahrer-tabelle"
|
||||||
|
hx-select="#fahrer-tabelle" hx-swap="outerHTML" autocomplete="off">
|
||||||
|
{% include "partials/fahrer_tabelle.html" %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ fahrer.anzeigename }} · {{ projekt }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ fahrer.anzeigename }}</h1>
|
||||||
|
{% if not fahrer.anzeige_anonymisiert %}
|
||||||
|
<p>{{ fahrer.geburtsjahr or '' }}{% if fahrer.verein %} · {{ fahrer.verein.name }}{% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card"><h2>{{ anzahl }}</h2><p>Starts</p></div>
|
||||||
|
<div class="card"><h2>{{ bestzeit | zeit }}</h2><p>Bestzeit (s)</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Starts</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Datum</th><th>Veranstaltung</th><th>Turnier</th><th>Klasse</th>
|
||||||
|
<th class="num">Zeit</th><th class="num">Fehler</th><th class="num">Platz</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in starts %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ s.turnier.veranstaltung.datum.strftime('%d.%m.%Y') }}</td>
|
||||||
|
<td><a href="/ergebnisse/turnier/{{ s.turnier.id }}">{{ s.turnier.veranstaltung.titel }}</a></td>
|
||||||
|
<td>{{ s.turnier.name }}</td>
|
||||||
|
<td>{{ s.klasse.code if s.klasse else '–' }}</td>
|
||||||
|
<td class="num">{{ s.ergebnis.summe_zeit | zeit if s.ergebnis else '–' }}</td>
|
||||||
|
<td class="num">{{ s.ergebnis.summe_fehler if s.ergebnis and s.ergebnis.summe_fehler is not none else '–' }}</td>
|
||||||
|
<td class="num">{{ s.ergebnis.platzierung | platz if s.ergebnis else '–' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7">Keine Starts.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Meisterschaftsstand · {{ projekt }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Meisterschaftsstand</h1>
|
||||||
|
<p>Saison {{ bericht.saison_jahr }} · Kategorie {{ bericht.kategorie }} ·
|
||||||
|
{{ bericht.laeufe_gewertet }} von {{ bericht.laeufe_gesamt }} Läufen gewertet
|
||||||
|
(Rest = Streichresultate)</p>
|
||||||
|
|
||||||
|
{% for h in bericht.hinweise %}
|
||||||
|
<p class="hinweis">{{ h }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Platz</th><th>Fahrer</th><th class="num">Punkte</th><th class="num">Siege</th>
|
||||||
|
<th class="num">Streichresultate</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for z in bericht.zeilen %}
|
||||||
|
{% set f = fahrer_map.get(z.fahrer_id) %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ z.platz }}</td>
|
||||||
|
<td>{% if f %}<a href="/fahrer/{{ f.id }}">{{ f.anzeigename }}</a>{% else %}#{{ z.fahrer_id }}{% endif %}</td>
|
||||||
|
<td class="num">{{ '%.0f' % z.punkte_gesamt }}</td>
|
||||||
|
<td class="num">{{ z.anzahl_siege }}</td>
|
||||||
|
<td class="num">{{ z.gestrichene_laeufe | length }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5">Noch keine gewerteten Läufe.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Meisterschaft · {{ projekt }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Meisterschaften</h1>
|
||||||
|
<ul class="plain">
|
||||||
|
{% for m in meisterschaften %}
|
||||||
|
<li><a href="/meisterschaft/{{ m.id }}">{{ m.bezeichnung }}</a>
|
||||||
|
<span class="badge">{{ m.kategorie.value }}</span></li>
|
||||||
|
{% else %}
|
||||||
|
<li>Keine Meisterschaft angelegt.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>ADAC Motorrad-Turniere</h1>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
<section class="card">
|
||||||
|
<h2>Nächster Termin</h2>
|
||||||
|
{% if naechste %}
|
||||||
|
<p><strong>{{ naechste.datum.strftime('%d.%m.%Y') }}</strong><br>
|
||||||
|
{{ naechste.titel }}{% if naechste.ort %} · {{ naechste.ort }}{% endif %}</p>
|
||||||
|
{% else %}
|
||||||
|
<p>Kein Termin geplant.</p>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/termine">Alle Termine →</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Letzte Ergebnisse</h2>
|
||||||
|
<ul class="plain">
|
||||||
|
{% for v in letzte %}
|
||||||
|
<li><a href="/ergebnisse">{{ v.datum.strftime('%d.%m.%Y') }} – {{ v.titel }}</a>
|
||||||
|
<span class="badge">{{ v.status.value }}</span></li>
|
||||||
|
{% else %}
|
||||||
|
<li>Noch keine Ergebnisse erfasst.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Meisterschaft</h2>
|
||||||
|
<ul class="plain">
|
||||||
|
{% for m in meisterschaften %}
|
||||||
|
<li><a href="/meisterschaft/{{ m.id }}">{{ m.bezeichnung }}</a></li>
|
||||||
|
{% else %}
|
||||||
|
<li>Keine Meisterschaft angelegt.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Termine · {{ projekt }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Termine{% if saison %} {{ saison }}{% endif %}</h1>
|
||||||
|
<p><a href="/termine.ics">iCal-Abo</a></p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Datum</th><th>Titel</th><th>Ort</th><th>Meisterschaft</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for v in veranstaltungen %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ v.datum.strftime('%d.%m.%Y') }}</td>
|
||||||
|
<td>{{ v.titel }}{% if v.ist_doppelturnier %} <span class="badge">Doppelturnier</span>{% endif %}</td>
|
||||||
|
<td>{{ v.ort or '–' }}</td>
|
||||||
|
<td>{% if v.turniere and v.turniere[0].zaehlt_meisterschaft %}mit{% else %}o. M.{% endif %}</td>
|
||||||
|
<td><span class="badge">{{ v.status.value }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5">Keine Termine.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Wertungs-Engine — das Kernstueck (Plan §3.3, AP 2).
|
||||||
|
|
||||||
|
Die Wertungsformel ist pro Kategorie und Saison konfigurierbar, nicht fest
|
||||||
|
verdrahtet. Abnahmetest: die reale Ergebnisliste vom 31.05.2026 muss exakt
|
||||||
|
reproduziert werden (``tests/test_wertung_regression_2026_05_31.py``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from oamc.wertung.engine import (
|
||||||
|
DEFAULT_REGEL_JE_KATEGORIE,
|
||||||
|
REGELN,
|
||||||
|
WertungsEingabe,
|
||||||
|
WertungsErgebnis,
|
||||||
|
platziere,
|
||||||
|
regel_fuer,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"WertungsEingabe",
|
||||||
|
"WertungsErgebnis",
|
||||||
|
"platziere",
|
||||||
|
"regel_fuer",
|
||||||
|
"REGELN",
|
||||||
|
"DEFAULT_REGEL_JE_KATEGORIE",
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user