Files
mesh-configs/skills/devops/web-hosting-deployment/references/flask-sqlite-drilldown-site.md
T

12 KiB
Raw Blame History

Flask + SQLite: Mehrstufige "Drill-Down"-Website mit nginx-Reverse-Proxy

Bewährt für datengetriebene Sites, die über mehrere Ebenen verschachtelt navigierbar sind, z.B. urlaub.perfekt-ohne-en.de: Ebene1 (Start Radevormwald → 33 Ziele ≥100k), Klick auf ein Ziel → Ebene2 (Liste ab dieser Stadt, ≥50k), Klick → Ebene3.

Warum Backend (Flask+SQLite) statt statischer HTML

Für 3 Ebenen wären statische Seiten ~1000–2000 Dateien (jede Ebene-2-Zielstadt wieder eine Liste + PDFs) — unwartbar bei häufigen Datenänderungen. Ein Backend mit DB: eine UPDATE → alle Seiten/Pfade sofort korrekt. SQLite reicht locker (<100 Queries/Tag, persönliches Projekt). Team-Empfehlung (UnraidHermes) bestätigt.

Architektur

nginx (Subdomain vhost) → proxy_pass 127.0.0.1:8040 → Flask (app.py)
                                                        └─ SQLite (urlaub.db)
  • Flask läuft als systemd-Service (/opt/<app>/venv/bin/python app.py).
  • nginx-location / { proxy_pass http://127.0.0.1:8040; proxy_set_header Host $host; ... }
  • PDF on-demand: Flask-Route rendert Template → wkhtmltopdf → Response(pdf).

Kern-Implementation (app.py)

  • SQLite-Verbindung je Request (sqlite3.connect, row_factory = sqlite3.Row).
  • Slugs für URLs: frankfurt-am-main, osnabrueck, goettingen.
  • Slug↔Stadtname-Match muss Fallback/Transkription akzeptieren — URL ist lowercase slug mit Bindestrichen, DB speichert echten Namen ("Frankfurt am Main", "Osnabrück"). Match mit LOWER(replace(nach,' ',''))=LOWER(replace(?,' ','')) UND Umlaut-Rücktranskription: ue→ü/ue/u, oe→ö/oe/o, ae→ä/ae/a, ss→ß/ss/s.
  • slug() muss Umlaute VOR ascii-encode expandieren: ä→ae, ö→oe, ü→ue, ß→ss.
  • Ebene-Route: @app.route('/<int:ebene>/<slug_name>') — ebene in (2,3), sonst 404.
  • Startstadt = die angematchte Stadt; Ziele = WHERE ebene=? AND von=?.
  • Leere Ebene → eigenes leer.html-Template ("Noch keine Ziele…"), kein 404.
  • /healthz → "ok" für systemd/nginx-Readiness.

Deployment-Ablauf

  1. python3 -m venv venv && venv/bin/pip install flask (sqlite3 in stdlib).
  2. venv/bin/python -c "import flask" (Syntax-/Install-Check NICHT mit ||-Fehler in derselben Python-Zeile verwechseln).
  3. DB-Init + Seed (Skripte db_init.py, seed_ebene2.py).
  4. Testen: ./venv/bin/python app.py im Hintergrund → curl 127.0.0.1:8040/healthz und Routen. Dann killen, Start an systemd übergeben: ExecStart=/opt/<app>/venv/bin/python /opt/<app>/app.py, Restart=always.
  5. nginx: Proxy gegen 127.0.0.1:8040 einrichten (siehe Haupt-SKILL: vhost via /tmp + cp — write_file weigert sich bei /etc/nginx/*; Backup-Regel vorher).
  6. nginx -t && systemctl reload nginx, dann über https://<sub> verifizieren.

⚠️ Änderungen an Templates/Code erfordern einen Service-Neustart (2026-08-08)

Flask läuft hier mit debug=False (systemd). Das heißt: Template-Edits (*.html) und app.py-Änderungen werden NICHT automatisch neu geladen. Symptom aus dem Live-Betrieb: Nachdem eine neue Kategorie-Gruppe (z.B. unter 5h) ins Template kam, zeigte die laufende Seite sie nicht — erst systemctl restart urlaub.service brachte sie sichtbar. Regel: nach jeder Template/Code-Änderung IMMER den systemd- Service neu starten und dann die Seite per curl gegen die erwartete neue Markierung prüfen (z.B. grep -oE '<h2>[^<]+</h2>'). Nur DB-Seed-/Update-Skripte (die urlaub.db schreiben) wirken sofort, weil jede Request frisch auf SQLite liest.

Verifikation (immer durchführen)

curl -sk -o /dev/null -w "/ → %{http_code}\n" https://<sub>/
curl -sk -o /dev/null -w "/2/<stadt> → %{http_code}\n" https://<sub>/2/<stadt>
curl -sk -o /dev/null -w "/3/<stadt> → %{http_code}\n" https://<sub>/3/<stadt>
curl -sk -o /dev/null -w "/pdf/<stadt> → %{http_code}\n" https://<sub>/pdf/<stadt>

HTTP 000 + curl-Exit 60 = nur Zert-Warnung, -k zeigt 200.

Datenquellen für Ebene-2/3 am sinnvollsten

  • Start mit kleinen, konkreten Team-Tasks ("gib je Startstadt Ziele ≥50k im Format Stadt|~h|Ziel1,Ziel2") — NICHT breit formulieren; Sub-Agenten liefern sonst nur Enzyklopädie/Regionen statt präziser Stadt-zu-Stadt-Zuordnung (wiederholtes Muster).
  • Der Master kuratiert die Team-Rohdaten gegen Geographie (keine offensichtlichen Fehler), markiert alles als Richtwerte.
  • Ebene-3-Daten optional; Route existiert und zeigt sauber leer.html bis befüllt.

⚠️ Datenqualität: NIE Platzhalter-Zahlen als echtes Datum (2026-08-08)

Beim Seeden von Ebene-2-Städten wurde einwohner=150000 pauschal für ALLE gesetzt. Der Nutzer bemerkte sofort "es gibt keine 50k–100k-Städte in der Liste" — korrekt: alle kleineren Städte (Würzburg, Koblenz, Hameln …) zeigten fälschlich 150k.

Regeln:

  • Jeder Eintrag braucht echte, individuell korrekte Werte. Unbekannte als NULL/"k.A." führen — niemals einen plausiblen Platzhalter wie 150000.
  • Nach dem Seed IMMER einen Duplikat-/Platzhalter-Check laufen lassen: SELECT nach FROM verkehr WHERE einwohner=<platzhalter> → darf 0 Treffer liefern.
  • Wenn echte Zahlen fehlen (kein Webzugriff), schätzen mit Bereich + Label ("Richtwert"), nicht als exakte Zahl. Beim späteren Update echte Werte nachtragen.
  • 50k–100k-Ziele sind der eigentliche Kern einer kleinere-Schwelle-Liste — sie explizit ergänzen (Team lieferte konkrete: ab Trier→Worms/Bad Kreuznach/ Idar-Oberstein/Neunkirchen; ab Hannover→Hameln/Celle/Goslar/Lüneburg/Delmenhorst).

Das Drill-Down-Template verlinkte jedes Ziel auf /<ebene+1>/<slug> — auch auf der letzten Ebene. Bei 3 Ebenen zeigte Ebene-3 daher Ziele, die auf /4/<stadt> verwiesen, eine Route die es nicht gibt → 404 (und Verstoß gegen die Nutzer-Erwartung „jede Ebene hat eine Unter-Ebene… bis die es gibt«). User bemerkte es sofort.

Fix / Regel: Das Template muss die letzte Ebene als final markieren statt einen toten Weiter-Link zu rendern:

{% if ebene <= MAX_EBENE_DEPTH %}
  <td><a href="/{{ ebene+1 }}/{{ z.nach|lower }}">{{ z.nach }}</a></td>
{% else %}
  <td>{{ z.nach }} <small>(final)</small></td>
{% endif %}

Generell: Jeder href auf eine <ebene+1>-Route darf nur in Ebenen gerendert werden, die wirklich existieren (≤ Tiefe). Vor dem Livegehen alle Funktions­pfade durchklicken (curl auf /1/ /2/ /3/) und darauf achten, DASS kein Link auf eine NICHT eingerichtete Ebene zeigt (grep -oE 'href="/4/' darf leer sein).

Umlaut-Slug-Match: auch umgekehrte Einzelbuchstaben berücksichtigen (2026-08-08)

Der Slug für Köln ist koln (nur ö→o beim Sluggen), während ein Nutzer/anderer Kandidat koeln (Transkription) verwenden könnte und die DB Köln speichert. Der Stadtname-Match muss ALSO beide^ Richtungen anbieten:

  • Transkription: ue→ü, oe→ö, ae→ä, ss→ß (für Slugs, die voll transkribieren).
  • Einzelbuchstaben umgekehrt: o→ö, u→ü, a→ä, s→ß (für URL-Slugs wie koln, wuerzburg→würzburg), damit koln→Köln matcht. Jeder Fund-Kandidat wird gegen LOWER(replace(nach,' ','')) geprüft; der erste Treffer gewinnt. Ohne den Einzelbuchstaben-Fall bleiben koln, rossleben-artige URLs auf 404, obwohl die Stadt existiert. Beispiel-Kandidatenliste für koln: koln, köln, koeln, koln (alle Versuche) — der ö-Versuch matcht die DB.

⚠️ Generator: nie nur die NÄCHSTEN Ziele nehmen — sonst verschwinden Fernziele (2026-08-09)

Beim automatischen Seeden von Drill-Down-Ebenen (Distanz-basiert, z.B. Ebene-2-Ziele aus einer Städte-Referenzliste mit Haversine) ist die größte Daten-Falle das Abschneiden des fernen Endes:

# FALSCH: sortiert nach Distanz (nahe zuerst) und nimmt die ersten N
ziele.sort(key=lambda x: x[0]/70)        # d/70 = h
for nach, ... in ziele[:N]:              # N = z.B. 26
    ...

→ Das nimmt nur die nächsten Städte. Alle 5-7h- und >7h-Fernziele fallen raus, weil sie weiter weg sind. Symfony: Trier-Ebene2 hatte danach nur "unter 5h" (alle 26), 0 Kernziele, 0 Fernziele. Der Nutzer bemerkte sofort: „Jetzt sind nur noch die kleinen da — was ist mit den 5-7h und >7h passiert?"

Fix — pro Kategorie die nahsten N, damit ALLE Kategorien vertreten bleiben:

kat = {"unter 5h":1, "Kernziel (5-7h)":2, "über 7h":3}[...]   # h aus d/70
kategorien[kat].append(...)
# danach je Kategorie sortieren + je Kategorie max_pro_kat nehmen
for kat in ["unter 5h","Kernziel (5-7h)","über 7h"]:
    kategorien[kat].sort(key=lambda x: dist)
    ziele += kategorien[kat][:max_pro_kat]      # z.B. max_pro_kat=9

Gib dadurch pro Startstadt z.B. 26 unter5h + 26 kern + 17 über7h statt nur 26 nah.

Zweite Fall-Hälfte (Kategorie aus echter Zeit ableiten, NICHT aus Wunschbandbreite): Die Kategorie muss aus der berechneten Stundenzahl (h<5 → unter5h, 5≤h≤7 → Kernziel(5-7h), h>7 → über7h) kommen. Vorher wurde d<350km → 5-7h als Distanz- Bandbreite gesetzt, was Ziele mit ~3h als "Kernziel 5-7h" labelte — also Label und angezeigte Fahrtzeit widersprachen sich (Dinslaken ~3h als "5-7h"). Einheitlich: erst h = d/70 berechnen, dann Kategorie aus h. Prüf-Check nach dem Seed:

SELECT kategorie,COUNT(*) FROM verkehr WHERE ebene=2 GROUP BY kategorie  -- alle 3 gefüllt?

Erwartung: nie eine Ebene, die nur 1 Kategorie enthält, solange es Fernziele <700km gibt.

⚠️ PDF-Erwartung: je EINZELNE Verbindung, nicht Liste aller Ziele (2026-08-09)

Nutzer beim Betrachten der urlaub-Site: „Die PDF finde ich blöd. Ich habe gedacht die PDF wäre für die Verbindung von → nach, nicht nur eine PDF mit allem." — Der erste Entwurf erzeugte eine PDF pro Startstadt mit ALLEN Zielen (Liste). Erwartung war aber: eine PDF je konkreter Fahrt (Start → Ziel).

Fix: Route auf Paar umbauen @app.route('/pdf/<von>/<nach>') mit einem losen pdf_einzel.html-Template (Abfahrt-Zeit/Ziel/Fahrtzeit/Weg/Ticket), das genau eine Verbindung beschreibt. Links in der Tabelle zeigen dann auf /pdf/<von>/<nach> (je Zeile), nicht auf /pdf/<startstadt>. Wichtig:

  • Beide Slugs über den resolve()-Helper mit Umlaut-/Fallback-Match gegen DB auflösen.
  • Existiert die Verbindung nicht in der DB, trotzdem eine saubere Wegzeile rendern („Richtwert (DB prüfen)") statt 404 — der Nutzer soll immer ein PDF bekommen.
  • Nach Umbau die alte Listen-Route entfernen (/pdf/<stadt> → 404 bestätigen), sonst bleibt die gewohnte Liste erreichbar und verwirrt.
  • Dateiname je Verbindung: <von>--<nach>.pdf (vermeidet Überschreiben bei mehreren Fahrten).

Grundsatz: Bei "PDF pro X" immer zuerst klären, ob der Nutzer ein Dokument je Datensatz (hier: Fahrt) oder ein Sammeldokument je Gruppe (Startstadt) will. Im Zweifel Richtung "je einzelner Eintrag/Fahrt" gehen — das ist die intuitive Erwartung.

Team-Audit: Sub-Agenten können VPS-Dateien nicht lesen (2026-08-09)

Ein Audit-Task, das einem Sub-Agenten nur einen Pfad gibt (/opt/urlaub/app.py, urlaub.db) scheitert stets: Rechen-Agenten haben keinen VPS-Lesezugriff und finden die Datei nicht → sie können den Code nicht gegenprüfen und brechen ab (korrekt, statt zu erfinden). Für Code-/Daten-Audits musst du als Master die relevanten Inhalte in den Task einbetten (Schema, Codesnippets, Beispiel-Zeilen) oder den Audit selbst anhand des echten Systemstands durchführen. Sonst läuft ein „Team-Audit" ins Leere. Dieses Session-Thema: 1 von 3 Audit-Tasks brachte nichts, weil der Agent nur den Pfad bekam.

⚠️ Backend-Code gehört ebenfalls ins Team-Review (2026-08-08)

Der Nutzer fragte nach dem Bau zu Recht: „Habt ihr euch gegengecheckt?" — Die Ebene-2-DATEN kamen vom Team, aber der Flask-Code/PDF/DB-Link war solo gebaut ohne Review. Die Review-Runde fand SOFORT echte Bugs:

  • DB-Deep-Link #query=-Format war falsch (→ korrektes query.exe/dn, siehe deutsche-bahn-timetable-data.md).
  • Einwohner-Platzhalter (s.o.). Lektion: Nicht nur fachliche Daten durchs Team prüfen lassen, sondern auch den Deployment-Code. Ein kurzer Review-Task mit eingebettetem Kontext („Bitte analysiere den Deep-Link + Platzhalter, nenne Fixes") findet solche Fehler günstig und schnell — genau das „4 Agents > 1 teurer Agent"-Prinzip des Nutzers.