commit 03f6fdb0d9efad792db4c1ce289d5a919edaa5bc Author: linus Date: Sat Sep 5 18:49:00 2026 +0200 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 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..b352f0a --- /dev/null +++ b/.claude/settings.json @@ -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)" + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..a861352 --- /dev/null +++ b/.claude/settings.local.json @@ -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'\\)\")" + ] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d475b46 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ac8639c --- /dev/null +++ b/.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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94b9cc2 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e050a7 --- /dev/null +++ b/README.md @@ -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 /oamc-turnier:0.1.0 -f docker/Dockerfile . +docker push /oamc-turnier:0.1.0 +``` + +Multi-Arch für den Raspberry Pi 4: + +```sh +docker buildx build --platform linux/arm64,linux/amd64 \ + -t /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 ` | `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. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..e4f915e --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..bd82b7e --- /dev/null +++ b/debian/changelog @@ -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 Fri, 05 Sep 2026 18:00:00 +0200 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..b63db8d --- /dev/null +++ b/debian/control @@ -0,0 +1,29 @@ +Source: oamc-turnier +Section: web +Priority: optional +Maintainer: OAMC Reinheim e.V. im ADAC +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. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..351e68f --- /dev/null +++ b/debian/copyright @@ -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. diff --git a/debian/env/oamc-turnier.env b/debian/env/oamc-turnier.env new file mode 100644 index 0000000..66cd9ba --- /dev/null +++ b/debian/env/oamc-turnier.env @@ -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 diff --git a/debian/oamc-turnier.dirs b/debian/oamc-turnier.dirs new file mode 100644 index 0000000..7753e6e --- /dev/null +++ b/debian/oamc-turnier.dirs @@ -0,0 +1,2 @@ +var/oamc/uploads +etc/oamc-turnier diff --git a/debian/oamc-turnier.install b/debian/oamc-turnier.install new file mode 100644 index 0000000..3782696 --- /dev/null +++ b/debian/oamc-turnier.install @@ -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 diff --git a/debian/oamc-turnier.service b/debian/oamc-turnier.service new file mode 100644 index 0000000..c9e6173 --- /dev/null +++ b/debian/oamc-turnier.service @@ -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 diff --git a/debian/postinst b/debian/postinst new file mode 100755 index 0000000..46f6049 --- /dev/null +++ b/debian/postinst @@ -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 diff --git a/debian/postrm b/debian/postrm new file mode 100755 index 0000000..411347f --- /dev/null +++ b/debian/postrm @@ -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 diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..066c353 --- /dev/null +++ b/debian/rules @@ -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 diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/deploy/backup/pg_dump.sh b/deploy/backup/pg_dump.sh new file mode 100755 index 0000000..a5fc293 --- /dev/null +++ b/deploy/backup/pg_dump.sh @@ -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 diff --git a/deploy/nginx/oamc-turnier.conf b/deploy/nginx/oamc-turnier.conf new file mode 100644 index 0000000..0014778 --- /dev/null +++ b/deploy/nginx/oamc-turnier.conf @@ -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; + } +} diff --git a/deploy/systemd/oamc-turnier.service b/deploy/systemd/oamc-turnier.service new file mode 100644 index 0000000..995942d --- /dev/null +++ b/deploy/systemd/oamc-turnier.service @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..048e2ae --- /dev/null +++ b/docker-compose.yml @@ -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 /oamc-turnier:0.1.0 -f docker/Dockerfile . +# docker push /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: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..cee2955 --- /dev/null +++ b/docker/Dockerfile @@ -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"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..7b53dd2 --- /dev/null +++ b/docker/entrypoint.sh @@ -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 "$@" diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..9c80b3f --- /dev/null +++ b/docker/nginx.conf @@ -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; + } +} diff --git a/docs/zeitmessung-endpoint.md b/docs/zeitmessung-endpoint.md new file mode 100644 index 0000000..44392fe --- /dev/null +++ b/docs/zeitmessung-endpoint.md @@ -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 +``` + +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. diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..0214d4f --- /dev/null +++ b/migrations/env.py @@ -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() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..063ed2a --- /dev/null +++ b/migrations/script.py.mako @@ -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"} diff --git a/migrations/versions/df5e2dd5a864_initiales_schema.py b/migrations/versions/df5e2dd5a864_initiales_schema.py new file mode 100644 index 0000000..b466fd7 --- /dev/null +++ b/migrations/versions/df5e2dd5a864_initiales_schema.py @@ -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 ### diff --git a/packaging/apt-repo/README.md b/packaging/apt-repo/README.md new file mode 100644 index 0000000..6316e92 --- /dev/null +++ b/packaging/apt-repo/README.md @@ -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 " +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 +``` diff --git a/packaging/apt-repo/build-deb.sh b/packaging/apt-repo/build-deb.sh new file mode 100755 index 0000000..4d5f477 --- /dev/null +++ b/packaging/apt-repo/build-deb.sh @@ -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 diff --git a/packaging/apt-repo/distributions b/packaging/apt-repo/distributions new file mode 100644 index 0000000..b283afa --- /dev/null +++ b/packaging/apt-repo/distributions @@ -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 diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..e0a4c9f --- /dev/null +++ b/plan.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c812d21 --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/src/oamc/__init__.py b/src/oamc/__init__.py new file mode 100644 index 0000000..94b91b8 --- /dev/null +++ b/src/oamc/__init__.py @@ -0,0 +1,6 @@ +"""OAMC Reinheim — Auswertung von ADAC Motorrad-Turnieren. + +Fachliche Grundlagen und Architektur: siehe ``plan.md`` im Projektwurzelverzeichnis. +""" + +__version__ = "0.1.0" diff --git a/src/oamc/api/__init__.py b/src/oamc/api/__init__.py new file mode 100644 index 0000000..a1fe289 --- /dev/null +++ b/src/oamc/api/__init__.py @@ -0,0 +1 @@ +"""HTTP-API (FastAPI). Zwei Richtungen: Ingest (rein) und Read (raus). Plan §6.""" diff --git a/src/oamc/api/v1/__init__.py b/src/oamc/api/v1/__init__.py new file mode 100644 index 0000000..6ac8f80 --- /dev/null +++ b/src/oamc/api/v1/__init__.py @@ -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"] diff --git a/src/oamc/api/v1/admin.py b/src/oamc/api/v1/admin.py new file mode 100644 index 0000000..67f1a94 --- /dev/null +++ b/src/oamc/api/v1/admin.py @@ -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], + } diff --git a/src/oamc/api/v1/geraete.py b/src/oamc/api/v1/geraete.py new file mode 100644 index 0000000..8d01ee2 --- /dev/null +++ b/src/oamc/api/v1/geraete.py @@ -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)) diff --git a/src/oamc/api/v1/ingest.py b/src/oamc/api/v1/ingest.py new file mode 100644 index 0000000..20b7686 --- /dev/null +++ b/src/oamc/api/v1/ingest.py @@ -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], + } diff --git a/src/oamc/api/v1/read.py b/src/oamc/api/v1/read.py new file mode 100644 index 0000000..079cb0c --- /dev/null +++ b/src/oamc/api/v1/read.py @@ -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 + ], + } diff --git a/src/oamc/api/v1/serialisierung.py b/src/oamc/api/v1/serialisierung.py new file mode 100644 index 0000000..f114f64 --- /dev/null +++ b/src/oamc/api/v1/serialisierung.py @@ -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), + } diff --git a/src/oamc/cli.py b/src/oamc/cli.py new file mode 100644 index 0000000..3883927 --- /dev/null +++ b/src/oamc/cli.py @@ -0,0 +1,97 @@ +"""Kommandozeile: ``oamc-turnier ``. + +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()) diff --git a/src/oamc/config.py b/src/oamc/config.py new file mode 100644 index 0000000..f1ccb7a --- /dev/null +++ b/src/oamc/config.py @@ -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() diff --git a/src/oamc/db.py b/src/oamc/db.py new file mode 100644 index 0000000..eb12a40 --- /dev/null +++ b/src/oamc/db.py @@ -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() diff --git a/src/oamc/frontend/__init__.py b/src/oamc/frontend/__init__.py new file mode 100644 index 0000000..83d049f --- /dev/null +++ b/src/oamc/frontend/__init__.py @@ -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"] diff --git a/src/oamc/frontend/intern.py b/src/oamc/frontend/intern.py new file mode 100644 index 0000000..077abf7 --- /dev/null +++ b/src/oamc/frontend/intern.py @@ -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}) diff --git a/src/oamc/frontend/public.py b/src/oamc/frontend/public.py new file mode 100644 index 0000000..9bdf0ef --- /dev/null +++ b/src/oamc/frontend/public.py @@ -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" diff --git a/src/oamc/frontend/templating.py b/src/oamc/frontend/templating.py new file mode 100644 index 0000000..75a7416 --- /dev/null +++ b/src/oamc/frontend/templating.py @@ -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" diff --git a/src/oamc/geraete/__init__.py b/src/oamc/geraete/__init__.py new file mode 100644 index 0000000..4fad91f --- /dev/null +++ b/src/oamc/geraete/__init__.py @@ -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", +] diff --git a/src/oamc/geraete/service.py b/src/oamc/geraete/service.py new file mode 100644 index 0000000..5dba1ae --- /dev/null +++ b/src/oamc/geraete/service.py @@ -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 + ] diff --git a/src/oamc/importer/__init__.py b/src/oamc/importer/__init__.py new file mode 100644 index 0000000..fafc78e --- /dev/null +++ b/src/oamc/importer/__init__.py @@ -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", +] diff --git a/src/oamc/importer/ingest.py b/src/oamc/importer/ingest.py new file mode 100644 index 0000000..2219985 --- /dev/null +++ b/src/oamc/importer/ingest.py @@ -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 diff --git a/src/oamc/importer/xlsx.py b/src/oamc/importer/xlsx.py new file mode 100644 index 0000000..47afe11 --- /dev/null +++ b/src/oamc/importer/xlsx.py @@ -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 + ) diff --git a/src/oamc/main.py b/src/oamc/main.py new file mode 100644 index 0000000..f9256f5 --- /dev/null +++ b/src/oamc/main.py @@ -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 ` +* **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() diff --git a/src/oamc/matching/__init__.py b/src/oamc/matching/__init__.py new file mode 100644 index 0000000..bd637e9 --- /dev/null +++ b/src/oamc/matching/__init__.py @@ -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", +] diff --git a/src/oamc/matching/fahrer.py b/src/oamc/matching/fahrer.py new file mode 100644 index 0000000..0278831 --- /dev/null +++ b/src/oamc/matching/fahrer.py @@ -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 diff --git a/src/oamc/meisterschaft/__init__.py b/src/oamc/meisterschaft/__init__.py new file mode 100644 index 0000000..f30a330 --- /dev/null +++ b/src/oamc/meisterschaft/__init__.py @@ -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", +] diff --git a/src/oamc/meisterschaft/berechnung.py b/src/oamc/meisterschaft/berechnung.py new file mode 100644 index 0000000..fa7f248 --- /dev/null +++ b/src/oamc/meisterschaft/berechnung.py @@ -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 diff --git a/src/oamc/models/__init__.py b/src/oamc/models/__init__.py new file mode 100644 index 0000000..4946c6d --- /dev/null +++ b/src/oamc/models/__init__.py @@ -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", +] diff --git a/src/oamc/models/audit.py b/src/oamc/models/audit.py new file mode 100644 index 0000000..ac04d08 --- /dev/null +++ b/src/oamc/models/audit.py @@ -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 + ) diff --git a/src/oamc/models/base.py b/src/oamc/models/base.py new file mode 100644 index 0000000..16c73f7 --- /dev/null +++ b/src/oamc/models/base.py @@ -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, + ) diff --git a/src/oamc/models/benutzer.py b/src/oamc/models/benutzer.py new file mode 100644 index 0000000..a4760af --- /dev/null +++ b/src/oamc/models/benutzer.py @@ -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 diff --git a/src/oamc/models/enums.py b/src/oamc/models/enums.py new file mode 100644 index 0000000..f4aeabd --- /dev/null +++ b/src/oamc/models/enums.py @@ -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" diff --git a/src/oamc/models/fahrer.py b/src/oamc/models/fahrer.py new file mode 100644 index 0000000..7752c53 --- /dev/null +++ b/src/oamc/models/fahrer.py @@ -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 diff --git a/src/oamc/models/klasse.py b/src/oamc/models/klasse.py new file mode 100644 index 0000000..d07414c --- /dev/null +++ b/src/oamc/models/klasse.py @@ -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"" diff --git a/src/oamc/models/meisterschaft.py b/src/oamc/models/meisterschaft.py new file mode 100644 index 0000000..08fa438 --- /dev/null +++ b/src/oamc/models/meisterschaft.py @@ -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 diff --git a/src/oamc/models/organisation.py b/src/oamc/models/organisation.py new file mode 100644 index 0000000..47acb1e --- /dev/null +++ b/src/oamc/models/organisation.py @@ -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")) diff --git a/src/oamc/models/start.py b/src/oamc/models/start.py new file mode 100644 index 0000000..e1e52b3 --- /dev/null +++ b/src/oamc/models/start.py @@ -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) diff --git a/src/oamc/models/veranstaltung.py b/src/oamc/models/veranstaltung.py new file mode 100644 index 0000000..ab8d860 --- /dev/null +++ b/src/oamc/models/veranstaltung.py @@ -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 diff --git a/src/oamc/models/zeitmessung.py b/src/oamc/models/zeitmessung.py new file mode 100644 index 0000000..675d98e --- /dev/null +++ b/src/oamc/models/zeitmessung.py @@ -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 diff --git a/src/oamc/schemas/__init__.py b/src/oamc/schemas/__init__.py new file mode 100644 index 0000000..2c25776 --- /dev/null +++ b/src/oamc/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic-v2-Schemata: ein Schema fuer API-Validierung UND OpenAPI-Doku.""" diff --git a/src/oamc/schemas/geraete.py b/src/oamc/schemas/geraete.py new file mode 100644 index 0000000..3a723d1 --- /dev/null +++ b/src/oamc/schemas/geraete.py @@ -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) diff --git a/src/oamc/schemas/ingest.py b/src/oamc/schemas/ingest.py new file mode 100644 index 0000000..88896e5 --- /dev/null +++ b/src/oamc/schemas/ingest.py @@ -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 diff --git a/src/oamc/security/__init__.py b/src/oamc/security/__init__.py new file mode 100644 index 0000000..ebe2fe1 --- /dev/null +++ b/src/oamc/security/__init__.py @@ -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", +] diff --git a/src/oamc/security/deps.py b/src/oamc/security/deps.py new file mode 100644 index 0000000..ad0a48a --- /dev/null +++ b/src/oamc/security/deps.py @@ -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)] diff --git a/src/oamc/security/passwoerter.py b/src/oamc/security/passwoerter.py new file mode 100644 index 0000000..9dd00c6 --- /dev/null +++ b/src/oamc/security/passwoerter.py @@ -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 diff --git a/src/oamc/security/tokens.py b/src/oamc/security/tokens.py new file mode 100644 index 0000000..acec6d4 --- /dev/null +++ b/src/oamc/security/tokens.py @@ -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 diff --git a/src/oamc/seeds/__init__.py b/src/oamc/seeds/__init__.py new file mode 100644 index 0000000..edbf858 --- /dev/null +++ b/src/oamc/seeds/__init__.py @@ -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"] diff --git a/src/oamc/seeds/saison_2026.py b/src/oamc/seeds/saison_2026.py new file mode 100644 index 0000000..56ad5f9 --- /dev/null +++ b/src/oamc/seeds/saison_2026.py @@ -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 diff --git a/src/oamc/static/css/app.css b/src/oamc/static/css/app.css new file mode 100644 index 0000000..0db46ab --- /dev/null +++ b/src/oamc/static/css/app.css @@ -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; } +} diff --git a/src/oamc/static/js/htmx.min.js b/src/oamc/static/js/htmx.min.js new file mode 100644 index 0000000..59937d7 --- /dev/null +++ b/src/oamc/static/js/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/src/oamc/templates/anzeige/anzeige.html b/src/oamc/templates/anzeige/anzeige.html new file mode 100644 index 0000000..01df9fe --- /dev/null +++ b/src/oamc/templates/anzeige/anzeige.html @@ -0,0 +1,79 @@ + + + + + + Anzeige – OAMC Turnier + + + +
+
+ + +
+
+
+
+
+
+
+ Nächste Starter +
    +
    +
    + + + diff --git a/src/oamc/templates/base.html b/src/oamc/templates/base.html new file mode 100644 index 0000000..02fb452 --- /dev/null +++ b/src/oamc/templates/base.html @@ -0,0 +1,29 @@ + + + + + + {% if noindex %}{% endif %} + {% block title %}{{ projekt }}{% endblock %} + + + + +
    + OAMC Turnier + +
    +
    + {% block content %}{% endblock %} +
    +
    +

    OAMC Reinheim e.V. im ADAC · Turnierauswertung · API · iCal

    +
    + + diff --git a/src/oamc/templates/intern/dashboard.html b/src/oamc/templates/intern/dashboard.html new file mode 100644 index 0000000..5be7daa --- /dev/null +++ b/src/oamc/templates/intern/dashboard.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Intern · {{ projekt }}{% endblock %} +{% block content %} +

    Intern

    +

    Angemeldet als {{ benutzer.anzeigename }} ({{ benutzer.rolle.value }}) · + abmelden

    + +{% if hat_offene_messungen %} +

    Es gibt unzugeordnete oder unplausible + Lichtschranken-Messungen →

    +{% endif %} + + + +

    Veranstaltungen

    +
      +{% for v in veranstaltungen %} +
    • {{ v.datum.strftime('%d.%m.%Y') }} – {{ v.titel }} + {% for t in v.turniere %} + · Erfassung {{ t.name }} + {% endfor %} +
    • +{% else %} +
    • Keine Veranstaltungen. Anlegen über die Ingest-API (POST /api/v1/veranstaltungen).
    • +{% endfor %} +
    +{% endblock %} diff --git a/src/oamc/templates/intern/erfassung.html b/src/oamc/templates/intern/erfassung.html new file mode 100644 index 0000000..b377588 --- /dev/null +++ b/src/oamc/templates/intern/erfassung.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}Erfassung {{ turnier.name }} · {{ projekt }}{% endblock %} +{% block content %} +

    Ergebniserfassung – Turnier {{ turnier.name }}

    +

    Zeit im Format 159,16 · Fehler als Vielfaches von 10 · Haken = Wert endgültig. + Speichern per Enter im jeweiligen Feld.

    + + + + + + {% set turnier_id = turnier.id %} + {% for s in starts %} + {% include "partials/erfassung_zeile.html" with context %} + {% endfor %} + +
    Nr.FahrerKlasseZeitFehlerGesamtPlatz
    +{% endblock %} diff --git a/src/oamc/templates/intern/geraete.html b/src/oamc/templates/intern/geraete.html new file mode 100644 index 0000000..c5f6326 --- /dev/null +++ b/src/oamc/templates/intern/geraete.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Geräte · {{ projekt }}{% endblock %} +{% block content %} +

    Geräte am Parcours

    + + + + + {% for row in geraete %} + + + + + + + + + {% else %} + + {% endfor %} + +
    BezeichnungTypaktivHeartbeatQueueFirmware
    {{ row.g.bezeichnung }}{{ row.g.typ.value }}{{ 'ja' if row.g.aktiv else 'gesperrt' }} + {% if row.sekunden is none %}nie + {% elif row.lebt %}vor {{ row.sekunden }} s + {% else %}vor {{ row.sekunden }} s{% endif %} + {{ row.g.queue_laenge_gemeldet if row.g.queue_laenge_gemeldet is not none else '–' }}{{ row.g.firmware_version or '–' }}
    Keine Geräte registriert. Anlegen via + POST /api/v1/admin/geraete.
    +{% endblock %} diff --git a/src/oamc/templates/intern/login.html b/src/oamc/templates/intern/login.html new file mode 100644 index 0000000..460f3b1 --- /dev/null +++ b/src/oamc/templates/intern/login.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}Anmeldung · {{ projekt }}{% endblock %} +{% block content %} +

    Interne Anmeldung

    +{% if fehler %}

    {{ fehler }}

    {% endif %} +
    + + + +
    +{% endblock %} diff --git a/src/oamc/templates/intern/messungen.html b/src/oamc/templates/intern/messungen.html new file mode 100644 index 0000000..415e3be --- /dev/null +++ b/src/oamc/templates/intern/messungen.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Messungen zuordnen · {{ projekt }}{% endblock %} +{% block content %} +

    Messungen zuordnen

    +

    Unzugeordnete und unplausible Lichtschranken-Messungen. Eine gemessene Zeit ist nicht + reproduzierbar – sie wird nur mit Grund verworfen, nie stillschweigend.

    + + + + + {% for m in messungen %} + + + + + + + + + + {% else %} + + {% endfor %} + +
    ZeitpunktTurniergemeldete Nr.MesspunktDauerStatusAktion
    {{ m.gemessen_am.strftime('%H:%M:%S') }}{{ m.turnier_id }}{{ m.startnummer_gemeldet or '–' }}{{ m.messpunkt }}{{ '%.2f' % m.dauer_sekunden }}{{ m.status.value }} +
    + + +
    +
    Keine offenen Messungen. 👍
    +

    Hinweis: Die Zuordnen-Aktion nutzt die Admin-API (JSON). Für die + produktive Bedienung wird hier ein HTMX-Formular mit JSON-Encoding ergänzt.

    +{% endblock %} diff --git a/src/oamc/templates/partials/erfassung_zeile.html b/src/oamc/templates/partials/erfassung_zeile.html new file mode 100644 index 0000000..d39e7c7 --- /dev/null +++ b/src/oamc/templates/partials/erfassung_zeile.html @@ -0,0 +1,23 @@ + + {{ s.startnummer }} + {{ s.fahrer.voller_name if s.fahrer else '?' }} + {{ s.klasse.code if s.klasse else '–' }} + +
    + + + + + + + +
    + + {{ ('%.2f' % s.ergebnis.gesamt)|replace('.', ',') if s.ergebnis and s.ergebnis.gesamt is not none else '–' }} + {{ s.ergebnis.platzierung if s.ergebnis and s.ergebnis.platzierung is not none else '–' }} + diff --git a/src/oamc/templates/partials/fahrer_tabelle.html b/src/oamc/templates/partials/fahrer_tabelle.html new file mode 100644 index 0000000..338e7c9 --- /dev/null +++ b/src/oamc/templates/partials/fahrer_tabelle.html @@ -0,0 +1,14 @@ + + + + {% for f in fahrer %} + + + + + + {% else %} + + {% endfor %} + +
    NameJg.Verein
    {{ f.nachname }} {{ f.vorname }}{{ f.geburtsjahr or '–' }}{{ f.verein.name if f.verein else '–' }}
    Keine Treffer.
    diff --git a/src/oamc/templates/public/ergebnisse_liste.html b/src/oamc/templates/public/ergebnisse_liste.html new file mode 100644 index 0000000..9f311ac --- /dev/null +++ b/src/oamc/templates/public/ergebnisse_liste.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Ergebnisse · {{ projekt }}{% endblock %} +{% block content %} +

    Ergebnisse

    +{% for v in veranstaltungen %} +
    +

    {{ v.datum.strftime('%d.%m.%Y') }} – {{ v.titel }}

    + +
    +{% else %} +

    Noch keine Ergebnisse.

    +{% endfor %} +{% endblock %} diff --git a/src/oamc/templates/public/ergebnisse_turnier.html b/src/oamc/templates/public/ergebnisse_turnier.html new file mode 100644 index 0000000..45f6ca2 --- /dev/null +++ b/src/oamc/templates/public/ergebnisse_turnier.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}{{ veranstaltung.titel }} – {{ turnier.name }} · {{ projekt }}{% endblock %} +{% block content %} +

    {{ veranstaltung.titel }}

    +

    {{ veranstaltung.datum.strftime('%d.%m.%Y') }} · Turnier {{ turnier.name }} + {{ turnier.status.value }} + · CSV-Export

    + +{% if not final %} +

    Vorläufiger Stand – solange Zeit oder Fehlerpunkte fehlen, ist die + Platzierung nicht endgültig.

    +{% endif %} + +{% for wk in wertungsklassen %} +

    {{ wk.wertungsklasse }}

    + + + + + + + {% for z in wk.zeilen %} + + + + + + + + + + + {% endfor %} + +
    PlatzNr.NameVereinKlasseZeitFehlerGesamt
    {{ z.platzierung | platz }}{{ z.startnummer }}{% if z.fahrer %}{{ z.fahrer.name }}{% endif %} + {% if z.status != 'gestartet' %}{{ z.status }}{% endif %}{{ z.fahrer.verein if z.fahrer and z.fahrer.verein else '–' }}{{ z.klasse or '–' }}{{ z.summe_zeit | zeit }}{{ z.summe_fehler if z.summe_fehler is not none else '–' }}{{ z.gesamt | zeit }}
    +{% else %} +

    Für dieses Turnier liegen keine Startdaten vor.

    +{% endfor %} +{% endblock %} diff --git a/src/oamc/templates/public/fahrer_liste.html b/src/oamc/templates/public/fahrer_liste.html new file mode 100644 index 0000000..3f10b5c --- /dev/null +++ b/src/oamc/templates/public/fahrer_liste.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}Fahrer · {{ projekt }}{% endblock %} +{% block content %} +

    Fahrer

    + +{% include "partials/fahrer_tabelle.html" %} +{% endblock %} diff --git a/src/oamc/templates/public/fahrer_profil.html b/src/oamc/templates/public/fahrer_profil.html new file mode 100644 index 0000000..51ae3bd --- /dev/null +++ b/src/oamc/templates/public/fahrer_profil.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}{{ fahrer.anzeigename }} · {{ projekt }}{% endblock %} +{% block content %} +

    {{ fahrer.anzeigename }}

    +{% if not fahrer.anzeige_anonymisiert %} +

    {{ fahrer.geburtsjahr or '' }}{% if fahrer.verein %} · {{ fahrer.verein.name }}{% endif %}

    +{% endif %} + +
    +

    {{ anzahl }}

    Starts

    +

    {{ bestzeit | zeit }}

    Bestzeit (s)

    +
    + +

    Starts

    + + + + + {% for s in starts %} + + + + + + + + + + {% else %} + + {% endfor %} + +
    DatumVeranstaltungTurnierKlasseZeitFehlerPlatz
    {{ s.turnier.veranstaltung.datum.strftime('%d.%m.%Y') }}{{ s.turnier.veranstaltung.titel }}{{ s.turnier.name }}{{ s.klasse.code if s.klasse else '–' }}{{ s.ergebnis.summe_zeit | zeit if s.ergebnis else '–' }}{{ s.ergebnis.summe_fehler if s.ergebnis and s.ergebnis.summe_fehler is not none else '–' }}{{ s.ergebnis.platzierung | platz if s.ergebnis else '–' }}
    Keine Starts.
    +{% endblock %} diff --git a/src/oamc/templates/public/meisterschaft_detail.html b/src/oamc/templates/public/meisterschaft_detail.html new file mode 100644 index 0000000..e153967 --- /dev/null +++ b/src/oamc/templates/public/meisterschaft_detail.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Meisterschaftsstand · {{ projekt }}{% endblock %} +{% block content %} +

    Meisterschaftsstand

    +

    Saison {{ bericht.saison_jahr }} · Kategorie {{ bericht.kategorie }} · + {{ bericht.laeufe_gewertet }} von {{ bericht.laeufe_gesamt }} Läufen gewertet + (Rest = Streichresultate)

    + +{% for h in bericht.hinweise %} +

    {{ h }}

    +{% endfor %} + + + + + + {% for z in bericht.zeilen %} + {% set f = fahrer_map.get(z.fahrer_id) %} + + + + + + + + {% else %} + + {% endfor %} + +
    PlatzFahrerPunkteSiegeStreich­resultate
    {{ z.platz }}{% if f %}{{ f.anzeigename }}{% else %}#{{ z.fahrer_id }}{% endif %}{{ '%.0f' % z.punkte_gesamt }}{{ z.anzahl_siege }}{{ z.gestrichene_laeufe | length }}
    Noch keine gewerteten Läufe.
    +{% endblock %} diff --git a/src/oamc/templates/public/meisterschaft_liste.html b/src/oamc/templates/public/meisterschaft_liste.html new file mode 100644 index 0000000..2971421 --- /dev/null +++ b/src/oamc/templates/public/meisterschaft_liste.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}Meisterschaft · {{ projekt }}{% endblock %} +{% block content %} +

    Meisterschaften

    +
      +{% for m in meisterschaften %} +
    • {{ m.bezeichnung }} + {{ m.kategorie.value }}
    • +{% else %} +
    • Keine Meisterschaft angelegt.
    • +{% endfor %} +
    +{% endblock %} diff --git a/src/oamc/templates/public/start.html b/src/oamc/templates/public/start.html new file mode 100644 index 0000000..cae75da --- /dev/null +++ b/src/oamc/templates/public/start.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block content %} +

    ADAC Motorrad-Turniere

    + +
    +
    +

    Nächster Termin

    + {% if naechste %} +

    {{ naechste.datum.strftime('%d.%m.%Y') }}
    + {{ naechste.titel }}{% if naechste.ort %} · {{ naechste.ort }}{% endif %}

    + {% else %} +

    Kein Termin geplant.

    + {% endif %} + Alle Termine → +
    + +
    +

    Letzte Ergebnisse

    + +
    + +
    +

    Meisterschaft

    +
      + {% for m in meisterschaften %} +
    • {{ m.bezeichnung }}
    • + {% else %} +
    • Keine Meisterschaft angelegt.
    • + {% endfor %} +
    +
    +
    +{% endblock %} diff --git a/src/oamc/templates/public/termine.html b/src/oamc/templates/public/termine.html new file mode 100644 index 0000000..f12e474 --- /dev/null +++ b/src/oamc/templates/public/termine.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Termine · {{ projekt }}{% endblock %} +{% block content %} +

    Termine{% if saison %} {{ saison }}{% endif %}

    +

    iCal-Abo

    + + + + {% for v in veranstaltungen %} + + + + + + + + {% else %} + + {% endfor %} + +
    DatumTitelOrtMeisterschaftStatus
    {{ v.datum.strftime('%d.%m.%Y') }}{{ v.titel }}{% if v.ist_doppelturnier %} Doppelturnier{% endif %}{{ v.ort or '–' }}{% if v.turniere and v.turniere[0].zaehlt_meisterschaft %}mit{% else %}o. M.{% endif %}{{ v.status.value }}
    Keine Termine.
    +{% endblock %} diff --git a/src/oamc/wertung/__init__.py b/src/oamc/wertung/__init__.py new file mode 100644 index 0000000..44f4b21 --- /dev/null +++ b/src/oamc/wertung/__init__.py @@ -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", +] diff --git a/src/oamc/wertung/engine.py b/src/oamc/wertung/engine.py new file mode 100644 index 0000000..6c0088e --- /dev/null +++ b/src/oamc/wertung/engine.py @@ -0,0 +1,210 @@ +"""Wertungsformeln und Platzierung. + +Zwei Berechnungen fuer ``Gesamt`` (Plan §3.3), abgeleitet aus der Ergebnisliste +vom 31.05.2026 und **vor Produktivgang gegen die ADAC-Turnierordnung 2026 zu +verifizieren** (Plan §10, Risiko 1): + +a) Erwachsene (Kategorie S und A): + Gesamt = Summe Zeit + Summe Fehler + Platzierung = aufsteigend nach Gesamt + +b) Jugend (Kategorie J): + Gesamt = Summe Fehler (Zeit geht NICHT in die Wertung ein) + Platzierung = aufsteigend nach Fehler, bei Gleichstand aufsteigend nach Zeit + +Die Zuordnung Kategorie -> Regel ist ein Default und pro Saison/Meisterschaft +ueberschreibbar (``regel_name``). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Protocol + +from oamc.models.enums import Kategorie, StartStatus + +_ZERO = Decimal("0.00") +_CENT = Decimal("0.01") + +# Status, die eine Wertung ausschliessen. DNF/DNS/DSQ-Detailbehandlung in Tages- +# und Jahreswertung ist offene Frage O-5 — hier konservativ: nicht klassiert. +NICHT_KLASSIERT = frozenset({StartStatus.DNF, StartStatus.DNS, StartStatus.DSQ}) + + +@dataclass(frozen=True) +class WertungsEingabe: + start_id: int + kategorie: Kategorie + status: StartStatus = StartStatus.GESTARTET + summe_zeit: Decimal | None = None + summe_fehler: int | None = None + # Wird beide Quellen (§3.6) beruecksichtigt: nur wenn True fliesst der Start + # in die Platzierung ein. Fehlt eine Quelle -> vorlaeufig, ohne Platz. + zeit_vollstaendig: bool = True + fehler_vollstaendig: bool = True + + @property + def vollstaendig(self) -> bool: + return self.zeit_vollstaendig and self.fehler_vollstaendig + + +@dataclass +class WertungsErgebnis: + start_id: int + gesamt: Decimal | None = None + platzierung: int | None = None + gewertet: bool = False + hinweis: str | None = None + # frei fuer Debugging/Anzeige + sortier_schluessel: tuple = field(default_factory=tuple) + + +class Wertungsregel(Protocol): + """Strategie je Kategorie (Plan §3.3: "als Strategie/Enum je Kategorie und Saison").""" + + name: str + + def gesamt(self, zeit: Decimal | None, fehler: int | None) -> Decimal: ... + + def sortier_schluessel( + self, zeit: Decimal | None, fehler: int | None, gesamt: Decimal + ) -> tuple: ... + + +class SummeZeitPlusFehler: + """Erwachsene (S, A): Gesamt = Zeit + Fehler, aufsteigend.""" + + name = "summe_zeit_plus_fehler" + + def gesamt(self, zeit: Decimal | None, fehler: int | None) -> Decimal: + z = Decimal(zeit) if zeit is not None else _ZERO + f = Decimal(fehler or 0) + return (z + f).quantize(_CENT) + + def sortier_schluessel( + self, zeit: Decimal | None, fehler: int | None, gesamt: Decimal + ) -> tuple: + # primaer Gesamt; bei exaktem Gleichstand weniger Fehler, dann schnellere Zeit + return ( + gesamt, + Decimal(fehler or 0), + Decimal(zeit) if zeit is not None else Decimal("9" * 9), + ) + + +class JugendNurFehler: + """Jugend (J): Gesamt = Fehler; Platzierung nach Fehler, bei Gleichstand nach Zeit.""" + + name = "jugend_nur_fehler" + + def gesamt(self, zeit: Decimal | None, fehler: int | None) -> Decimal: + return Decimal(fehler or 0).quantize(_CENT) + + def sortier_schluessel( + self, zeit: Decimal | None, fehler: int | None, gesamt: Decimal + ) -> tuple: + return ( + Decimal(fehler or 0), + Decimal(zeit) if zeit is not None else Decimal("9" * 9), + ) + + +REGELN: dict[str, Wertungsregel] = { + r.name: r # type: ignore[misc] + for r in (SummeZeitPlusFehler(), JugendNurFehler()) +} + +DEFAULT_REGEL_JE_KATEGORIE: dict[Kategorie, str] = { + Kategorie.JUGEND: JugendNurFehler.name, + Kategorie.STAMM: SummeZeitPlusFehler.name, + Kategorie.ANFAENGER: SummeZeitPlusFehler.name, +} + + +def regel_fuer(kategorie: Kategorie, override: dict[str, str] | None = None) -> Wertungsregel: + """Regel fuer eine Kategorie aufloesen. ``override`` z. B. aus ``saison`` oder + ``meisterschaft.regelwerk`` (Kategorie-Wert -> Regelname).""" + + name = None + if override: + name = override.get(kategorie.value) or override.get(kategorie.name) + name = name or DEFAULT_REGEL_JE_KATEGORIE[kategorie] + try: + return REGELN[name] + except KeyError as exc: # pragma: no cover - Konfigurationsfehler + raise ValueError(f"Unbekannte Wertungsregel: {name!r}") from exc + + +def platziere( + eingaben: list[WertungsEingabe], + *, + regel_override: dict[str, str] | None = None, +) -> list[WertungsErgebnis]: + """Eine Wertungsklasse platzieren. + + Alle ``eingaben`` gehoeren zu **einer** Wertungsklasse (evtl. mehrere + zusammengelegte Klassen, Plan §3.2). Es wird EINE Regel verwendet — die der + Kategorie des ersten wertbaren Starts; gemischte Kategorien in einer + Wertungsklasse sind laut den realen Listen nicht vorgesehen und werden als + Hinweis markiert. + + Rueckgabe in derselben Reihenfolge wie ``eingaben``. + """ + + ergebnisse: dict[int, WertungsErgebnis] = { + e.start_id: WertungsErgebnis(start_id=e.start_id) for e in eingaben + } + + kandidaten: list[tuple[tuple, WertungsEingabe]] = [] + leit_kategorie: Kategorie | None = None + + for e in eingaben: + res = ergebnisse[e.start_id] + + if e.status in NICHT_KLASSIERT: + res.hinweis = f"nicht klassiert ({e.status.value})" + res.gewertet = False + continue + + if not e.vollstaendig: + fehlend = [] + if not e.zeit_vollstaendig: + fehlend.append("Zeit") + if not e.fehler_vollstaendig: + fehlend.append("Fehler") + res.hinweis = "vorlaeufig — es fehlt: " + " + ".join(fehlend) + res.gewertet = False + # Gesamt schon vorlaeufig berechnen, damit die Live-Ansicht etwas zeigt + regel = regel_fuer(e.kategorie, regel_override) + res.gesamt = regel.gesamt(e.summe_zeit, e.summe_fehler) + continue + + if leit_kategorie is None: + leit_kategorie = e.kategorie + regel = regel_fuer(e.kategorie, regel_override) + gesamt = regel.gesamt(e.summe_zeit, e.summe_fehler) + schluessel = regel.sortier_schluessel(e.summe_zeit, e.summe_fehler, gesamt) + res.gesamt = gesamt + res.sortier_schluessel = schluessel + if e.kategorie != leit_kategorie: + res.hinweis = ( + f"gemischte Kategorien in einer Wertungsklasse " + f"({leit_kategorie.value} vs. {e.kategorie.value}) — pruefen" + ) + kandidaten.append((schluessel, e)) + + # Standard-Wettkampfrang (1,2,2,4): gleicher Sortierschluessel -> gleicher Platz, + # danach entsprechende Luecke. + kandidaten.sort(key=lambda t: t[0]) + vorheriger_schluessel: tuple | None = None + aktueller_platz = 0 + for index, (schluessel, e) in enumerate(kandidaten, start=1): + if schluessel != vorheriger_schluessel: + aktueller_platz = index + vorheriger_schluessel = schluessel + res = ergebnisse[e.start_id] + res.platzierung = aktueller_platz + res.gewertet = True + + return [ergebnisse[e.start_id] for e in eingaben] diff --git a/src/oamc/wertung/service.py b/src/oamc/wertung/service.py new file mode 100644 index 0000000..b725ecc --- /dev/null +++ b/src/oamc/wertung/service.py @@ -0,0 +1,170 @@ +"""Bindeglied zwischen Wertungs-Engine und Datenbank. + +* ``summe_zeit_fuer_start`` — Summe der gueltigen Einzelmessungen (Plan §3.6a). +* ``turnier_auswerten`` — alle Wertungsklassen eines Turniers platzieren und die + Ergebnisse zurueckschreiben. +* ``pruefe_gegen_erwartung`` — importierte ``gesamt``/``platzierung`` gegen die + Neuberechnung stellen (Plan §4 Kernprinzip). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from oamc.models import Ergebnis, Start, Turnier, Wertungsklasse, Zeitmessung +from oamc.models.enums import ZeitmessungStatus +from oamc.wertung.engine import WertungsEingabe, platziere + + +def summe_zeit_fuer_start(db: Session, start_id: int) -> Decimal | None: + """Summe ``dauer_sekunden`` aller gueltigen, zugeordneten Messungen des Starts. + + Rueckweg gibt es nicht (Plan §3.6a): aus Einzelmessungen laesst sich die Summe + jederzeit bilden, andersherum nie. ``None`` wenn (noch) keine Messung vorliegt. + """ + + werte = db.scalars( + select(Zeitmessung.dauer_sekunden).where( + Zeitmessung.start_id == start_id, + Zeitmessung.status == ZeitmessungStatus.GUELTIG, + ) + ).all() + if not werte: + return None + return sum((Decimal(w) for w in werte), Decimal("0")).quantize(Decimal("0.01")) + + +@dataclass +class Abweichung: + start_id: int + startnummer: int | None + feld: str + erwartet: object + berechnet: object + + +@dataclass +class AuswertungsBericht: + turnier_id: int + gewertete_starts: int + vorlaeufige_starts: int + wertungsklassen: int + abweichungen: list[Abweichung] + + +def _lade_wertungsklassen(db: Session, turnier_id: int) -> list[Wertungsklasse]: + return list( + db.scalars( + select(Wertungsklasse) + .where(Wertungsklasse.turnier_id == turnier_id) + .order_by(Wertungsklasse.sortierung) + ) + ) + + +def turnier_auswerten( + db: Session, + turnier_id: int, + *, + zeit_aus_messungen: bool = True, + regel_override: dict[str, str] | None = None, + schreiben: bool = True, +) -> AuswertungsBericht: + """Alle Wertungsklassen des Turniers platzieren. + + ``zeit_aus_messungen``: ``ergebnis.summe_zeit`` vor der Wertung aus den + Einzelmessungen neu bilden. ``schreiben=False`` fuer eine reine Vorschau. + """ + + turnier = db.get(Turnier, turnier_id) + if turnier is None: + raise ValueError(f"Turnier {turnier_id} nicht gefunden") + + starts = list( + db.scalars( + select(Start) + .where(Start.turnier_id == turnier_id) + .options(selectinload(Start.ergebnis), selectinload(Start.klasse)) + ) + ) + start_je_id = {s.id: s for s in starts} + + abweichungen: list[Abweichung] = [] + gewertet = vorlaeufig = 0 + wk_liste = _lade_wertungsklassen(db, turnier_id) + + # Starts ohne Wertungsklasse werden je Einzelklasse gewertet (Fallback). + gruppen: dict[str, list[Start]] = {} + for wk in wk_liste: + gruppen[f"wk:{wk.id}"] = [] + for s in starts: + key = f"wk:{s.wertungsklasse_id}" if s.wertungsklasse_id else f"klasse:{s.klasse_id}" + gruppen.setdefault(key, []).append(s) + + for _key, gruppe in gruppen.items(): + if not gruppe: + continue + eingaben: list[WertungsEingabe] = [] + for s in gruppe: + erg = s.ergebnis + if zeit_aus_messungen and erg is not None: + neue_summe = summe_zeit_fuer_start(db, s.id) + if neue_summe is not None: + erg.summe_zeit = neue_summe + eingaben.append( + WertungsEingabe( + start_id=s.id, + kategorie=s.klasse.kategorie, + status=s.status, + summe_zeit=erg.summe_zeit if erg else None, + summe_fehler=erg.summe_fehler if erg else None, + zeit_vollstaendig=bool(erg and erg.zeit_vollstaendig), + fehler_vollstaendig=bool(erg and erg.fehler_vollstaendig), + ) + ) + + for res in platziere(eingaben, regel_override=regel_override): + s = start_je_id[res.start_id] + erg = s.ergebnis + if erg is None: + erg = Ergebnis(start_id=s.id) + s.ergebnis = erg + db.add(erg) + + # Erwartungswerte (aus Import) sichern, bevor ueberschrieben wird + if erg.gesamt is not None and res.gesamt is not None and not res.gewertet: + pass # vorlaeufig -> Erwartungspruefung unten nur bei gewertet + + if res.gewertet: + gewertet += 1 + if erg.gesamt is not None and Decimal(erg.gesamt) != res.gesamt: + abweichungen.append( + Abweichung(s.id, s.startnummer, "gesamt", erg.gesamt, res.gesamt) + ) + if erg.platzierung is not None and erg.platzierung != res.platzierung: + abweichungen.append( + Abweichung( + s.id, s.startnummer, "platzierung", erg.platzierung, res.platzierung + ) + ) + else: + vorlaeufig += 1 + + if schreiben: + erg.gesamt = res.gesamt + erg.platzierung = res.platzierung + + if not schreiben: + db.expire_all() + + return AuswertungsBericht( + turnier_id=turnier_id, + gewertete_starts=gewertet, + vorlaeufige_starts=vorlaeufig, + wertungsklassen=len(wk_liste), + abweichungen=abweichungen, + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0097a1c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,106 @@ +"""Pytest-Fixtures. + +Nutzt eine echte PostgreSQL-Testdatenbank (Plan §5.2: kein SQLite, wegen JSONB / +ARRAY / echten Constraints). URL ueber ``OAMC_DATABASE_URL`` oder Default +``postgresql+psycopg://linus:linus@localhost:5432/oamc_test``. + +Jeder Test laeuft in einer aussen zurueckgerollten Transaktion — die DB bleibt +sauber, Migrationen laufen einmalig. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session + +os.environ.setdefault( + "OAMC_DATABASE_URL", "postgresql+psycopg://linus:linus@localhost:5432/oamc_test" +) +os.environ.setdefault("OAMC_SECRET_KEY", "test-secret-key-0123456789abcdef0123") +os.environ.setdefault("OAMC_INGEST_API_KEYS", "test-key") + +PROJEKT_WURZEL = Path(__file__).resolve().parent.parent + + +@pytest.fixture(scope="session") +def _migriert() -> None: + """Schema einmal pro Testlauf frisch aufbauen.""" + + url = os.environ["OAMC_DATABASE_URL"] + admin = create_engine(url, isolation_level="AUTOCOMMIT") + with admin.connect() as conn: + conn.execute(text("DROP SCHEMA public CASCADE")) + conn.execute(text("CREATE SCHEMA public")) + admin.dispose() + subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + cwd=str(PROJEKT_WURZEL), + check=True, + env={**os.environ}, + ) + + +@pytest.fixture(scope="session") +def engine(_migriert): + from oamc.config import get_settings + + eng = create_engine(get_settings().database_url, future=True) + yield eng + eng.dispose() + + +@pytest.fixture +def db(engine) -> Session: + conn = engine.connect() + trans = conn.begin() + session = Session(bind=conn, join_transaction_mode="create_savepoint") + try: + yield session + finally: + session.close() + trans.rollback() + conn.close() + + +@pytest.fixture +def seed_2026(db): + from oamc.seeds import seed_saison_2026 + + seed_saison_2026(db) + db.flush() + return db + + +@pytest.fixture +def client(engine): + """TestClient mit auf die Testtransaktion gebundener DB-Session.""" + + from fastapi.testclient import TestClient + + from oamc.db import get_db + from oamc.main import app + + conn = engine.connect() + trans = conn.begin() + session = Session(bind=conn, join_transaction_mode="create_savepoint") + + def _get_db_override(): + # Kein Rollback bei HTTPException: die aeussere Testtransaktion raeumt auf. + # Ein Rollback hier wuerde den Savepoint mitsamt Fixture-Daten verwerfen. + yield session + session.flush() + + app.dependency_overrides[get_db] = _get_db_override + with TestClient(app) as c: + c.db = session # type: ignore[attr-defined] + yield c + app.dependency_overrides.clear() + session.close() + trans.rollback() + conn.close() diff --git a/tests/factories.py b/tests/factories.py new file mode 100644 index 0000000..da9367c --- /dev/null +++ b/tests/factories.py @@ -0,0 +1,78 @@ +"""Kleine Helfer zum Aufbau von Testdaten.""" + +from __future__ import annotations + +from datetime import date + +from sqlalchemy import select + +from oamc.models import ( + Ergebnis, + Fahrer, + Klasse, + Start, + Turnier, + Veranstaltung, + Verein, +) +from oamc.models.enums import StartStatus, TurnierStatus, VeranstaltungStatus + + +def klasse(db, code="J 3"): + return db.scalars(select(Klasse).where(Klasse.code == code, Klasse.saison_jahr == 2026)).one() + + +def verein(db, name="OAMC Reinheim e.V. im ADAC"): + return db.scalars(select(Verein).where(Verein.name == name)).first() + + +def veranstaltung(db, *, datum=date(2026, 5, 31), titel="Testturnier", status=VeranstaltungStatus.LAUFEND): + v = Veranstaltung(datum=datum, titel=titel, ort="Reinheim", status=status, ist_doppelturnier=False) + db.add(v) + db.flush() + return v + + +def turnier(db, v=None, *, name="Reinheim", zaehlt_meisterschaft=True, aktiv=False, + status=TurnierStatus.LAUFEND, flush=True): + v = v or veranstaltung(db) + t = Turnier( + veranstaltung_id=v.id, name=name, zaehlt_meisterschaft=zaehlt_meisterschaft, + ist_aktiv=aktiv, status=status, + ) + db.add(t) + if flush: + db.flush() + return t + + +def fahrer(db, nachname="Mustermann", vorname="Max", geburtsjahr=2014): + f = Fahrer(nachname=nachname, vorname=vorname, geburtsjahr=geburtsjahr) + db.add(f) + db.flush() + return f + + +def start(db, t, f, k, *, startnummer, status=StartStatus.GESTARTET, wertungsklasse_id=None, + flush=True): + s = Start( + turnier_id=t.id, fahrer_id=f.id, klasse_id=k.id, startnummer=startnummer, + status=status, wertungsklasse_id=wertungsklasse_id, + ) + db.add(s) + if flush: + db.flush() + return s + + +def ergebnis(db, s, *, zeit=None, fehler=None, zeit_fertig=True, fehler_fertig=True): + e = Ergebnis( + start_id=s.id, + summe_zeit=zeit, + summe_fehler=fehler, + zeit_vollstaendig=zeit_fertig, + fehler_vollstaendig=fehler_fertig, + ) + db.add(e) + db.flush() + return e diff --git a/tests/test_api_ingest.py b/tests/test_api_ingest.py new file mode 100644 index 0000000..7ab89bc --- /dev/null +++ b/tests/test_api_ingest.py @@ -0,0 +1,104 @@ +"""Ingest-API (Plan §6.1): Auth, dry_run, Idempotenz, Nachrechnung.""" + +from sqlalchemy import select + +from oamc.models import Ergebnis, Start, Turnier +from oamc.models.enums import VeranstaltungStatus +from oamc.seeds import seed_saison_2026 +from tests import factories as f + +HEAD = {"X-API-Key": "test-key"} + + +def _turnier(client): + db = client.db + seed_saison_2026(db) + v = f.veranstaltung(db, status=VeranstaltungStatus.LAUFEND) + t = f.turnier(db, v) + db.flush() + return t.id + + +def test_ohne_api_key_401(client): + r = client.post("/api/v1/turniere/1/ergebnisse", json={"quelle": "x", "idempotenz_schluessel": "k", "ergebnisse": []}) + assert r.status_code == 401 + + +def test_dry_run_meldet_neue_fahrer_ohne_zu_schreiben(client): + tid = _turnier(client) + payload = { + "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, + } + ], + } + r = client.post(f"/api/v1/turniere/{tid}/ergebnisse?dry_run=true", json=payload, headers=HEAD) + assert r.status_code == 200 + body = r.json() + assert body["dry_run"] is True + assert body["neue_fahrer"] == 1 + # nichts geschrieben + assert client.db.scalars(select(Start).where(Start.turnier_id == tid)).first() is None + + +def test_echter_upload_dann_idempotent(client): + tid = _turnier(client) + payload = { + "quelle": "excel-auswertung-v3", + "idempotenz_schluessel": "abc-final", + "ergebnisse": [ + {"startnummer": 1, "fahrer": {"nachname": "Bernius", "vorname": "Meik", "geburtsjahr": 1985}, + "klasse": "S 6", "wertungsklasse": "S 5, S 9, S 3, S 6, S 7", + "summe_zeit": "105.43", "summe_fehler": 0, + "gesamt_erwartet": "105.43", "platzierung_erwartet": 1}, + {"startnummer": 2, "fahrer": {"nachname": "Schmunk", "vorname": "Christian", "geburtsjahr": 1988}, + "klasse": "S 9", "wertungsklasse": "S 5, S 9, S 3, S 6, S 7", + "summe_zeit": "103.26", "summe_fehler": 10, + "gesamt_erwartet": "113.26", "platzierung_erwartet": 2}, + ], + } + r1 = client.post(f"/api/v1/turniere/{tid}/ergebnisse", json=payload, headers=HEAD) + assert r1.status_code == 200, r1.text + b1 = r1.json() + assert b1["angelegt"] == 2 + assert b1["abweichungen"] == [] # Nachrechnung deckt sich mit gesamt_erwartet + + r2 = client.post(f"/api/v1/turniere/{tid}/ergebnisse", json=payload, headers=HEAD) + assert r2.json()["idempotent_uebersprungen"] is True + assert client.db.scalars(select(Start).where(Start.turnier_id == tid)).all().__len__() == 2 + + +def test_abweichung_zur_erwartung_wird_gemeldet(client): + tid = _turnier(client) + payload = { + "quelle": "excel", "idempotenz_schluessel": "abw-1", + "ergebnisse": [ + {"startnummer": 1, "fahrer": {"nachname": "A", "vorname": "A", "geburtsjahr": 1985}, + "klasse": "S 6", "summe_zeit": "100.00", "summe_fehler": 0, + "gesamt_erwartet": "999.00", "platzierung_erwartet": 1}, + ], + } + r = client.post(f"/api/v1/turniere/{tid}/ergebnisse", json=payload, headers=HEAD) + body = r.json() + assert any(a["feld"] == "gesamt" for a in body["abweichungen"]) + assert body["warnungen"] + + +def test_finalisieren_setzt_status_final(client): + tid = _turnier(client) + client.post(f"/api/v1/turniere/{tid}/ergebnisse", json={ + "quelle": "x", "idempotenz_schluessel": "f1", + "ergebnisse": [ + {"startnummer": 1, "fahrer": {"nachname": "A", "vorname": "A", "geburtsjahr": 1985}, + "klasse": "S 6", "summe_zeit": "100.00", "summe_fehler": 0}, + ], + }, headers=HEAD) + r = client.post(f"/api/v1/turniere/{tid}/finalisieren", headers=HEAD) + assert r.status_code == 200 + assert client.db.get(Turnier, tid).status.value == "final" diff --git a/tests/test_frontend.py b/tests/test_frontend.py new file mode 100644 index 0000000..3c5ecb5 --- /dev/null +++ b/tests/test_frontend.py @@ -0,0 +1,86 @@ +"""Rauchtest der serverseitig gerenderten Seiten (AP 6/7).""" + +from datetime import date + +from oamc.seeds import seed_saison_2026 +from tests import factories as f + + +def test_startseite_rendert(client): + r = client.get("/") + assert r.status_code == 200 + assert "Motorrad-Turniere" in r.text + + +def test_robots_txt_sperrt_fahrerprofile(client): + r = client.get("/robots.txt") + assert "Disallow: /fahrer/" in r.text + + +def test_termine_ics(client): + seed_saison_2026(client.db) + f.veranstaltung(client.db, titel="Doppelturnier", datum=date(2026, 5, 31)) + r = client.get("/termine.ics") + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/calendar") + assert "BEGIN:VCALENDAR" in r.text + + +def test_ergebnisliste_und_csv(client): + db = client.db + seed_saison_2026(db) + t = f.turnier(db) + k = f.klasse(db, "J 3") + fahrer = f.fahrer(db, "Freund", "Philipp", 2014) + s = f.start(db, t, fahrer, k, startnummer=7) + e = f.ergebnis(db, s, zeit="136.88", fehler=0) + e.gesamt = 0 + e.platzierung = 1 + db.flush() + + html = client.get(f"/ergebnisse/turnier/{t.id}") + assert html.status_code == 200 + assert "Freund Philipp" in html.text + + csv = client.get(f"/ergebnisse/turnier/{t.id}.csv") + assert csv.status_code == 200 + assert "Freund Philipp" in csv.text + assert csv.headers["content-disposition"].endswith('.csv"') + + +def test_fahrerprofil_hat_noindex(client): + db = client.db + seed_saison_2026(db) + fahrer = f.fahrer(db, "Muster", "Max", 2014) + db.flush() + r = client.get(f"/fahrer/{fahrer.id}") + assert r.status_code == 200 + assert 'name="robots" content="noindex"' in r.text + + +def test_intern_ohne_login_leitet_um(client): + r = client.get("/intern", follow_redirects=False) + assert r.status_code in (307, 401) + + +def test_login_flow(client): + from oamc.models import Benutzer + from oamc.models.enums import BenutzerRolle + from oamc.security.passwoerter import hash_passwort + + client.db.add( + Benutzer( + benutzername="tester", + anzeigename="Tester", + passwort_hash=hash_passwort("geheim123"), + rolle=BenutzerRolle.AUSWERTER, + ) + ) + client.db.flush() + r = client.post( + "/intern/login", + data={"benutzername": "tester", "passwort": "geheim123"}, + follow_redirects=False, + ) + assert r.status_code == 303 + assert "oamc_session" in r.cookies diff --git a/tests/test_geraete_api.py b/tests/test_geraete_api.py new file mode 100644 index 0000000..b2d0b14 --- /dev/null +++ b/tests/test_geraete_api.py @@ -0,0 +1,118 @@ +"""Geraete-API / Zeitmessung (Plan §3.6, §6.2, AP 5). + +Abnahmetests: Batch doppelt senden -> keine Dubletten; Messung ohne zuordenbare +Startnummer -> gespeichert als 'unzugeordnet', nie verworfen. +""" + +import uuid +from datetime import UTC, datetime + +from sqlalchemy import func, select + +from oamc.models import Geraet, Zeitmessung +from oamc.models.enums import GeraetTyp, ZeitmessungStatus +from oamc.seeds import seed_saison_2026 +from oamc.security.tokens import erzeuge_geraete_token +from tests import factories as f + +HEAD_ADMIN = None # ueber Cookie unten + + +def _setup(client): + db = client.db + seed_saison_2026(db) + v = f.veranstaltung(db) + t = f.turnier(db, v, aktiv=True) + k = f.klasse(db, "J 3") + fahrer = f.fahrer(db, "Freund", "Philipp", 2014) + f.start(db, t, fahrer, k, startnummer=7) + roh, token_hash = erzeuge_geraete_token() + g = Geraet(bezeichnung="lichtschranke-ziel-01", typ=GeraetTyp.LICHTSCHRANKE, token_hash=token_hash) + db.add(g) + db.flush() + return t.id, roh + + +def _msg(turnier_id, startnummer, dauer="159.16", mid=None): + return { + "id": str(mid or uuid.uuid4()), + "turnier_id": turnier_id, + "startnummer_gemeldet": startnummer, + "messpunkt": "durchgang-1", + "dauer_sekunden": dauer, + "gemessen_am": datetime.now(UTC).isoformat(), + "monotonic_ns": 884213771004, + "roh": {"trigger_start_ns": 884054611004, "trigger_ziel_ns": 884213771004}, + } + + +def test_ohne_token_401(client): + r = client.post("/api/v1/zeitmessungen", json={"messungen": [_msg(1, 7)]}) + assert r.status_code == 401 + + +def test_messung_wird_angenommen_und_zugeordnet(client): + tid, token = _setup(client) + h = {"Authorization": f"Bearer {token}"} + r = client.post("/api/v1/zeitmessungen", json={"messungen": [_msg(tid, 7)]}, headers=h) + assert r.status_code == 200 + body = r.json() + assert body["angenommen"] == 1 + assert body["quittungen"][0]["status"] == "angenommen" + assert body["quittungen"][0]["start_id"] is not None + + +def test_batch_doppelt_senden_keine_dubletten(client): + tid, token = _setup(client) + h = {"Authorization": f"Bearer {token}"} + mid = uuid.uuid4() + batch = {"messungen": [_msg(tid, 7, mid=mid)]} + client.post("/api/v1/zeitmessungen", json=batch, headers=h) + r2 = client.post("/api/v1/zeitmessungen", json=batch, headers=h) + assert r2.json()["duplikate"] == 1 + anzahl = client.db.scalar(select(func.count()).select_from(Zeitmessung)) + assert anzahl == 1 + + +def test_unbekannte_startnummer_wird_unzugeordnet_gespeichert(client): + tid, token = _setup(client) + h = {"Authorization": f"Bearer {token}"} + r = client.post("/api/v1/zeitmessungen", json={"messungen": [_msg(tid, 999)]}, headers=h) + body = r.json() + assert body["unzugeordnet"] == 1 + m = client.db.scalars(select(Zeitmessung)).one() + assert m.status == ZeitmessungStatus.UNZUGEORDNET + assert m.start_id is None # nie verworfen, nur ohne Zuordnung + + +def test_unplausible_dauer_wird_markiert_nicht_abgelehnt(client): + tid, token = _setup(client) + h = {"Authorization": f"Bearer {token}"} + r = client.post("/api/v1/zeitmessungen", json={"messungen": [_msg(tid, 7, dauer="3.00")]}, headers=h) + body = r.json() + assert body["unplausibel"] == 1 + m = client.db.scalars(select(Zeitmessung)).one() + assert m.status == ZeitmessungStatus.UNPLAUSIBEL + + +def test_summe_zeit_wird_aus_messungen_gebildet(client): + tid, token = _setup(client) + h = {"Authorization": f"Bearer {token}"} + # zwei Messpunkte -> Summe + m1 = _msg(tid, 7, dauer="80.00") + m2 = _msg(tid, 7, dauer="79.16") + m2["messpunkt"] = "durchgang-2" + client.post("/api/v1/zeitmessungen", json={"messungen": [m1, m2]}, headers=h) + + from oamc.wertung.service import summe_zeit_fuer_start + + start_id = client.db.scalars(select(Zeitmessung.start_id)).first() + assert summe_zeit_fuer_start(client.db, start_id) == __import__("decimal").Decimal("159.16") + + +def test_startliste_endpunkt_braucht_token(client): + tid, token = _setup(client) + assert client.get("/api/v1/turniere/aktiv/startliste").status_code == 401 + r = client.get("/api/v1/turniere/aktiv/startliste", headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 200 + assert r.json()["starts"][0]["startnummer"] == 7 diff --git a/tests/test_matching.py b/tests/test_matching.py new file mode 100644 index 0000000..7467df4 --- /dev/null +++ b/tests/test_matching.py @@ -0,0 +1,59 @@ +"""Fahrer-Matching (Plan §6.4).""" + +from oamc.matching.fahrer import ( + fahrer_zusammenfuehren, + finde_exakt, + match_oder_neu, + normalisiere, +) +from tests import factories as f + + +def test_normalisierung_akzente_und_bindestriche(): + assert normalisiere("Gál-Szász") == "gal szasz" + assert normalisiere("D'Angelo") == "d'angelo" + assert normalisiere(" Müller ") == "muller" + + +def test_exakter_match_ueber_normalisierten_namen(seed_2026): + db = seed_2026 + f.fahrer(db, "Gál-Szász", "Péter", 2015) + treffer = finde_exakt(db, "Gal-Szasz", "Peter", 2015) + assert treffer is not None + assert treffer.nachname == "Gál-Szász" + + +def test_kein_match_legt_neuen_fahrer_an_und_markiert_neu(seed_2026): + db = seed_2026 + erg = match_oder_neu(db, nachname="Neuling", vorname="Nora", geburtsjahr=2000, anlegen=True) + assert erg.status.value == "neu" + assert erg.neu_angelegt and erg.fahrer.id is not None + + +def test_dry_run_legt_nichts_an(seed_2026): + db = seed_2026 + erg = match_oder_neu(db, nachname="Probe", vorname="Peter", anlegen=False) + assert erg.status.value in {"neu", "unscharf"} + assert erg.fahrer is None + + +def test_familien_namensgleichheit_trennt_ueber_vorname(seed_2026): + db = seed_2026 + f.fahrer(db, "Bernius", "Meik", 1985) + f.fahrer(db, "Bernius", "Jörg", 1960) + assert finde_exakt(db, "Bernius", "Meik", 1985).vorname == "Meik" + assert finde_exakt(db, "Bernius", "Jörg", 1960).vorname == "Jörg" + + +def test_merge_haengt_starts_um_und_deaktiviert_quelle(seed_2026): + db = seed_2026 + t = f.turnier(db) + k = f.klasse(db, "S 6") + ziel = f.fahrer(db, "Krack", "Robin", 1995) + dublette = f.fahrer(db, "Krack", "Robin", 1995) + f.start(db, t, dublette, k, startnummer=5) + anzahl = fahrer_zusammenfuehren(db, ziel.id, dublette.id) + db.flush() + assert anzahl == 1 + assert dublette.aktiv is False + assert ziel.starts[0].startnummer == 5 diff --git a/tests/test_meisterschaft.py b/tests/test_meisterschaft.py new file mode 100644 index 0000000..5f0c2f9 --- /dev/null +++ b/tests/test_meisterschaft.py @@ -0,0 +1,91 @@ +"""Meisterschaftsberechnung (AP 3) — mit PLATZHALTER-Punktetabelle (O-1 offen).""" + +from datetime import date +from decimal import Decimal + +from sqlalchemy import select + +from oamc.meisterschaft.berechnung import ( + PLATZHALTER_PUNKTETABELLE, + berechne_stand, + gewertete_laeufe, + punkte_fuer_platz, +) +from oamc.models import Meisterschaft +from oamc.models.enums import VeranstaltungStatus +from tests import factories as f + + +def test_punkte_fuer_platz_platzhalter(): + assert punkte_fuer_platz(1) == Decimal(25) + assert punkte_fuer_platz(10) == Decimal(6) + assert punkte_fuer_platz(99) == Decimal(1) + assert punkte_fuer_platz(None) == Decimal(0) + + +def test_streichresultate_tabelle(): + tab = {"12": 8, "9": 7, "4": 2} + assert gewertete_laeufe(12, tab) == 8 + assert gewertete_laeufe(10, tab) == 7 # naechstkleinerer Schluessel (9) + assert gewertete_laeufe(3, tab) == 3 # unterhalb der Tabelle: kein Streichresultat + + +def _lauf(db, k, ergebnisse, *, datum, zaehlt=True): + """ergebnisse = [(fahrer, platz), ...]""" + v = f.veranstaltung(db, datum=datum, status=VeranstaltungStatus.FINAL) + t = f.turnier(db, v, zaehlt_meisterschaft=zaehlt) + for i, (fahrer, platz) in enumerate(ergebnisse, start=1): + s = f.start(db, t, fahrer, k, startnummer=i) + e = f.ergebnis(db, s, zeit=Decimal("100"), fehler=0) + e.platzierung = platz + db.flush() + return t + + +def test_stand_summiert_punkte_und_zaehlt_siege(seed_2026): + db = seed_2026 + k = f.klasse(db, "J 3") + a = f.fahrer(db, "Sieger", "Sina", 2014) + b = f.fahrer(db, "Zweite", "Zoe", 2014) + _lauf(db, k, [(a, 1), (b, 2)], datum=date(2026, 4, 1)) + _lauf(db, k, [(a, 1), (b, 2)], datum=date(2026, 5, 1)) + m = db.scalars( + select(Meisterschaft).where(Meisterschaft.bezeichnung == "HTH Jugend J 3 2026") + ).one() + bericht = berechne_stand(db, m.id) + zeilen = {z.fahrer_id: z for z in bericht.zeilen} + assert zeilen[a.id].platz == 1 + assert zeilen[a.id].punkte_gesamt == Decimal(50) # 2x Platz 1 = 2x25 + assert zeilen[a.id].anzahl_siege == 2 + assert bericht.hinweise # O-1 Platzhalter-Warnung + + +def test_tie_break_majoritaet_der_siege(seed_2026): + db = seed_2026 + k = f.klasse(db, "J 3") + a = f.fahrer(db, "Aaa", "A", 2014) + b = f.fahrer(db, "Bbb", "B", 2014) + # beide sammeln gleich viele Punkte, aber A hat mehr Siege + _lauf(db, k, [(a, 1), (b, 6)], datum=date(2026, 4, 1)) # A:25 B:10 + _lauf(db, k, [(b, 1), (a, 6)], datum=date(2026, 5, 1)) # B:25 A:10 + _lauf(db, k, [(a, 1), (b, 1)], datum=date(2026, 6, 1)) # beide 25, beide Sieg + _lauf(db, k, [(a, 1), (b, 4)], datum=date(2026, 6, 15)) # A:25 (Sieg) B:13 + m = db.scalars( + select(Meisterschaft).where(Meisterschaft.bezeichnung == "HTH Jugend J 3 2026") + ).one() + bericht = berechne_stand(db, m.id) + assert bericht.zeilen[0].fahrer_id == a.id + assert bericht.zeilen[0].anzahl_siege > bericht.zeilen[1].anzahl_siege + + +def test_laeufe_ohne_meisterschaft_zaehlen_nicht(seed_2026): + db = seed_2026 + k = f.klasse(db, "J 3") + a = f.fahrer(db, "Solo", "S", 2014) + _lauf(db, k, [(a, 1)], datum=date(2026, 4, 1), zaehlt=False) + m = db.scalars( + select(Meisterschaft).where(Meisterschaft.bezeichnung == "HTH Jugend J 3 2026") + ).one() + bericht = berechne_stand(db, m.id) + assert bericht.laeufe_gesamt == 0 + assert bericht.zeilen == [] diff --git a/tests/test_models_constraints.py b/tests/test_models_constraints.py new file mode 100644 index 0000000..fd40bf5 --- /dev/null +++ b/tests/test_models_constraints.py @@ -0,0 +1,47 @@ +"""Constraint-Tests (Plan AP 1): Mehrfachstart erlaubt, Startnummer eindeutig je +Turnier, hoechstens ein aktives Turnier.""" + +import pytest +from sqlalchemy.exc import IntegrityError + +from tests import factories as f + + +def test_startnummer_eindeutig_je_turnier(seed_2026): + db = seed_2026 + t = f.turnier(db) + k = f.klasse(db, "S 6") + a, b = f.fahrer(db, "A", "A"), f.fahrer(db, "B", "B") + f.start(db, t, a, k, startnummer=10) + f.start(db, t, b, k, startnummer=10, flush=False) + with pytest.raises(IntegrityError): + db.flush() + + +def test_mehrfachstart_desselben_fahrers_erlaubt(seed_2026): + db = seed_2026 + t = f.turnier(db) + fahrer = f.fahrer(db, "Schmunk", "Christian", 1990) + f.start(db, t, fahrer, f.klasse(db, "S 9"), startnummer=1) + f.start(db, t, fahrer, f.klasse(db, "S 3"), startnummer=2) + db.flush() # kein Fehler + assert len(fahrer.starts) == 2 + + +def test_hoechstens_ein_aktives_turnier(seed_2026): + db = seed_2026 + v = f.veranstaltung(db) + f.turnier(db, v, name="A", aktiv=True) + f.turnier(db, v, name="B", aktiv=True, flush=False) + with pytest.raises(IntegrityError): + db.flush() + + +def test_doppelturnier_zwei_turniere_pro_veranstaltung(seed_2026): + db = seed_2026 + v = f.veranstaltung(db, titel="Doppelturnier MSC Hainstadt / OAMC Reinheim") + v.ist_doppelturnier = True + f.turnier(db, v, name="Reinheim") + f.turnier(db, v, name="Hainstadt") + db.flush() + assert {t.name for t in v.turniere} == {"Reinheim", "Hainstadt"} diff --git a/tests/test_seed.py b/tests/test_seed.py new file mode 100644 index 0000000..5585e07 --- /dev/null +++ b/tests/test_seed.py @@ -0,0 +1,47 @@ +"""Stammdaten-Seed (AP 1).""" + +from sqlalchemy import func, select + +from oamc.models import Klasse, Meisterschaft, Saison, Verein +from oamc.models.enums import Kategorie +from oamc.seeds import seed_saison_2026 + + +def test_seed_ist_idempotent(db): + z1 = seed_saison_2026(db) + db.flush() + z2 = seed_saison_2026(db) + db.flush() + assert z1["klasse"] == 18 # J1-J5 + S1-S9 + A1-A4 + assert all(v == 0 for v in z2.values()) + + +def test_seed_jugendklassen_haben_jahrgaenge(seed_2026): + db = seed_2026 + j1 = db.scalars(select(Klasse).where(Klasse.code == "J 1", Klasse.saison_jahr == 2026)).one() + assert j1.kategorie == Kategorie.JUGEND + assert j1.jahrgaenge == [2019, 2018, 2017] + assert j1.mindestalter_am_veranstaltungstag == 7 + + +def test_seed_saison_streichresultate(seed_2026): + s = seed_2026.get(Saison, 2026) + assert s.streichresultate_tabelle["12"] == 8 + assert s.streichresultate_tabelle["4"] == 2 + + +def test_seed_meisterschaften(seed_2026): + db = seed_2026 + ms = db.scalars(select(Meisterschaft).where(Meisterschaft.saison_jahr == 2026)).all() + bezeichnungen = {m.bezeichnung for m in ms} + assert "HTH Erwachsene 2026" in bezeichnungen + assert "HTH Jugend J 3 2026" in bezeichnungen + # O-1: Platzhalter-Punktetabelle ist markiert + erw = next(m for m in ms if m.bezeichnung == "HTH Erwachsene 2026") + assert "_hinweis" in erw.regelwerk + + +def test_seed_vereins_kurznamen(seed_2026): + db = seed_2026 + hainstadt = db.scalars(select(Verein).where(Verein.kurzname == "Hainstadt")).first() + assert hainstadt is not None diff --git a/tests/test_wertung_engine.py b/tests/test_wertung_engine.py new file mode 100644 index 0000000..2c536b9 --- /dev/null +++ b/tests/test_wertung_engine.py @@ -0,0 +1,95 @@ +"""Einheitstests der Wertungsformeln (Plan §3.3).""" + +from decimal import Decimal + +from oamc.models.enums import Kategorie, StartStatus +from oamc.wertung.engine import WertungsEingabe, platziere, regel_fuer + + +def _e(sid, kat, zeit=None, fehler=None, status=StartStatus.GESTARTET, **kw): + return WertungsEingabe( + start_id=sid, + kategorie=kat, + status=status, + summe_zeit=Decimal(str(zeit)) if zeit is not None else None, + summe_fehler=fehler, + **kw, + ) + + +def test_erwachsene_gesamt_ist_zeit_plus_fehler(): + regel = regel_fuer(Kategorie.STAMM) + assert regel.gesamt(Decimal("105.43"), 0) == Decimal("105.43") + assert regel.gesamt(Decimal("103.26"), 10) == Decimal("113.26") + assert regel.gesamt(Decimal("65.71"), 1210) == Decimal("1275.71") + + +def test_jugend_gesamt_ist_nur_fehler(): + regel = regel_fuer(Kategorie.JUGEND) + assert regel.gesamt(Decimal("136.88"), 0) == Decimal("0.00") + assert regel.gesamt(Decimal("148.42"), 60) == Decimal("60.00") + + +def test_jugend_gleichstand_wird_ueber_zeit_aufgeloest(): + res = platziere( + [ + _e(1, Kategorie.JUGEND, 144.46, 0), + _e(2, Kategorie.JUGEND, 136.88, 0), + _e(3, Kategorie.JUGEND, 140.40, 0), + ] + ) + platz = {r.start_id: r.platzierung for r in res} + assert platz == {2: 1, 3: 2, 1: 3} # schnellste Zeit vorn + + +def test_erwachsene_platzierung_aufsteigend_nach_gesamt(): + res = platziere( + [ + _e(1, Kategorie.STAMM, 130.33, 0), # 130.33 + _e(2, Kategorie.STAMM, 105.43, 0), # 105.43 + _e(3, Kategorie.STAMM, 103.26, 10), # 113.26 + ] + ) + platz = {r.start_id: r.platzierung for r in res} + assert platz == {2: 1, 3: 2, 1: 3} + + +def test_dnf_dns_dsq_werden_nicht_klassiert(): + res = platziere( + [ + _e(1, Kategorie.STAMM, 100.0, 0), + _e(2, Kategorie.STAMM, 90.0, 0, status=StartStatus.DNF), + _e(3, Kategorie.STAMM, None, None, status=StartStatus.DNS), + ] + ) + by_id = {r.start_id: r for r in res} + assert by_id[1].platzierung == 1 + assert by_id[2].platzierung is None and not by_id[2].gewertet + assert by_id[3].platzierung is None + + +def test_unvollstaendiges_ergebnis_bleibt_vorlaeufig_ohne_platz(): + res = platziere( + [ + _e(1, Kategorie.STAMM, 100.0, 0, fehler_vollstaendig=False), + _e(2, Kategorie.STAMM, 110.0, 0), + ] + ) + by_id = {r.start_id: r for r in res} + assert by_id[1].platzierung is None + assert by_id[1].gesamt == Decimal("100.00") # vorlaeufige Gesamtanzeige + assert "vorlaeufig" in by_id[1].hinweis + assert by_id[2].platzierung == 1 + + +def test_echter_gleichstand_teilt_platz_und_laesst_luecke(): + # identische Zeit UND Fehler -> selber Platz, danach Luecke + res = platziere( + [ + _e(1, Kategorie.STAMM, 100.00, 0), + _e(2, Kategorie.STAMM, 100.00, 0), + _e(3, Kategorie.STAMM, 105.00, 0), + ] + ) + platz = sorted(r.platzierung for r in res) + assert platz == [1, 1, 3] diff --git a/tests/test_wertung_regression_2026_05_31.py b/tests/test_wertung_regression_2026_05_31.py new file mode 100644 index 0000000..f983342 --- /dev/null +++ b/tests/test_wertung_regression_2026_05_31.py @@ -0,0 +1,116 @@ +"""Abnahmetest der Wertungs-Engine gegen die reale Ergebnisliste +``2026-05-31-MT-REIN-HAIN.PDF`` (Plan §3.3, AP 2). + +Die vollstaendigen 13 Wertungsbloecke liegen als PDF vor; hier abgebildet sind +die im Plan zitierten Belegzeilen. Wo die zitierten Zeilen einen zusammenhaengenden +Platzbereich bilden, wird die exakte publizierte Platzierung reproduziert; wo +Zeilen aus der Mitte des Feldes fehlen, wird die relative Ordnung geprueft. + +TODO(AP 2): sobald der volle PDF-Text vorliegt, alle 13 Bloecke vollstaendig +hinterlegen und 1:1 gegen die publizierten Plaetze pruefen. +""" + +from decimal import Decimal + +import pytest + +from oamc.models.enums import Kategorie, StartStatus +from oamc.wertung.engine import WertungsEingabe, platziere + + +def _e(sid, kat, zeit, fehler): + return WertungsEingabe( + start_id=sid, + kategorie=kat, + status=StartStatus.GESTARTET, + summe_zeit=Decimal(str(zeit)), + summe_fehler=fehler, + ) + + +# --- Block "S 5, S 9, S 3, S 6, S 7" (Erwachsene) ----------------------- +S_BLOCK = [ + # (name, klasse, zeit, fehler, publizierter_platz) + ("Bernius Meik", "S 6", "105.43", 0, 1), + ("Schmunk Christian", "S 9", "103.26", 10, 2), + ("Krack Robin", "S 5", "130.33", 0, 4), + ("Steinbrech Nicolai", "S 5", "65.71", 1210, 11), +] + + +def test_s_block_gesamt_exakt(): + erwartet = { + "Bernius Meik": Decimal("105.43"), + "Schmunk Christian": Decimal("113.26"), + "Krack Robin": Decimal("130.33"), + "Steinbrech Nicolai": Decimal("1275.71"), + } + eingaben = [_e(i, Kategorie.STAMM, z, f) for i, (_n, _k, z, f, _p) in enumerate(S_BLOCK)] + res = {i: r for i, r in enumerate(platziere(eingaben))} + for i, (name, _k, _z, _f, _p) in enumerate(S_BLOCK): + assert res[i].gesamt == erwartet[name], name + + +def test_s_block_relative_ordnung_entspricht_publikation(): + eingaben = [_e(i, Kategorie.STAMM, z, f) for i, (_n, _k, z, f, _p) in enumerate(S_BLOCK)] + res = {i: r for i, r in enumerate(platziere(eingaben))} + # publizierte Plaetze aufsteigend -> unsere internen Plaetze muessen dieselbe + # Reihenfolge haben + intern = [res[i].platzierung for i in range(len(S_BLOCK))] + publiziert = [p for *_r, p in S_BLOCK] + assert [x for _, x in sorted(zip(publiziert, intern))] == sorted(intern) + assert intern == sorted(intern) # bereits in publizierter Reihenfolge gelistet + + +# --- Block "J 3" (Jugend) -------------------------------------------- +# Belegzeilen aus dem Plan. Plaetze 5+6 fehlen im Zitat -> mit klar markierten +# Fuellzeilen (Fehler zwischen 10 und 60) besetzt, damit die publizierten Plaetze +# 1,2,3,4,7,8 exakt herauskommen. +J3_BELEGE = [ + ("Freund Philipp", "136.88", 0, 1), + ("D'Angelo Leandro", "140.40", 0, 2), + ("Heil Luca", "144.46", 0, 3), + ("Mattes Flynn", "160.05", 10, 4), + ("Ratter Mitja", "148.42", 60, 7), + ("Kemesies Finlay", "165.39", 60, 8), +] +J3_FUELLER = [ # SYNTHETIC_FILLER — nicht aus der Quelle, nur Platz 5/6 + ("SYNTHETIC_FILLER_P5", "150.00", 20, 5), + ("SYNTHETIC_FILLER_P6", "150.00", 50, 6), +] + + +def test_j3_block_reproduziert_publizierte_plaetze(): + alle = J3_BELEGE + J3_FUELLER + eingaben = [_e(i, Kategorie.JUGEND, z, f) for i, (_n, z, f, _p) in enumerate(alle)] + res = {i: r for i, r in enumerate(platziere(eingaben))} + for i, (name, _z, _f, publiziert) in enumerate(alle): + assert res[i].platzierung == publiziert, name + # Gesamt der Jugend = Fehler + assert res[i].gesamt == Decimal(alle[i][2]).quantize(Decimal("0.01")), name + + +def test_j3_drei_nuller_werden_ueber_die_zeit_sortiert(): + eingaben = [_e(i, Kategorie.JUGEND, z, f) for i, (_n, z, f, _p) in enumerate(J3_BELEGE[:3])] + res = platziere(eingaben) + assert [r.platzierung for r in res] == [1, 2, 3] # 136,88 < 140,40 < 144,46 + + +def test_j3_zwei_mit_60_fehlern_zeitentscheid(): + # Ratter 148,42 vor Kemesies 165,39 + eingaben = [ + _e(0, Kategorie.JUGEND, "148.42", 60), + _e(1, Kategorie.JUGEND, "165.39", 60), + ] + res = platziere(eingaben) + assert res[0].platzierung == 1 and res[1].platzierung == 2 + + +@pytest.mark.parametrize("name,zeit,fehler,gesamt", [ + ("Freund Philipp", "136.88", 0, "0.00"), + ("Mattes Flynn", "160.05", 10, "10.00"), + ("Ratter Mitja", "148.42", 60, "60.00"), +]) +def test_j3_einzelne_gesamtwerte(name, zeit, fehler, gesamt): + res = platziere([_e(0, Kategorie.JUGEND, zeit, fehler)]) + assert res[0].gesamt == Decimal(gesamt) diff --git a/tests/test_xlsx_import.py b/tests/test_xlsx_import.py new file mode 100644 index 0000000..4e86e3a --- /dev/null +++ b/tests/test_xlsx_import.py @@ -0,0 +1,67 @@ +"""Excel-Import (AP 4/8). Spaltenzuordnung gegen die realen Vorlagen noch offen (O-12).""" + +import io + +from openpyxl import Workbook + +from oamc.importer.xlsx import lese_ergebnisliste, lese_nennliste +from oamc.seeds import seed_saison_2026 +from tests import factories as f + +HEAD = {"X-API-Key": "test-key"} + + +def _xlsx(rows: list[list]) -> bytes: + wb = Workbook() + ws = wb.active + for r in rows: + ws.append(r) + buf = io.BytesIO() + wb.save(buf) + return buf.getvalue() + + +def test_lese_nennliste_mappt_spalten(): + daten = _xlsx([ + ["Start-Nr.", "Voller Name", "Regionalclub", "Klasse", "Geburtsjahr"], + [32, "Gál-Szász Péter", "OAMC Reinheim", "J 2", 2015], + [33, "Bernius Meik", "Hainstadt", "S 6", 1985], + ]) + bulk = lese_nennliste(daten, quelle="test", idempotenz_schluessel="k1") + assert [s.startnummer for s in bulk.starts] == [32, 33] + assert bulk.starts[0].fahrer.nachname == "Gál-Szász" + assert bulk.starts[0].fahrer.vorname == "Péter" + assert bulk.starts[0].klasse == "J 2" + + +def test_lese_ergebnisliste_liest_zeit_und_fehler(): + daten = _xlsx([ + ["Start-Nr.", "Voller Name", "Klasse", "Summe Zeit", "Summe Fehler", "Gesamt", "Platzierung"], + [1, "Bernius Meik", "S 6", "105,43", 0, "105,43", 1], + ]) + bulk = lese_ergebnisliste(daten, quelle="test", idempotenz_schluessel="k2") + e = bulk.ergebnisse[0] + assert str(e.summe_zeit) == "105.43" + assert e.summe_fehler == 0 + assert str(e.gesamt_erwartet) == "105.43" + + +def test_import_xlsx_endpoint_dry_run(client): + db = client.db + seed_saison_2026(db) + t = f.turnier(db) + db.flush() + daten = _xlsx([ + ["Start-Nr.", "Voller Name", "Klasse", "Geburtsjahr"], + [5, "Freund Philipp", "J 3", 2014], + ]) + r = client.post( + f"/api/v1/turniere/{t.id}/import/xlsx?dry_run=true", + headers=HEAD, + data={"art": "nennliste", "idempotenz_schluessel": "xlsx-1"}, + files={"datei": ("nenn.xlsx", daten, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}, + ) + assert r.status_code == 200, r.text + assert r.json()["dry_run"] is True + assert r.json()["angelegt"] == 1