12 KiB
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 VORascii-encodeexpandieren:ä→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
python3 -m venv venv && venv/bin/pip install flask(sqlite3 in stdlib).venv/bin/python -c "import flask"(Syntax-/Install-Check NICHT mit||-Fehler in derselben Python-Zeile verwechseln).- DB-Init + Seed (Skripte
db_init.py,seed_ebene2.py). - Testen:
./venv/bin/python app.pyim Hintergrund →curl 127.0.0.1:8040/healthzund Routen. Dann killen, Start an systemd übergeben:ExecStart=/opt/<app>/venv/bin/python /opt/<app>/app.py,Restart=always. - nginx: Proxy gegen
127.0.0.1:8040einrichten (siehe Haupt-SKILL: vhost via/tmp+cp— write_file weigert sich bei/etc/nginx/*; Backup-Regel vorher). nginx -t && systemctl reload nginx, dann überhttps://<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.htmlbis 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 wie150000. - 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).
⚠️ Finale Ebene darf keine Dangling-Links auf nicht-existente Unter-Ebene erzeugen (2026-08-08)
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 Funktionspfade
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 wiekoln,wuerzburg→würzburg), damitkoln→Kölnmatcht. Jeder Fund-Kandidat wird gegenLOWER(replace(nach,' ',''))geprüft; der erste Treffer gewinnt. Ohne den Einzelbuchstaben-Fall bleibenkoln,rossleben-artige URLs auf 404, obwohl die Stadt existiert. Beispiel-Kandidatenliste fürkoln: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 (→ korrektesquery.exe/dn, siehedeutsche-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.