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

215 lines
12 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
```bash
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).
## ⚠️ 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:
```jinja
{% 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**:
```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/<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.