215 lines
12 KiB
Markdown
215 lines
12 KiB
Markdown
# 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 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 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. |