# 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//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('//')` — `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//venv/bin/python /opt//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://` 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 '

[^<]+

'`). Nur DB-Seed-/Update-Skripte (die `urlaub.db` schreiben) wirken sofort, weil jede Request frisch auf SQLite liest. ## Verifikation (immer durchführen) ```bash curl -sk -o /dev/null -w "/ → %{http_code}\n" https:/// curl -sk -o /dev/null -w "/2/ → %{http_code}\n" https:///2/ curl -sk -o /dev/null -w "/3/ → %{http_code}\n" https:///3/ curl -sk -o /dev/null -w "/pdf/ → %{http_code}\n" https:///pdf/ ``` 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=` → 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 `//` — auch auf der **letzten** Ebene. Bei 3 Ebenen zeigte Ebene-3 daher Ziele, die auf **`/4/`** 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: ```jinja {% if ebene <= MAX_EBENE_DEPTH %} {{ z.nach }} {% else %} {{ z.nach }} (final) {% endif %} ``` Generell: **Jeder `href` auf eine ``-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**: ```python # 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:** ```python 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: ```sql 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//')` 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//` (je Zeile), nicht auf `/pdf/`. 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/` → 404 bestätigen), sonst bleibt die gewohnte Liste erreichbar und verwirrt. - Dateiname je Verbindung: `--.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.