Compare commits
7 Commits
8f6f1a6e0e
...
8ff8b39b2e
| Author | SHA256 | Date | |
|---|---|---|---|
| 8ff8b39b2e | |||
| 1ef297ab89 | |||
| 89a08965db | |||
| 09b34ae13b | |||
| f5fa3e45e8 | |||
| 74684c7e6c | |||
| 9316d72bb4 |
154
CLAUDE.md
Normal file
154
CLAUDE.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# CLAUDE.md — Repo `~/bin`
|
||||
|
||||
> Diese Datei richtet sich primär an künftige Claude-Coding-Sessions in
|
||||
> diesem Repo, sekundär an Menschen. Architektur/Modul-Übersicht (Mermaid):
|
||||
> **`docs/ARCHITEKTUR.md`**. Vor Code-Änderungen dort das Komponenten- und
|
||||
> Sequenzdiagramm lesen — jeder dort genannte Name existiert real im Code.
|
||||
|
||||
## Projektüberblick
|
||||
|
||||
Finanzberatungs-Tool (Verzeichnis `finance/`): FastAPI-App (API + Web-GUI),
|
||||
Postgres, Grafana-Dashboards. Importiert Kontoauszüge (PDF: VR/HVB/DKB;
|
||||
CSV: VR/HVB/DKB), verwaltet Buchungen/Kategorien/Fixposten, rechnet
|
||||
Kredit-Tilgungspläne und Liquiditäts-Szenarien durch. Läuft als Podman-Pod
|
||||
`finance_pod` (3 Container) unter systemd `--user`, siehe
|
||||
`docs/ARCHITEKTUR.md` Abschnitt 3 (Deployment). Aktuelle Version:
|
||||
`finance/VERSION` (Single Source — siehe dort).
|
||||
|
||||
Zugehöriges Repo `/home/wlfb/fb` ist die **Beratungsumgebung** (Skills,
|
||||
Gesprächsleitfäden für die Nutzung des Tools in der Finanzberatung selbst,
|
||||
eigenes `CLAUDE.md`) — nicht der Code dieses Tools. Dieses Repo (`~/bin`)
|
||||
enthält Quellcode, Deployment- und Betriebsskripte.
|
||||
|
||||
## Betriebskommandos
|
||||
|
||||
**Test-Suite** (Basis-Erwartung: alle grün, dreistellige Anzahl):
|
||||
```bash
|
||||
cd finance && .venv/bin/python -m pytest -q
|
||||
```
|
||||
|
||||
**Deploy / Redeploy** (baut das Image neu, erstellt Pod+Container neu,
|
||||
generiert systemd-Units, aktiviert den Service, wartet auf Bereitschaft):
|
||||
```bash
|
||||
./create_pod_finance.sh
|
||||
```
|
||||
Idempotent — erneutes Ausführen rotiert **keine** Secrets in einer
|
||||
bestehenden `.env` (Ausnahme: `FB_GUI_PASSWORD_HASH` wird bei jedem Lauf mit
|
||||
`FB_PASSWORD` synchron gehalten, siehe Passwort-Prozedur unten). Legacy-Pfad
|
||||
`~/bin/finance/.env` wird beim ersten Lauf automatisch nach
|
||||
`~/.local/share/finance_pod/.env` migriert.
|
||||
|
||||
**Backup** (kalt, kurze Downtime, Zielverzeichnis optional):
|
||||
```bash
|
||||
./backup_finance_pod.sh [ZIELDIR] # Default: ~/backups
|
||||
```
|
||||
Stoppt `pod-finance_pod.service`, sichert `~/.local/share/finance_pod`
|
||||
(inkl. `.env`) via `tar czf` **im `podman unshare`-Namespace** (Postgres-Daten
|
||||
gehören auf dem Host der subuid-verschobenen Container-UID und sind sonst
|
||||
unlesbar), startet den Service wieder, prüft Erreichbarkeit
|
||||
(`GET /login` → 200) und Archiv-Integrität (`.env`, `postgres-data`,
|
||||
`grafana-data`, `data` müssen enthalten sein), setzt `chmod 600` (Archiv
|
||||
enthält Secrets). Bei jedem Fehler läuft der Service danach trotzdem wieder
|
||||
(EXIT-Trap).
|
||||
|
||||
**Passwort ändern** (GUI + Grafana teilen sich `FB_PASSWORD`):
|
||||
- **Vorzugsweg (Admin-Seite):** `/admin` in der GUI aufrufen — ändert altes
|
||||
Passwort gegen Grafana (`services/admin.py::change_password`), schreibt
|
||||
danach die `.env` **in-place** neu. Bestehende Sitzungen bleiben gültig
|
||||
(Session-Secret ändert sich nicht).
|
||||
- **Handverfahren:** `FB_PASSWORD` in
|
||||
`~/.local/share/finance_pod/.env` von Hand editieren (Wert
|
||||
**single-quoted** lassen!) und `./create_pod_finance.sh` erneut ausführen
|
||||
— das Skript leitet `FB_GUI_PASSWORD_HASH` automatisch neu ab und
|
||||
synchronisiert Grafana per `grafana cli admin reset-admin-password`.
|
||||
|
||||
**Parser-Werkzeuge** (nur lokal, siehe DATENSCHUTZ unten):
|
||||
```bash
|
||||
cd finance
|
||||
PYTHONPATH=. .venv/bin/python scripts/parser_audit.py tests/fixtures/dkb_beispiel.pdf
|
||||
PYTHONPATH=. .venv/bin/python scripts/parser_vs_csv.py <pdf> <csv> [<csv>...]
|
||||
```
|
||||
`parser_audit.py` zeigt geparste Transaktionen + PDF-Rohtext einer Fixture.
|
||||
`parser_vs_csv.py` vergleicht ein PDF-Parser-Ergebnis gegen CSV-Ground-Truth
|
||||
(Betrag/Datum-Match, Gegenpartei/Verwendungszweck-Ähnlichkeit via
|
||||
`difflib`). Beide geben echte Kontodaten auf der Konsole aus.
|
||||
|
||||
## WICHTIGE REGELN für künftige Sessions
|
||||
|
||||
**`tests/fixtures/*` = ECHTE Kontodaten — NIE committen oder zitieren.**
|
||||
`finance/tests/fixtures/*.pdf`, `*.csv` und `expected_*.json` sind reale
|
||||
Kontoauszüge (via `finance/.gitignore` vom Repo ausgeschlossen). In Commits,
|
||||
PR-Beschreibungen, Reports, Ledger-Einträgen oder Chat-Antworten dürfen
|
||||
weder Dateiinhalte noch daraus abgeleitete Beträge/Namen/IBANs auftauchen.
|
||||
Ausgaben von `parser_audit.py`/`parser_vs_csv.py` sind ausschließlich zur
|
||||
lokalen Fehlersuche bestimmt, niemals zum Weiterreichen.
|
||||
|
||||
**Decimal, nicht float.** Geldbeträge sind überall `decimal.Decimal`
|
||||
(DB-Spaltentyp `Numeric(12,2)`, siehe `models/tables.py::MONEY`). Keine
|
||||
`float`-Arithmetik für Beträge einführen (Rundungsfehler).
|
||||
|
||||
**Deutsch.** GUI-Texte, Fehlermeldungen, Templates, Commit-Messages und
|
||||
Beratungsinhalte sind auf Deutsch. Datumsformat TT.MM.JJJJ.
|
||||
|
||||
**UX-Regel: Bedienelemente sichtbar, nicht versteckt.** Formulare/Buttons
|
||||
bleiben immer sichtbar und bedienbar; Sperrzustände werden über `disabled`
|
||||
ausgedrückt, nicht durch Entfernen des Elements aus dem DOM. Fehler werden
|
||||
inline auf derselben Seite gemeldet (Beispiel: `routers/admin.py` gibt bei
|
||||
Validierungsfehlern dasselbe Template mit `error`-Kontext zurück statt
|
||||
umzuleiten oder das Formular verschwinden zu lassen).
|
||||
|
||||
**Fable-Testagent-Gate (Nutzer-Vorgabe).** Jeder Task in einem
|
||||
Superpowers-Plan (`docs/superpowers/plans/*.md`) braucht vor dem Commit eine
|
||||
Abnahme durch den Fable-Testagenten (Faktencheck/Live-Test je nach Task).
|
||||
Kein Task gilt als abgeschlossen, solange dieses Gate nicht durchlaufen ist
|
||||
— auch nicht bei scheinbar trivialen Doku-/Config-Änderungen.
|
||||
|
||||
**Superpowers-Workflow.** Pläne liegen unter `docs/superpowers/plans/`
|
||||
(ein Plan pro Ausbaustufe, z.B. `2026-07-20-ausbaustufe-4.md`), gegliedert
|
||||
in Tasks mit Checkbox-Tracking. Fortschritt/Entscheidungen/offene Punkte
|
||||
werden fortlaufend im Ledger `.superpowers/sdd/progress.md` festgehalten
|
||||
(ein Eintrag pro abgeschlossenem Task, inkl. Commit-Range und Fable-Befund).
|
||||
Vor Arbeitsbeginn an einem Task: Plan-Datei UND Ledger lesen — der Ledger
|
||||
enthält oft bindende Nutzerentscheidungen und Warnungen aus früheren Tasks
|
||||
(z.B. Migrations-Hinweise), die nicht im Plan selbst stehen.
|
||||
|
||||
**Versionierung via `finance/VERSION`.** Einzige Versionsquelle (siehe
|
||||
`docs/ARCHITEKTUR.md` Abschnitt 4). Speist Image-Tag
|
||||
(`create_pod_finance.sh`), GUI-Footer und `GET /api/version`
|
||||
(`app/version.py::get_version`). Bei einem Release: `VERSION` hochzählen,
|
||||
bevor `create_pod_finance.sh` läuft — sonst baut das Skript ein Image mit
|
||||
altem Tag.
|
||||
|
||||
**`.env` unter `~/.local/share/finance_pod/`, nicht im Repo-Checkout.**
|
||||
Secrets liegen unter `$BIND_DIR/.env` (`chmod 600`), niemals unter
|
||||
`finance/.env` (Legacy-Pfad, wird migriert). Alle Werte darin sind
|
||||
**single-quoted** zu schreiben (`KEY='wert'`) — `create_pod_finance.sh`
|
||||
sourced die Datei per `set -a; . "$ENV_FILE"; set +a`, und unquoted Werte
|
||||
mit `$` (z.B. der GUI-Passwort-Hash `salt$digest`) würden dabei fälschlich
|
||||
shell-expandiert. Der API-Container mountet dieselbe Datei zusätzlich als
|
||||
**Einzeldatei-Bind-Mount** nach `/data/.env` — das folgt dem Host-Inode, die
|
||||
App muss sie **in-place** überschreiben (`open` im Modus `r+`, `flock`,
|
||||
`truncate` — siehe `services/admin.py::_rewrite_env_file`), niemals über
|
||||
Temp-Datei+`rename` (neuer Inode, vom laufenden Mount nicht mehr gesehen).
|
||||
Host-seitige `sed`-Edits in `create_pod_finance.sh` selbst sind unkritisch,
|
||||
weil das Skript die Container ohnehin bei jedem Lauf neu erstellt.
|
||||
|
||||
**Disaster Recovery = `BIND_DIR`-Backup + Repo + Skript.** Für vollständige
|
||||
Wiederherstellung werden **beide** gebraucht: ein Backup von
|
||||
`~/.local/share/finance_pod` (Secrets + Postgres-/Grafana-Daten, via
|
||||
`backup_finance_pod.sh`) UND der Repo-Checkout `~/bin` (Code für den
|
||||
Image-Build, `create_pod_finance.sh`, `finance/grafana/` für die
|
||||
Provisioning-Mounts). Wiederherstellung: Backup nach
|
||||
`~/.local/share/finance_pod` entpacken, dann `./create_pod_finance.sh`
|
||||
erneut ausführen (baut Image, erstellt Pod/Container/systemd-Units neu,
|
||||
übernimmt vorhandene `.env` unverändert). Verifiziert in A2-Task 7
|
||||
(Live-DR-Probe, siehe `.superpowers/sdd/progress.md`).
|
||||
|
||||
## Weiterführend
|
||||
|
||||
- Architektur (Module, Funktionen, drei Mermaid-Diagramme):
|
||||
`docs/ARCHITEKTUR.md`
|
||||
- Pläne: `docs/superpowers/plans/`
|
||||
- Fortschritts-Ledger: `.superpowers/sdd/progress.md`
|
||||
- Beratungsumgebung (Nutzung des Tools, Skills): `/home/wlfb/fb`
|
||||
(eigenes `CLAUDE.md` dort)
|
||||
135
backup_finance_pod.sh
Executable file
135
backup_finance_pod.sh
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Kalt-Backup-Skript für den finance_pod (Postgres-Daten, Grafana-Daten,
|
||||
# Uploads/Inbox und das Secrets-.env unter ~/.local/share/finance_pod).
|
||||
# Stoppt den systemd-User-Service für die Dauer der Sicherung (kurze
|
||||
# Downtime), erstellt ein tar.gz, startet den Service wieder, prüft die
|
||||
# Erreichbarkeit und die Archiv-Integrität, und schützt das Archiv per
|
||||
# chmod 600 (es enthält Secrets aus .env).
|
||||
#
|
||||
# Usage: ./backup_finance_pod.sh [ZIELDIR] (Default: $HOME/backups)
|
||||
|
||||
set -e
|
||||
|
||||
# Secrets landen im Archiv (.env) -> alle vom Skript neu erzeugten
|
||||
# Dateien/Verzeichnisse sollen von Anfang an nur für den Besitzer lesbar
|
||||
# sein, nicht erst nach einem nachträglichen chmod.
|
||||
umask 077
|
||||
|
||||
POD_NAME='finance_pod'
|
||||
SERVICE_NAME="pod-${POD_NAME}.service"
|
||||
SOURCE_PARENT="$HOME/.local/share"
|
||||
BIND_DIR="$SOURCE_PARENT/$POD_NAME"
|
||||
TARGET_DIR="${1:-$HOME/backups}"
|
||||
|
||||
HOST_LOCAL_IP='127.0.0.1'
|
||||
API_HOST_PORT='8096'
|
||||
CHECK_URL="http://$HOST_LOCAL_IP:$API_HOST_PORT/login"
|
||||
|
||||
if [ ! -d "$BIND_DIR" ]; then
|
||||
echo "ERROR: $BIND_DIR existiert nicht." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Zielverzeichnis anlegen, BEVOR der Service gestoppt wird: wenn das Ziel
|
||||
# unbeschreibbar ist, bricht das Skript hier ab und der Service lief nie
|
||||
# an, bleibt also ungestört aktiv. Nur beim NEU-Anlegen explizit auf 700
|
||||
# setzen (ein bereits vorhandenes Zielverzeichnis wird in seinen
|
||||
# bestehenden Rechten nicht angetastet).
|
||||
if [ ! -d "$TARGET_DIR" ]; then
|
||||
mkdir -p "$TARGET_DIR"
|
||||
chmod 700 "$TARGET_DIR"
|
||||
else
|
||||
mkdir -p "$TARGET_DIR"
|
||||
fi
|
||||
|
||||
ARCHIVE="$TARGET_DIR/${POD_NAME}_$(date +%F_%H%M%S).tar.gz"
|
||||
|
||||
# Merkt sich, ob DIESES Skript den Service gestoppt hat. Der EXIT-Trap
|
||||
# startet ihn bei jedem Fehler wieder - unabhängig davon, an welcher Stelle
|
||||
# das Skript abbricht (set -e sorgt dafür, dass jeder Fehlschlag hierher
|
||||
# springt). Zusätzlich entfernt der Trap ein evtl. bereits angelegtes,
|
||||
# aber unvollständiges/fehlerhaftes Archiv - es kann Secrets (.env)
|
||||
# enthalten und darf im Fehlerfall nicht liegen bleiben.
|
||||
SERVICE_STOPPED=0
|
||||
|
||||
restart_on_error() {
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
if [ -n "${ARCHIVE:-}" ] && [ -f "$ARCHIVE" ]; then
|
||||
echo "Entferne unvollständiges/fehlerhaftes Archiv $ARCHIVE (kann Secrets enthalten)..." >&2
|
||||
rm -f "$ARCHIVE"
|
||||
fi
|
||||
if [ "$SERVICE_STOPPED" -eq 1 ]; then
|
||||
echo "FEHLER (Exit-Code $rc) — starte $SERVICE_NAME sicherheitshalber wieder..." >&2
|
||||
systemctl --user start "$SERVICE_NAME" || true
|
||||
fi
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
trap restart_on_error EXIT
|
||||
|
||||
echo "Stoppe $SERVICE_NAME für das Kalt-Backup..."
|
||||
systemctl --user stop "$SERVICE_NAME"
|
||||
SERVICE_STOPPED=1
|
||||
|
||||
# tar läuft im rootless-Podman-User-Namespace (`podman unshare`): das
|
||||
# Postgres-Datenverzeichnis gehört auf dem Host der subuid-verschobenen UID
|
||||
# (Container-UID 999 -> Host-UID via /etc/subuid), für `wlfb` direkt daher
|
||||
# unlesbar (drwx------). Im User-Namespace mappt UID 0 auf den aufrufenden
|
||||
# Host-User zurück und darf als Namespace-root alle verschobenen Dateien
|
||||
# lesen; die von ns-root neu erzeugte Archivdatei gehört auf dem Host
|
||||
# dadurch bereits `wlfb` - KEIN nachträgliches chown noetig (und keins
|
||||
# versuchen: `podman unshare chown "$(id -u):$(id -g)"` wertet die
|
||||
# id-Substitution auf dem HOST aus, aber im Namespace ist genau diese
|
||||
# host-UID/GID bereits subuid-verschoben, sodass der Aufruf die Datei auf
|
||||
# eine fremde subuid-UID umbiegt und das anschliessende `chmod 600` mit
|
||||
# EPERM scheitert). Zusätzlich vererbt `podman unshare` NICHT die umask
|
||||
# des aufrufenden Skripts (dort dokumentiert 0022 statt der oben gesetzten
|
||||
# 077) - daher `umask 077` explizit erneut innerhalb des unshare-Subshells,
|
||||
# damit das Archiv von Geburt an 600 ist statt bis zum finalen chmod als
|
||||
# 644 mit Secrets dazuliegen.
|
||||
echo "Erstelle Archiv $ARCHIVE (im Podman-User-Namespace, wegen UID-verschobener Postgres-Daten)..."
|
||||
podman unshare sh -c 'umask 077; tar czf "$1" -C "$2" "$3"' _ "$ARCHIVE" "$SOURCE_PARENT" "$POD_NAME"
|
||||
|
||||
echo "Starte $SERVICE_NAME wieder..."
|
||||
systemctl --user start "$SERVICE_NAME"
|
||||
SERVICE_STOPPED=0
|
||||
|
||||
echo "Warte auf Bereitschaft ($CHECK_URL)..."
|
||||
READY=0
|
||||
CODE=''
|
||||
for attempt in $(seq 1 30); do
|
||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL" || true)
|
||||
if [ "$CODE" = "200" ]; then
|
||||
echo "API ist wieder erreichbar (200)."
|
||||
READY=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$READY" -ne 1 ]; then
|
||||
echo "ERROR: API wurde nach dem Neustart nicht rechtzeitig bereit (letzter Status: $CODE)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Prüfe Archiv-Integrität (tar -tzf)..."
|
||||
LISTING=$(tar -tzf "$ARCHIVE")
|
||||
for required in ".env" "postgres-data" "grafana-data" "data"; do
|
||||
# Regex-Sonderzeichen (nur '.' kommt in dieser Liste vor, bei ".env")
|
||||
# escapen, damit z.B. "xenv" nicht faelschlich auf ".env" matcht.
|
||||
escaped="${required//./\\.}"
|
||||
if ! echo "$LISTING" | grep -Eq "^${POD_NAME}/${escaped}(/.*)?\$"; then
|
||||
echo "ERROR: Archiv enthält kein '$required'." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "Archiv-Integrität OK (.env, postgres-data, grafana-data, data vorhanden)."
|
||||
|
||||
# Archiv enthält Secrets (.env) -> nur der Besitzer darf lesen. (umask 077
|
||||
# hat das Archiv bereits als 600 angelegt; chmod hier ist eine zusätzliche
|
||||
# Absicherung, falls sich das je ändert.)
|
||||
chmod 600 "$ARCHIVE"
|
||||
|
||||
SIZE=$(du -h "$ARCHIVE" | cut -f1)
|
||||
echo "Backup fertig: $ARCHIVE ($SIZE, chmod 600)"
|
||||
@@ -212,6 +212,12 @@ podman exec "$DB_CTR_NAME" psql -U finance -d finance -c \
|
||||
echo "Role 'finance_read' is ready."
|
||||
|
||||
# API container (runs alembic upgrade head on start via entrypoint.sh)
|
||||
# The extra "-v $ENV_FILE:/data/.env:Z" below (Ausbaustufe 4 Task 2, Admin
|
||||
# password change) is a SINGLE-FILE bind mount. Unlike a directory mount,
|
||||
# this follows the host file's INODE: the app must read/write it in place
|
||||
# (open r+/truncate under flock - see app/services/admin.py), NEVER via
|
||||
# temp-file+rename, because a rename would swap in a new inode that the
|
||||
# already-running mount no longer points at.
|
||||
podman run -d --name "$API_CTR_NAME" --pod "$POD_NAME" \
|
||||
-e FB_DATABASE_URL="postgresql+psycopg://finance:$POSTGRES_PASSWORD@localhost:5432/finance" \
|
||||
-e FB_API_KEY \
|
||||
@@ -221,6 +227,7 @@ podman run -d --name "$API_CTR_NAME" --pod "$POD_NAME" \
|
||||
-e FB_INBOX_DIR=/data/inbox \
|
||||
-e FB_UPLOADS_DIR=/data/uploads \
|
||||
-v "$DATA_DIR:/data:Z" \
|
||||
-v "$ENV_FILE:/data/.env:Z" \
|
||||
"$API_IMAGE"
|
||||
echo "Container '$API_CTR_NAME' started (rc=$?)"
|
||||
|
||||
|
||||
314
docs/ARCHITEKTUR.md
Normal file
314
docs/ARCHITEKTUR.md
Normal file
@@ -0,0 +1,314 @@
|
||||
# Architektur — Finanzberatungs-Tool (`finance/`)
|
||||
|
||||
> Diese Doku richtet sich **primär an künftige Claude-Coding-Sessions** in
|
||||
> diesem Repo, sekundär an Menschen. Sie beschreibt den Stand nach
|
||||
> Ausbaustufe 4 (v0.5.0). Jeder hier genannte Modul-/Funktionsname existiert
|
||||
> real im Code unter `finance/app/` — bei Unsicherheit den Pfad öffnen statt
|
||||
> zu raten. Betriebs-/Konventionsregeln stehen in `/home/wlfb/bin/CLAUDE.md`.
|
||||
|
||||
## 1. Komponenten-Diagramm
|
||||
|
||||
Kernachse: `routers/* → services/* → engine/* + parsers/* → models/tables.py
|
||||
→ db.py`. `routers/gui.py` ist ein Sonderfall — es importiert
|
||||
Handler-Funktionen direkt aus anderen Routern (`imports.py`, `planning.py`,
|
||||
`scenarios.py`, `transactions.py`), um serverseitige Logik nicht zu
|
||||
duplizieren. `routers/admin.py` importiert `gui_session` und `templates`
|
||||
direkt aus `routers/gui.py`.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph TPL["templates (Jinja2, app/templates/)"]
|
||||
T_pages["base.html, login.html, index.html<br/>transactions.html, salden.html<br/>import.html, planning.html<br/>admin.html, hilfe.html"]
|
||||
end
|
||||
|
||||
subgraph ROUTERS["routers/"]
|
||||
R_gui["gui.py<br/>gui_session, index, buchungen,<br/>salden_page, import_page, planung_page"]
|
||||
R_accounts["accounts.py<br/>list_accounts, create_account,<br/>patch_account"]
|
||||
R_transactions["transactions.py<br/>list_transactions, create_transaction,<br/>patch_transaction, count_transactions"]
|
||||
R_categories["categories.py<br/>list_categories, create_category_rule"]
|
||||
R_imports["imports.py<br/>upload, scan_inbox, preview,<br/>confirm, rollback"]
|
||||
R_planning["planning.py<br/>list_recurring, list_planned,<br/>list_loans, recurring_suggestions"]
|
||||
R_scenarios["scenarios.py<br/>create_scenario, add_modifier,<br/>project_scenario"]
|
||||
R_admin["admin.py<br/>admin_page, admin_change_password,<br/>apply_category_rules"]
|
||||
end
|
||||
|
||||
subgraph SERVICES["services/"]
|
||||
S_importer["importer.py<br/>process_file, process_pdf, process_csv"]
|
||||
S_balances["balances.py<br/>account_balance(at), total_balance"]
|
||||
S_categorize["categorize.py<br/>apply_rules"]
|
||||
S_suggestions["suggestions.py<br/>suggest_recurring"]
|
||||
S_projection["projection_service.py<br/>run_projection"]
|
||||
S_admin["admin.py<br/>change_password, apply_rules_retroactively"]
|
||||
end
|
||||
|
||||
subgraph ENGINE["engine/ (rein, kein DB-Zugriff)"]
|
||||
E_loans["loans.py<br/>loan_schedule, annuity_payment, add_months"]
|
||||
E_recurrence["recurrence.py<br/>occurrences"]
|
||||
E_projection["projection.py<br/>project"]
|
||||
E_scenario["scenario.py<br/>build_cashflows (PlainRecurring/<br/>PlainPlanned/PlainModifier)"]
|
||||
end
|
||||
|
||||
subgraph PARSERS["parsers/"]
|
||||
P_base["base.py<br/>parse_german_amount, parse_german_date"]
|
||||
P_detect["detect.py<br/>detect_bank"]
|
||||
P_validate["validate.py<br/>balance_difference, dedup_hash"]
|
||||
P_vr["vr.py — parse"]
|
||||
P_hvb["hvb.py — parse"]
|
||||
P_dkb["dkb.py — parse"]
|
||||
P_csv["csv_formats.py<br/>detect_csv_format, parse_csv"]
|
||||
P_registry["registry.py — parse_pdf"]
|
||||
end
|
||||
|
||||
subgraph MODELS["models/ + alembic/"]
|
||||
M_tables["tables.py<br/>Account, Statement, Transaction,<br/>RecurringItem, PlannedItem, Loan,<br/>Scenario, ProjectionResult, ..."]
|
||||
M_views["views.py — create_views()<br/>v_balance_history, v_balance_total,<br/>v_monthly_by_category, v_projection"]
|
||||
M_alembic["alembic/versions<br/>b2b1f5a18a74_initial_schema<br/>1ef6a356f028_konto_anker"]
|
||||
end
|
||||
|
||||
R_gui --> T_pages
|
||||
R_admin --> T_pages
|
||||
|
||||
R_gui --> R_imports
|
||||
R_gui --> R_planning
|
||||
R_gui --> R_scenarios
|
||||
R_gui --> R_transactions
|
||||
R_gui --> E_loans
|
||||
R_gui --> E_recurrence
|
||||
R_gui --> S_balances
|
||||
R_admin --> R_gui
|
||||
|
||||
R_accounts --> S_balances
|
||||
R_transactions --> P_validate
|
||||
R_transactions --> S_categorize
|
||||
R_imports --> S_importer
|
||||
R_planning --> E_loans
|
||||
R_planning --> S_suggestions
|
||||
R_scenarios --> S_projection
|
||||
R_admin --> S_admin
|
||||
|
||||
ROUTERS -.-> M_tables
|
||||
|
||||
S_importer --> P_csv
|
||||
S_importer --> P_registry
|
||||
S_importer --> P_validate
|
||||
S_importer --> S_categorize
|
||||
S_projection --> E_loans
|
||||
S_projection --> E_projection
|
||||
S_projection --> E_scenario
|
||||
S_projection --> S_balances
|
||||
S_admin --> S_categorize
|
||||
|
||||
E_scenario --> E_loans
|
||||
E_scenario --> E_recurrence
|
||||
|
||||
P_registry --> P_vr
|
||||
P_registry --> P_hvb
|
||||
P_registry --> P_dkb
|
||||
P_registry --> P_detect
|
||||
P_vr --> P_base
|
||||
P_hvb --> P_base
|
||||
P_dkb --> P_base
|
||||
P_csv --> P_base
|
||||
P_validate --> P_base
|
||||
|
||||
SERVICES -.-> M_tables
|
||||
M_alembic -.->|erzeugt Schema fuer| M_tables
|
||||
M_tables -.->|Basis fuer| M_views
|
||||
```
|
||||
|
||||
Nicht im Diagramm (Querschnitt, siehe Prosa unten): `app/auth.py`
|
||||
(`require_auth`, `current_password_hash`, `session_valid` — an fast allen
|
||||
Routern als FastAPI-Dependency), `app/config.py` (`get_settings`),
|
||||
`app/db.py` (`get_session`, `get_engine`), `app/main.py` (bindet alle Router
|
||||
+ `lifespan` → `create_views`), `app/version.py` (`get_version`).
|
||||
|
||||
## 2. Sequenzdiagramm — Import-Fluss
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser/GUI
|
||||
participant RI as routers/imports.py
|
||||
participant IM as services/importer.py
|
||||
participant PA as parsers (registry.py / csv_formats.py)
|
||||
participant VA as parsers/validate.py
|
||||
participant CA as services/categorize.py
|
||||
participant DB as Postgres
|
||||
|
||||
Note over B,RI: Upload ODER Inbox-Scan
|
||||
alt Datei-Upload
|
||||
B->>RI: POST /api/imports/upload (PDF/CSV)
|
||||
else Inbox-Scan
|
||||
B->>RI: POST /api/imports/scan-inbox
|
||||
Note right of RI: iteriert *.pdf und *.csv in FB_INBOX_DIR
|
||||
end
|
||||
|
||||
RI->>IM: process_file(session, path)
|
||||
alt Endung .csv
|
||||
IM->>PA: parse_csv(path) [csv_formats.detect_csv_format]
|
||||
else Endung .pdf (Default)
|
||||
IM->>PA: parse_pdf(path) [registry.detect_bank -> vr/hvb/dkb.parse]
|
||||
end
|
||||
PA-->>IM: ParsedStatement / ParsedCsv
|
||||
|
||||
IM->>IM: _find_or_create_account(bank, iban)
|
||||
IM->>DB: INSERT Statement(status="draft")
|
||||
|
||||
opt Saldo pruefbar (PDF immer; CSV nur VR: balance_checkable=True)
|
||||
IM->>VA: balance_difference(parsed)
|
||||
Note right of IM: Differenz != 0 -> Statement.status="error", Abbruch
|
||||
end
|
||||
|
||||
loop je Buchungszeile
|
||||
IM->>VA: dedup_hash(account_id, booking_date, amount, purpose)
|
||||
IM->>DB: INSERT Transaction(status="draft", is_duplicate=?)
|
||||
end
|
||||
IM->>CA: apply_rules(session, drafts)
|
||||
IM->>IM: _apply_anchor_autofill(account, anchor)
|
||||
Note right of IM: nur wenn CSV-Anker-Datum >= Account.anchor_date (oder kein Anker)
|
||||
IM->>DB: Datei inbox -> uploads verschieben, COMMIT
|
||||
IM-->>RI: Statement (draft)
|
||||
RI-->>B: 201 StatementOut
|
||||
|
||||
B->>RI: GET /api/imports/{id}/preview
|
||||
RI->>DB: SELECT Transactions WHERE statement_id=id
|
||||
RI->>RI: balance_ok = true/false/None (dreiwertig:<br/>None bei fehlenden Salden, z.B. HVB/DKB-CSV)
|
||||
RI-->>B: PreviewOut(balance_ok, duplicates)
|
||||
|
||||
alt Bestaetigen
|
||||
B->>RI: POST /api/imports/{id}/confirm
|
||||
loop je Draft-Transaktion (nicht is_duplicate)
|
||||
RI->>DB: Dedup-Recheck: dedup_hash bereits confirmed<br/>in ANDEREM Statement?
|
||||
alt Kollision gefunden
|
||||
RI->>DB: tx.is_duplicate = true (bleibt draft)
|
||||
else keine Kollision
|
||||
RI->>DB: tx.status = "confirmed"
|
||||
end
|
||||
end
|
||||
RI->>DB: Statement.status = "confirmed"
|
||||
RI-->>B: 200 StatementOut
|
||||
else Verwerfen (Rollback)
|
||||
B->>RI: POST /api/imports/{id}/rollback
|
||||
Note right of RI: nur wenn Statement.status == "confirmed"
|
||||
RI->>DB: DELETE Transactions + Statement
|
||||
RI-->>B: 200 {deleted_transactions, statement_id}
|
||||
end
|
||||
```
|
||||
|
||||
## 3. Deployment-Diagramm
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph HOST["Host wlfb (rootless Podman)"]
|
||||
Boot["create_pod_finance.sh<br/>Bootstrap: FB_PASSWORD erzeugen<br/>-> PBKDF2-Hash (FB_GUI_PASSWORD_HASH)<br/>-> Grafana-Sync (grafana cli<br/>admin reset-admin-password)"]
|
||||
Backup["backup_finance_pod.sh [ZIELDIR]<br/>KALT: systemctl stop -> tar czf<br/>via 'podman unshare' (subuid-Daten)<br/>-> systemctl start -> Readiness-Curl<br/>-> tar -tzf Integritaet -> chmod 600"]
|
||||
|
||||
subgraph SYSTEMD["systemd --user (Unit-Dateien in ~/.config/systemd/user, je chmod 600)"]
|
||||
U1["pod-finance_pod.service"]
|
||||
U2["container-finance-db_ctr.service"]
|
||||
U3["container-finance-api_ctr.service"]
|
||||
U4["container-finance-grafana_ctr.service"]
|
||||
end
|
||||
|
||||
subgraph POD["Pod finance_pod (podman pod)"]
|
||||
API["finance-api_ctr<br/>127.0.0.1:8096 -> 8000<br/>uvicorn app.main:app<br/>(entrypoint.sh: alembic upgrade head)"]
|
||||
DBC["finance-db_ctr<br/>Postgres 17.10<br/>NUR pod-intern: localhost:5432<br/>(kein Host-Port)"]
|
||||
GRA["finance-grafana_ctr<br/>127.0.0.1:8097 -> 3000<br/>grafana-oss 12.1.0"]
|
||||
end
|
||||
|
||||
subgraph BINDDIR["Bind-Mounts ~/.local/share/finance_pod (BIND_DIR, chmod 700)"]
|
||||
DATA["data/ (-v DATA_DIR:/data:Z)<br/>inbox/, uploads/"]
|
||||
ENVF[".env (-v ENV_FILE:/data/.env:Z)<br/>EINZELDATEI-Mount folgt dem INODE!<br/>App schreibt IN-PLACE (open r+,<br/>flock, truncate) - NIE Temp+rename<br/>(services/admin.py _rewrite_env_file)"]
|
||||
PGD["postgres-data/"]
|
||||
GRD["grafana-data/ (setgid, gid 0)"]
|
||||
end
|
||||
|
||||
subgraph REPO["Repo-Checkout ~/bin/finance/grafana (read-only Mounts)"]
|
||||
PROV["grafana/provisioning<br/>-> /etc/grafana/provisioning:Z,ro"]
|
||||
DASH["grafana/dashboards/finanzen.json<br/>-> /var/lib/grafana/dashboards:Z,ro"]
|
||||
end
|
||||
end
|
||||
|
||||
Boot -->|erzeugt/aktualisiert| ENVF
|
||||
Boot -->|podman generate systemd --new| SYSTEMD
|
||||
Boot -->|podman run| API
|
||||
Boot -->|podman run| DBC
|
||||
Boot -->|podman run| GRA
|
||||
Boot -.->|exec grafana cli admin reset-admin-password| GRA
|
||||
|
||||
SYSTEMD -->|verwaltet Start/Stop| POD
|
||||
|
||||
API -->|liest/schreibt in-place| ENVF
|
||||
API -->|Daten| DATA
|
||||
API -.->|SQL localhost:5432| DBC
|
||||
DBC --> PGD
|
||||
GRA --> GRD
|
||||
GRA --> PROV
|
||||
GRA --> DASH
|
||||
GRA -.->|SQL read-only Rolle finance_read| DBC
|
||||
API -.->|HTTP PUT .../password, siehe change_password| GRA
|
||||
|
||||
Backup -->|stoppt/startet| U1
|
||||
Backup -->|tar czf BIND_DIR| BINDDIR
|
||||
```
|
||||
|
||||
## 4. Zentrale Konzepte (kompakt)
|
||||
|
||||
**Saldo-Anker (per Tagesende).** `Account.anchor_date` /
|
||||
`Account.anchor_balance` (Migration `1ef6a356f028_konto_anker`) ersetzen die
|
||||
frühere Statement-closing-Logik als Basis der Saldenrechnung.
|
||||
`services/balances.py::account_balance(session, account, at)` addiert bzw.
|
||||
subtrahiert bestätigte Buchungen zwischen Anker und `at`; der Anker gilt
|
||||
**per Tagesende** des Ankerdatums (enthält bereits alle Buchungen bis
|
||||
einschließlich diesem Tag). CSV-Importe füllen einen fehlenden oder
|
||||
veralteten Anker automatisch nach (`_apply_anchor_autofill` in
|
||||
`services/importer.py`) — ein manuell per PATCH gesetzter, neuerer Anker
|
||||
bleibt dabei unangetastet. Die Postgres-Views `v_balance_history` /
|
||||
`v_balance_total` (`models/views.py`) bilden dieselbe Anker-Semantik als
|
||||
lückenlose Tagesreihe (Carry-Forward via `generate_series` + Fenster-`SUM`)
|
||||
für Grafana ab.
|
||||
|
||||
**`dedup_hash`.** `parsers/validate.py::dedup_hash(account_id, booking_date,
|
||||
amount, purpose)` erzeugt einen deterministischen Hash je Buchung. Er wird
|
||||
zweimal genutzt: (1) beim Import markiert `services/importer.py` Entwürfe
|
||||
als `is_duplicate`, wenn der Hash bereits unter einer *bestätigten*
|
||||
Transaktion existiert; (2) beim Bestätigen (`routers/imports.py::confirm`)
|
||||
erfolgt ein **Dedup-Recheck** gegen zwischenzeitlich bestätigte Buchungen
|
||||
*anderer* Statements — zwei identische Buchungen *innerhalb* desselben
|
||||
Statements teilen sich zwar denselben Hash, dürfen sich aber nicht
|
||||
gegenseitig ausschließen, da die in der Preview geprüfte Bilanz von beiden
|
||||
abhängt.
|
||||
|
||||
**Statement-Lebenszyklus.** `Statement.status` (`models/tables.py`) durchläuft
|
||||
`draft` → `confirmed`, oder `draft` → `error` (Bank/Format nicht erkannt,
|
||||
Saldo-Differenz, kaputte Datei). Transaktionen haben denselben
|
||||
Status-Wortschatz (`draft`/`confirmed`) pro Zeile. Bestätigte Statements sind
|
||||
nur noch über `rollback` (löscht Statement + Transaktionen komplett) zu
|
||||
entfernen, nicht mehr über `DELETE /api/imports/{id}` (nur für Entwürfe und Fehl-Importe).
|
||||
|
||||
**Szenario-Engine ist rein.** `app/engine/*` (`loans.py`, `recurrence.py`,
|
||||
`projection.py`, `scenario.py`) hat **keinen** Datenbank- oder
|
||||
Request-Zugriff — nur `scenario.py` importiert innerhalb der Engine von
|
||||
`loans.py`/`recurrence.py`, sonst keine Abhängigkeiten auf `app.*`. Ein-/
|
||||
Ausgaben sind einfache Dataclasses (`Installment`, `PlainRecurring`,
|
||||
`PlainPlanned`, `PlainModifier`, `Projection`). `services/projection_service.py`
|
||||
ist die einzige Brücke: es lädt ORM-Objekte (`Loan`, `RecurringItem`,
|
||||
`PlannedItem`, `Scenario`, `ScenarioModifier`), baut daraus die Plain-Objekte
|
||||
und ruft `engine.scenario.build_cashflows` → `engine.projection.project`.
|
||||
|
||||
**`VERSION`-Datei als Single Source.** `finance/VERSION` (ein Einzeiler, z.B.
|
||||
`0.4.0`) ist die einzige Versionsquelle: `app/version.py::get_version()`
|
||||
liest sie (Fallback `"0.0.0-dev"`), gespeist in den Jinja2-Footer
|
||||
(`templates.env.globals["app_version"]` in `routers/gui.py`) und
|
||||
`GET /api/version` (`main.py`). `Containerfile` kopiert `VERSION` ins Image;
|
||||
`create_pod_finance.sh` liest dieselbe Datei für den Image-Tag
|
||||
(`API_IMAGE="localhost/finance-api:$(cat "$FINANCE_DIR/VERSION")"`). Ein
|
||||
Release ist somit: `VERSION` hochzählen → Image bauen → Redeploy.
|
||||
|
||||
**DATENSCHUTZ: Fixtures enthalten echte Kontodaten.** `finance/tests/fixtures/`
|
||||
enthält reale PDF-/CSV-Kontoauszüge (`*.pdf`, `*.csv`, `expected_*.json`) —
|
||||
siehe `finance/tests/fixtures/README.md` und `finance/.gitignore` (alle drei
|
||||
Muster ausgeschlossen). `scripts/parser_audit.py` und
|
||||
`scripts/parser_vs_csv.py` geben Klartext-Kontodaten auf der Konsole aus und
|
||||
sind ausdrücklich mit "NUR lokal verwenden" markiert — ihre Ausgabe darf
|
||||
nicht in Commits, Reports, Tickets oder Chat-Antworten landen. Details und
|
||||
verbindliche Regeln: siehe `CLAUDE.md`.
|
||||
44
docs/superpowers/plans/2026-07-20-ausbaustufe-4.md
Normal file
44
docs/superpowers/plans/2026-07-20-ausbaustufe-4.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Ausbaustufe 4 — Backup, Admin-Seite mit Passwortänderung, Grafana-Lücken, Architektur-Doku
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Fable-Testagent als Gate je Task (Nutzer-Vorgabe). Checkbox-Tracking.
|
||||
|
||||
**Goal:** Kalt-Backup-Skript; Admin-Seite (Passwortänderung vollintegriert + Regeln-neu-anwenden); durchgehende Grafana-Kontostandslinien; Architektur-Doku (Mermaid) + CLAUDE.md für künftige Sessions. Version 0.5.0.
|
||||
|
||||
**Nutzerentscheidungen (2026-07-20):** Backup kalt (Pod-Stopp) nach `~/backups/` (Zielverzeichnis als Argument überschreibbar); Passwortänderung vollintegriert (.env in Container gemountet, Laufzeit-Lesen, Grafana per HTTP-API); Admin-Zusatzfunktion NUR „Regeln neu anwenden".
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Wie Ausbaustufe 3 (Decimal, Deutsch, Suite grün — Basis 132, Trailer, DATENSCHUTZ, UX-Regel sichtbar/disabled, Fable-Testagent-Gate je Task). Deployment gesammelt am Ende (Task 5).
|
||||
- Backup-Datei enthält Secrets (.env) → chmod 600.
|
||||
- KRITISCH (.env im Container): Bind-Mount einer EINZELDATEI folgt dem Inode — die App muss die Datei IN-PLACE beschreiben (open r+/truncate, flock), NIEMALS über Temp-Datei+rename. Host-Skript-seitige sed-Edits erzeugen neue Inodes — unkritisch, weil das Skript die Container ohnehin neu erstellt.
|
||||
|
||||
### Task 1: Backup-Skript
|
||||
|
||||
**Files:** Create `backup_finance_pod.sh` (Repo-Root, ausführbar).
|
||||
**Interface:** `./backup_finance_pod.sh [ZIELDIR]` (Default `$HOME/backups`): systemctl --user stop pod-finance_pod.service → `tar czf ZIELDIR/finance_pod_$(date +%F_%H%M).tar.gz -C ~/.local/share finance_pod` → start → Readiness-Curl /login → `tar -tzf`-Integritätscheck (muss .env und postgres-data enthalten) → chmod 600 → Ausgabe Pfad+Größe. Bei jedem Fehler: Service wieder starten (trap), Exit ≠ 0.
|
||||
- [x] Skript schreiben, `bash -n` · [ ] FABLE-TESTAGENT: Skript LIVE ausführen (kurze Downtime ok), prüfen: Service danach active, Login 200, Archiv 600 + enthält .env/postgres-data/grafana-data/data, Restore-Probe NUR als tar-Listing (kein Zurückspielen); Fehlerpfad: Skript mit unbeschreibbarem Ziel → Service läuft trotzdem weiter · [ ] Commit `feat: Kalt-Backup-Skript finance_pod`
|
||||
|
||||
### Task 2: Admin-Seite (Passwortänderung + Regeln-Anwenden)
|
||||
|
||||
**Files:** Modify `create_pod_finance.sh` (Mount `-v "$ENV_FILE:/data/.env:Z"` am API-Container; Kommentar Inode-Regel), `finance/app/config.py` (+`env_file: Path` aus `FB_ENV_FILE`, Default `/data/.env`), `finance/app/auth.py` (Hash-Lookup zur Laufzeit: existiert env_file → Werte daraus parsen (single-quoted), sonst Fallback env; kleine Helferfunktion mit mtime-Cache), Create `finance/app/services/admin.py` (`change_password(old, new)`: verify old → Grafana `PUT http://localhost:3000/api/admin/users/1/password` Basic-Auth admin:old → .env IN-PLACE (flock, FB_PASSWORD+FB_GUI_PASSWORD_HASH single-quoted ersetzen) → bei Grafana-Fehler abbrechen ohne .env-Änderung; `apply_rules_retroactively(session) -> int` auf unkategorisierte confirmed), Create `finance/app/routers/admin.py` (GUI `GET /admin` [gui_session] + `POST /admin/passwort` [gui_session, Form alt/neu/neu2, neu ≥ 8 Zeichen] + API `POST /api/category-rules/apply` [require_auth]), Templates `admin.html` + Nav „Admin", Tests.
|
||||
**Semantik:** Session-Cookies bleiben nach Änderung gültig (Secret unverändert — dokumentieren). Meldungen Deutsch. In Tests env_file → tmp_path (FB_ENV_FILE), Grafana-Call gemockt (monkeypatch), In-Place-Schreiben per Inode-Vergleich getestet.
|
||||
- [x] TDD · [ ] FABLE-TESTAGENT (Suite; Inode-Konstanz beim Schreiben; Mock-Grafana-Reihenfolge: kein .env-Write bei Grafana-Fehler; /admin-Auth; apply-Endpoint zählt korrekt) · [ ] Commit `feat: Admin-Seite mit Passwortaenderung und Regel-Neuanwendung`
|
||||
|
||||
### Task 3: Grafana durchgehende Linien
|
||||
|
||||
**Files:** Modify `finance/app/models/views.py` (v_balance_history/v_balance_total: tägliche Reihe via `generate_series(min(start), CURRENT_DATE, '1 day')` je Konto mit Carry-Forward des ankerbasierten Saldos — keine NULL-Lücken mehr), `finance/grafana/dashboards/finanzen.json` (Panels 1+2: `lineInterpolation: "stepAfter"`, `spanNulls: true`).
|
||||
- [x] Implementieren (SQL Postgres-only wie bisher) · [ ] FABLE-TESTAGENT: Wegwerf-DB auf finance-db_ctr (Muster A3-T1): synthetische Daten mit mehrtägigen Buchungslücken → Views liefern JEDEN Tag genau eine Zeile je Konto, Werte = Handrechnung, keine Lücken; finanzen.json parsebar + Optionen gesetzt; Suite grün · [ ] Commit `fix: lueckenlose taegliche Saldo-Reihen fuer Grafana`
|
||||
|
||||
### Task 4: Architektur-Doku + CLAUDE.md
|
||||
|
||||
**Files:** Create `docs/ARCHITEKTUR.md` (Mermaid: (1) Komponenten-Diagramm Module→Funktionen: parsers [base/detect/validate/vr/hvb/dkb/csv_formats/registry], engine [loans/recurrence/projection/scenario], services [importer/balances/categorize/suggestions/projection_service/admin], routers [accounts/transactions/categories/imports/planning/scenarios/gui/admin], templates/Seiten, models+views, alembic; (2) Datenfluss-Sequenz Import CSV/PDF→Draft→Confirm→Anker/Salden; (3) Deployment-Diagramm Pod/Container/Mounts/Ports/systemd/.env; je Knoten Stichwort-Funktionsliste), Create `CLAUDE.md` (Repo-Root ~/bin: Projektüberblick, Betriebs-/Testkommandos, DATENSCHUTZ-Regeln [fixtures = echte Daten!], Konventionen [Decimal, Deutsch, Fable-Test-Gate, Superpowers-Workflow, Plan-/Ledger-Orte, Versions-Prozess VERSION-Datei, Backup/DR], Verweis auf ARCHITEKTUR.md + docs/superpowers/plans/), Update Claude-Memory.
|
||||
- [x] Schreiben · [ ] FABLE-TESTAGENT: Faktencheck der Doku gegen den Code (jede benannte Datei/Funktion existiert; Mermaid-Syntax valide; keine echten Daten) · [ ] Commit `docs: Architektur (Mermaid) und CLAUDE.md fuer kuenftige Sessions`
|
||||
|
||||
### Task 5: v0.5.0 + Redeploy + Smoke
|
||||
|
||||
- [x] VERSION 0.5.0, Build, Redeploy · [ ] Smoke: Kernseiten inkl. /admin; Passwortänderung E2E LIVE (auf Temp-Passwort ändern → GUI+Grafana-Login mit Temp OK → zurück auf Original ändern → Original-Login OK); Regeln-Anwenden-Button; Grafana-Panel liefert tägliche Punkte (View-COUNT == Kalendertage) · [ ] Backup-Skript einmal final ausführen (frisches Backup nach Abschluss) · [ ] Plan-Häkchen, Ledger, Memory, Commit + PUSH beider Repos
|
||||
|
||||
## Abschluss-Checkliste
|
||||
- [x] Suite grün (>= 140 erwartet); alle Fable-Gates dokumentiert
|
||||
- [x] Live v0.5.0: Passwort unverändert (nach E2E-Probe zurückrotiert), Admin-Seite funktional, Grafana-Linien durchgehend, Backup in ~/backups vorhanden (600)
|
||||
- [x] ARCHITEKTUR.md + CLAUDE.md committet; Memory aktuell; gepusht
|
||||
@@ -1 +1 @@
|
||||
0.4.0
|
||||
0.5.0
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from itsdangerous import BadSignature, TimestampSigner
|
||||
@@ -10,6 +12,48 @@ from app.config import get_settings
|
||||
COOKIE = "fb_session"
|
||||
MAX_AGE = 60 * 60 * 12 # 12 h
|
||||
|
||||
# mtime-Cache fuer den aus der (ggf. bind-gemounteten) .env gelesenen
|
||||
# GUI-Passwort-Hash: {Pfad: (mtime, hash)}. Vermeidet, die Datei bei jedem
|
||||
# Login/Request neu zu parsen, invalidiert sich aber automatisch, sobald
|
||||
# services.admin.change_password() die Datei in-place neu schreibt (neue
|
||||
# mtime).
|
||||
_hash_cache: dict[str, tuple[float, str]] = {}
|
||||
|
||||
_ENV_LINE_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)='([^']*)'\s*$")
|
||||
|
||||
|
||||
def _parse_env_file(env_file: Path) -> dict[str, str]:
|
||||
"""Parst single-quoted KEY='value'-Zeilen wie sie create_pod_finance.sh
|
||||
schreibt. Zeilen in anderer Form (Kommentare, unquoted, leer) werden
|
||||
ignoriert statt einen Fehler zu werfen."""
|
||||
values: dict[str, str] = {}
|
||||
for line in env_file.read_text().splitlines():
|
||||
m = _ENV_LINE_RE.match(line)
|
||||
if m:
|
||||
values[m.group(1)] = m.group(2)
|
||||
return values
|
||||
|
||||
|
||||
def current_password_hash() -> str:
|
||||
"""Liest FB_GUI_PASSWORD_HASH zur Laufzeit aus der .env (Bind-Mount,
|
||||
siehe KRITISCH-Hinweis Global Constraints Ausbaustufe 4: Einzeldatei-Mount
|
||||
folgt dem Inode, die Datei wird von services.admin.change_password()
|
||||
in-place ueberschrieben). Existiert die Datei nicht (z.B. lokale
|
||||
Entwicklung ohne Pod), wird auf die Umgebungsvariable zurueckgefallen."""
|
||||
settings = get_settings()
|
||||
env_file = settings.env_file
|
||||
if env_file.exists():
|
||||
mtime = env_file.stat().st_mtime
|
||||
key = str(env_file)
|
||||
cached = _hash_cache.get(key)
|
||||
if cached is not None and cached[0] == mtime:
|
||||
return cached[1]
|
||||
value = _parse_env_file(env_file).get("FB_GUI_PASSWORD_HASH")
|
||||
if value is not None:
|
||||
_hash_cache[key] = (mtime, value)
|
||||
return value
|
||||
return settings.gui_password_hash
|
||||
|
||||
|
||||
def hash_password(pw: str, salt: str | None = None) -> str:
|
||||
salt = salt or secrets.token_hex(16)
|
||||
|
||||
@@ -16,6 +16,8 @@ class Settings:
|
||||
uploads_dir: Path
|
||||
warn_threshold: Decimal
|
||||
horizon_days: int
|
||||
env_file: Path
|
||||
grafana_url: str
|
||||
|
||||
|
||||
@lru_cache
|
||||
@@ -31,4 +33,9 @@ def get_settings() -> Settings:
|
||||
uploads_dir=Path(e("FB_UPLOADS_DIR", "./uploads")),
|
||||
warn_threshold=Decimal(e("FB_WARN_THRESHOLD", "0")),
|
||||
horizon_days=int(e("FB_HORIZON_DAYS", "548")),
|
||||
# Bind-gemountete .env (siehe create_pod_finance.sh): Laufzeit-Lesen
|
||||
# der GUI-Passwort-Hashes fuer die Admin-Passwortaenderung (Ausbaustufe
|
||||
# 4 Task 2). Default passt zum Container-Mountpunkt "/data/.env".
|
||||
env_file=Path(e("FB_ENV_FILE", "/data/.env")),
|
||||
grafana_url=e("FB_GRAFANA_URL", "http://localhost:3000"),
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ from app.auth import require_auth
|
||||
from app.config import get_settings
|
||||
from app.db import get_engine
|
||||
from app.models.views import create_views
|
||||
from app.routers import (accounts, categories, gui, imports, planning,
|
||||
from app.routers import (accounts, admin, categories, gui, imports, planning,
|
||||
scenarios, transactions)
|
||||
from app.routers.gui import templates
|
||||
from app.version import get_version
|
||||
@@ -33,6 +33,7 @@ app.include_router(categories.router)
|
||||
app.include_router(imports.router)
|
||||
app.include_router(planning.router)
|
||||
app.include_router(scenarios.router)
|
||||
app.include_router(admin.router)
|
||||
|
||||
|
||||
@app.get("/login")
|
||||
@@ -44,7 +45,7 @@ def login_form(request: Request):
|
||||
def login(username: str = Form(...), password: str = Form(...)):
|
||||
s = get_settings()
|
||||
if not (hmac.compare_digest(username, s.gui_user)
|
||||
and auth.verify_password(password, s.gui_password_hash)):
|
||||
and auth.verify_password(password, auth.current_password_hash())):
|
||||
return HTMLResponse("Login fehlgeschlagen", status_code=401)
|
||||
resp = RedirectResponse("/", status_code=303)
|
||||
resp.set_cookie(auth.COOKIE, auth.make_session_token(), httponly=True,
|
||||
|
||||
@@ -26,22 +26,83 @@ _ACCOUNT_OFFSET = """
|
||||
WHERE a.anchor_date IS NOT NULL AND a.anchor_balance IS NOT NULL
|
||||
"""
|
||||
|
||||
|
||||
# Per-account earliest day the daily series needs to start at: the earlier of
|
||||
# the account's first confirmed booking and its anchor_date (an anchor can
|
||||
# predate the first booking, e.g. an anchor set before any import). Accounts
|
||||
# with neither an anchor nor any confirmed booking have no meaningful start
|
||||
# and are excluded (nothing to plot).
|
||||
_ACCOUNT_START = """
|
||||
SELECT a.id AS account_id, a.name AS account,
|
||||
LEAST(
|
||||
COALESCE(a.anchor_date, ft.first_date),
|
||||
COALESCE(ft.first_date, a.anchor_date)
|
||||
) AS start_date
|
||||
FROM accounts a
|
||||
LEFT JOIN (
|
||||
SELECT account_id, MIN(booking_date) AS first_date
|
||||
FROM transactions WHERE status = 'confirmed'
|
||||
GROUP BY account_id
|
||||
) ft ON ft.account_id = a.id
|
||||
WHERE a.anchor_date IS NOT NULL OR ft.first_date IS NOT NULL
|
||||
"""
|
||||
|
||||
VIEWS: dict[str, str] = {
|
||||
# Daily (gap-free) balance per account: one row per calendar day from the
|
||||
# account's start date (see _ACCOUNT_START) through CURRENT_DATE. The
|
||||
# cumulative SUM ... OVER (... ROWS UNBOUNDED PRECEDING) carries the
|
||||
# previous day's balance forward on days without any confirmed booking,
|
||||
# matching services.balances.account_balance's per-Tagesende semantics
|
||||
# (base + cumulated amounts up to and including that day).
|
||||
"v_balance_history": f"""
|
||||
SELECT t.booking_date AS day, a.name AS account,
|
||||
COALESCE(b.base, 0)
|
||||
+ SUM(SUM(t.amount)) OVER (PARTITION BY a.id
|
||||
ORDER BY t.booking_date) AS balance
|
||||
FROM transactions t JOIN accounts a ON a.id = t.account_id
|
||||
LEFT JOIN ({_ACCOUNT_OFFSET}) b ON b.account_id = a.id
|
||||
WHERE t.status = 'confirmed'
|
||||
GROUP BY a.id, a.name, t.booking_date, b.base""",
|
||||
WITH offsets AS ({_ACCOUNT_OFFSET}),
|
||||
bounds AS ({_ACCOUNT_START}),
|
||||
days AS (
|
||||
SELECT b.account_id, b.account, gs.day::date AS day
|
||||
FROM bounds b
|
||||
CROSS JOIN LATERAL generate_series(
|
||||
b.start_date, CURRENT_DATE, interval '1 day') AS gs(day)
|
||||
),
|
||||
daily_tx AS (
|
||||
SELECT account_id, booking_date AS day, SUM(amount) AS day_amount
|
||||
FROM transactions WHERE status = 'confirmed'
|
||||
GROUP BY account_id, booking_date
|
||||
)
|
||||
SELECT d.day, d.account,
|
||||
COALESCE(o.base, 0)
|
||||
+ SUM(COALESCE(dt.day_amount, 0)) OVER (
|
||||
PARTITION BY d.account_id ORDER BY d.day
|
||||
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS balance
|
||||
FROM days d
|
||||
LEFT JOIN daily_tx dt ON dt.account_id = d.account_id AND dt.day = d.day
|
||||
LEFT JOIN offsets o ON o.account_id = d.account_id""",
|
||||
# Daily (gap-free) total balance across all accounts: same carry-forward
|
||||
# logic as v_balance_history, but summed globally (equivalent to summing
|
||||
# each account's own base + cumulated amounts, since a cumulative sum of
|
||||
# a union of per-day amounts equals the sum of per-account cumulative
|
||||
# sums). The day series spans from the earliest account start date
|
||||
# through CURRENT_DATE.
|
||||
"v_balance_total": f"""
|
||||
SELECT booking_date AS day,
|
||||
(SELECT COALESCE(SUM(base), 0)
|
||||
FROM ({_ACCOUNT_OFFSET}) s)
|
||||
+ SUM(SUM(amount)) OVER (ORDER BY booking_date) AS balance
|
||||
FROM transactions WHERE status = 'confirmed' GROUP BY booking_date""",
|
||||
WITH offsets AS ({_ACCOUNT_OFFSET}),
|
||||
bounds AS ({_ACCOUNT_START}),
|
||||
days AS (
|
||||
SELECT gs.day::date AS day
|
||||
FROM (SELECT MIN(start_date) AS start_date FROM bounds) b
|
||||
CROSS JOIN LATERAL generate_series(
|
||||
b.start_date, CURRENT_DATE, interval '1 day') AS gs(day)
|
||||
),
|
||||
daily_tx AS (
|
||||
SELECT booking_date AS day, SUM(amount) AS day_amount
|
||||
FROM transactions WHERE status = 'confirmed'
|
||||
GROUP BY booking_date
|
||||
)
|
||||
SELECT d.day,
|
||||
(SELECT COALESCE(SUM(base), 0) FROM offsets)
|
||||
+ SUM(COALESCE(dt.day_amount, 0)) OVER (
|
||||
ORDER BY d.day
|
||||
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS balance
|
||||
FROM days d
|
||||
LEFT JOIN daily_tx dt ON dt.day = d.day""",
|
||||
"v_monthly_by_category": """
|
||||
SELECT date_trunc('month', t.booking_date) AS month,
|
||||
COALESCE(c.name, 'unkategorisiert') AS category,
|
||||
|
||||
52
finance/app/routers/admin.py
Normal file
52
finance/app/routers/admin.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.db import get_session
|
||||
from app.routers.gui import gui_session, templates
|
||||
from app.services.admin import apply_rules_retroactively, change_password
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/admin", dependencies=[Depends(gui_session)])
|
||||
def admin_page(request: Request):
|
||||
return templates.TemplateResponse(request, "admin.html", {"error": None, "success": None})
|
||||
|
||||
|
||||
@router.post("/admin/passwort", dependencies=[Depends(gui_session)])
|
||||
def admin_change_password(
|
||||
request: Request,
|
||||
alt: str = Form(...),
|
||||
neu: str = Form(...),
|
||||
neu2: str = Form(...),
|
||||
):
|
||||
# UX-Regel: das Formular selbst bleibt immer sichtbar/bedienbar; Fehler
|
||||
# werden inline auf derselben Seite gemeldet statt still zu verwerfen.
|
||||
if len(neu) < 8:
|
||||
return templates.TemplateResponse(request, "admin.html", {
|
||||
"error": "Das neue Passwort muss mindestens 8 Zeichen lang sein.",
|
||||
"success": None,
|
||||
}, status_code=400)
|
||||
if neu != neu2:
|
||||
return templates.TemplateResponse(request, "admin.html", {
|
||||
"error": "Die Wiederholung stimmt nicht mit dem neuen Passwort überein.",
|
||||
"success": None,
|
||||
}, status_code=400)
|
||||
try:
|
||||
change_password(alt, neu)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
return templates.TemplateResponse(request, "admin.html", {
|
||||
"error": str(exc),
|
||||
"success": None,
|
||||
}, status_code=400)
|
||||
return templates.TemplateResponse(request, "admin.html", {
|
||||
"error": None,
|
||||
"success": "Passwort erfolgreich geändert (gilt für GUI und Grafana). "
|
||||
"Bestehende Sitzungen bleiben angemeldet.",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/api/category-rules/apply", dependencies=[Depends(require_auth)])
|
||||
def apply_category_rules(session: Session = Depends(get_session)):
|
||||
return {"categorized": apply_rules_retroactively(session)}
|
||||
116
finance/app/services/admin.py
Normal file
116
finance/app/services/admin.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Admin-Funktionen: Passwortaenderung (GUI+Grafana gemeinsam) und
|
||||
Regel-Neuanwendung (Ausbaustufe 4 Task 2).
|
||||
|
||||
KRITISCH (siehe Global Constraints des Plans): die .env wird im API-Container
|
||||
als Einzeldatei-Bind-Mount eingehaengt. Ein solcher Mount folgt dem Inode -
|
||||
die Datei MUSS in-place ueberschrieben werden (open r+/truncate unter flock),
|
||||
NIEMALS ueber Temp-Datei+rename (das erzeugt einen neuen Inode und würde vom
|
||||
Container nicht mehr gesehen).
|
||||
"""
|
||||
import fcntl
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_password_hash, hash_password, verify_password
|
||||
from app.config import get_settings
|
||||
from app.models.tables import Transaction
|
||||
from app.services.categorize import apply_rules
|
||||
|
||||
|
||||
def _sync_grafana_password(old: str, new: str, base_url: str) -> None:
|
||||
"""PUT /api/admin/users/1/password gegen Grafana, Basic-Auth mit dem
|
||||
ALTEN Passwort (admin:old) - Grafana authentifiziert den Request noch mit
|
||||
dem bisherigen Passwort, aendert es aber auf `new`. Wird IMMER vor dem
|
||||
Schreiben der .env aufgerufen: schlaegt Grafana fehl, bleibt die Datei
|
||||
unangetastet (kein inkonsistenter Zwischenzustand GUI-Hash != Grafana)."""
|
||||
url = f"{base_url.rstrip('/')}/api/admin/users/1/password"
|
||||
auth_header = "Basic " + b64encode(f"admin:{old}".encode()).decode()
|
||||
body = json.dumps({"password": new}).encode()
|
||||
req = urllib.request.Request(
|
||||
url, data=body, method="PUT",
|
||||
headers={"Authorization": auth_header, "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
if resp.status >= 400:
|
||||
raise RuntimeError(
|
||||
f"Grafana-Passwortänderung fehlgeschlagen (HTTP {resp.status})."
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise RuntimeError(
|
||||
f"Grafana-Passwortänderung fehlgeschlagen (HTTP {exc.code})."
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(
|
||||
f"Grafana-Passwortänderung fehlgeschlagen: {exc.reason}"
|
||||
) from exc
|
||||
except (TimeoutError, OSError) as exc:
|
||||
# Ein Hang in der Lesephase (nach erfolgreichem Verbindungsaufbau)
|
||||
# kann ein rohes TimeoutError/socket.timeout werfen, das urlopen NICHT
|
||||
# in ein URLError verpackt (URLError selbst ist zwar ein OSError, aber
|
||||
# dieser Pfad faengt Faelle ab, die es nicht bis dorthin schaffen).
|
||||
# Ohne diesen Fang wuerde die Exception bis in den Router durchschlagen
|
||||
# und dort als 500 statt als deutsche 400-Fehlermeldung enden.
|
||||
raise RuntimeError(
|
||||
f"Grafana-Passwortänderung fehlgeschlagen: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _rewrite_env_file(env_file: Path, new_password: str, new_hash: str) -> None:
|
||||
"""Ersetzt FB_PASSWORD und FB_GUI_PASSWORD_HASH IN-PLACE (open r+ unter
|
||||
flock, truncate) - der Inode der Datei bleibt unveraendert, siehe
|
||||
Modul-Docstring."""
|
||||
with open(env_file, "r+", encoding="utf-8") as f:
|
||||
fcntl.flock(f, fcntl.LOCK_EX)
|
||||
try:
|
||||
lines = f.read().splitlines()
|
||||
out = []
|
||||
for line in lines:
|
||||
if line.startswith("FB_PASSWORD="):
|
||||
out.append(f"FB_PASSWORD='{new_password}'")
|
||||
elif line.startswith("FB_GUI_PASSWORD_HASH="):
|
||||
out.append(f"FB_GUI_PASSWORD_HASH='{new_hash}'")
|
||||
else:
|
||||
out.append(line)
|
||||
f.seek(0)
|
||||
f.write("\n".join(out) + "\n")
|
||||
f.truncate()
|
||||
finally:
|
||||
fcntl.flock(f, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def change_password(old: str, new: str) -> None:
|
||||
"""Aendert das gemeinsame GUI-/Grafana-Passwort. Reihenfolge ist bindend:
|
||||
1) altes Passwort gegen den aktuellen Hash verifizieren,
|
||||
2) Grafana AKTUALISIEREN (bricht bei Fehler ab, ohne die .env
|
||||
anzufassen),
|
||||
3) erst danach die .env in-place neu schreiben.
|
||||
"""
|
||||
if not verify_password(old, current_password_hash()):
|
||||
raise ValueError("Das alte Passwort ist falsch.")
|
||||
|
||||
settings = get_settings()
|
||||
_sync_grafana_password(old, new, settings.grafana_url)
|
||||
|
||||
new_hash = hash_password(new)
|
||||
_rewrite_env_file(settings.env_file, new, new_hash)
|
||||
|
||||
|
||||
def apply_rules_retroactively(session: Session) -> int:
|
||||
"""Wendet die aktuellen Kategorie-Regeln rueckwirkend auf alle
|
||||
bestaetigten, noch unkategorisierten Buchungen an und committet."""
|
||||
txs = session.execute(
|
||||
select(Transaction).where(
|
||||
Transaction.status == "confirmed",
|
||||
Transaction.category_id.is_(None),
|
||||
)
|
||||
).scalars().all()
|
||||
hits = apply_rules(session, txs)
|
||||
session.commit()
|
||||
return hits
|
||||
@@ -72,6 +72,14 @@ td.account {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: #d4edda;
|
||||
border: 1px solid #7bc088;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.total-balance {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
|
||||
46
finance/app/templates/admin.html
Normal file
46
finance/app/templates/admin.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Admin</h1>
|
||||
|
||||
<section class="admin-section">
|
||||
<h2>Passwort ändern</h2>
|
||||
<p class="muted">Gilt gemeinsam für GUI- und Grafana-Login. Bestehende Sitzungen bleiben nach der Änderung gültig.</p>
|
||||
{% if error %}<div class="warning">{{ error }}</div>{% endif %}
|
||||
{% if success %}<div class="success">{{ success }}</div>{% endif %}
|
||||
<form method="post" action="/admin/passwort">
|
||||
<label>Altes Passwort
|
||||
<input type="password" name="alt" required>
|
||||
</label>
|
||||
<label>Neues Passwort (mind. 8 Zeichen)
|
||||
<input type="password" name="neu" minlength="8" required>
|
||||
</label>
|
||||
<label>Neues Passwort wiederholen
|
||||
<input type="password" name="neu2" minlength="8" required>
|
||||
</label>
|
||||
<button type="submit">Passwort ändern</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-section">
|
||||
<h2>Regeln neu anwenden</h2>
|
||||
<p class="muted">Wendet die aktuellen Kategorie-Regeln rückwirkend auf noch nicht kategorisierte, bestätigte Buchungen an.</p>
|
||||
<button type="button" hx-post="/api/category-rules/apply" hx-swap="none"
|
||||
hx-on::after-request="handleApplyRulesResult(event)">
|
||||
Regeln neu anwenden
|
||||
</button>
|
||||
<p id="apply-rules-result"></p>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
function handleApplyRulesResult(event) {
|
||||
var result = document.getElementById('apply-rules-result');
|
||||
if (event.detail.successful) {
|
||||
var data = JSON.parse(event.detail.xhr.responseText);
|
||||
result.textContent = data.categorized + ' Buchung(en) neu kategorisiert.';
|
||||
} else {
|
||||
result.textContent = 'Fehler beim Anwenden der Regeln.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -14,6 +14,7 @@
|
||||
<a href="/buchungen">Buchungen</a>
|
||||
<a href="/salden">Salden</a>
|
||||
<a href="/planung">Planung</a>
|
||||
<a href="/admin">Admin</a>
|
||||
<a href="/hilfe">Hilfe</a>
|
||||
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a>
|
||||
<form method="post" action="/logout">
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"lineWidth": 1,
|
||||
"fillOpacity": 0
|
||||
"fillOpacity": 0,
|
||||
"lineInterpolation": "stepAfter",
|
||||
"spanNulls": true
|
||||
},
|
||||
"unit": "currencyEUR"
|
||||
},
|
||||
@@ -63,7 +65,9 @@
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"lineWidth": 2,
|
||||
"fillOpacity": 10
|
||||
"fillOpacity": 10,
|
||||
"lineInterpolation": "stepAfter",
|
||||
"spanNulls": true
|
||||
},
|
||||
"unit": "currencyEUR"
|
||||
},
|
||||
|
||||
@@ -47,3 +47,47 @@ def client(db, monkeypatch):
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env_file(tmp_path):
|
||||
"""Erzeugt eine .env-Datei im create_pod_finance.sh-Format (single-quoted
|
||||
KEY='value') mit einem initialen Passwort+Hash. Liefert (Pfad, Passwort)
|
||||
fuer Tests der Admin-Passwortaenderung (Ausbaustufe 4 Task 2), die die
|
||||
Laufzeit-.env-Lesung/-Schreibung ueber FB_ENV_FILE gegen eine tmp_path
|
||||
statt der echten Container-.env pruefen."""
|
||||
from app.auth import hash_password
|
||||
path = tmp_path / ".env"
|
||||
password = "geheim123"
|
||||
path.write_text(
|
||||
f"FB_PASSWORD='{password}'\n"
|
||||
f"FB_GUI_PASSWORD_HASH='{hash_password(password)}'\n"
|
||||
)
|
||||
return path, password
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_env_file(db, env_file, monkeypatch):
|
||||
"""Wie `client`, aber der GUI-Passwort-Hash kommt NICHT aus der
|
||||
FB_GUI_PASSWORD_HASH-Umgebungsvariable, sondern wird zur Laufzeit aus der
|
||||
ueber FB_ENV_FILE referenzierten Datei gelesen (der Pfad, den
|
||||
change_password() in-place ueberschreibt)."""
|
||||
path, _password = env_file
|
||||
monkeypatch.setenv("FB_API_KEY", "test-key")
|
||||
monkeypatch.delenv("FB_GUI_PASSWORD_HASH", raising=False)
|
||||
monkeypatch.setenv("FB_ENV_FILE", str(path))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.auth import _hash_cache
|
||||
_hash_cache.clear()
|
||||
from app.main import app
|
||||
|
||||
def _override_get_session():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_session] = _override_get_session
|
||||
with TestClient(app) as c:
|
||||
yield c, env_file[1]
|
||||
app.dependency_overrides.clear()
|
||||
get_settings.cache_clear()
|
||||
_hash_cache.clear()
|
||||
|
||||
300
finance/tests/test_admin.py
Normal file
300
finance/tests/test_admin.py
Normal file
@@ -0,0 +1,300 @@
|
||||
import os
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
import app.services.admin as admin_service
|
||||
from app.services.admin import apply_rules_retroactively, change_password
|
||||
from app.models.tables import Account, Category, CategoryRule, Transaction
|
||||
|
||||
|
||||
# --- change_password(): reine Service-Tests (kein HTTP), gegen die
|
||||
# FB_ENV_FILE-Datei aus client_with_env_file -----------------------------
|
||||
|
||||
def test_change_password_wrong_old_raises_without_touching_grafana_or_env(
|
||||
client_with_env_file, env_file, monkeypatch
|
||||
):
|
||||
_client, password = client_with_env_file
|
||||
path, _ = env_file
|
||||
before = path.read_text()
|
||||
calls = []
|
||||
monkeypatch.setattr(admin_service, "_sync_grafana_password",
|
||||
lambda *a, **kw: calls.append(a))
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
change_password("falsches-passwort", "neuesPasswort123")
|
||||
|
||||
assert calls == [] # Grafana darf bei falschem alten Passwort nie aufgerufen werden
|
||||
assert path.read_text() == before
|
||||
|
||||
|
||||
def test_change_password_calls_grafana_before_writing_env(
|
||||
client_with_env_file, env_file, monkeypatch
|
||||
):
|
||||
_client, password = client_with_env_file
|
||||
path, _ = env_file
|
||||
order = []
|
||||
|
||||
def fake_sync(old, new, base_url):
|
||||
order.append("grafana")
|
||||
assert path.read_text() == before # .env noch unveraendert an diesem Punkt
|
||||
|
||||
before = path.read_text()
|
||||
monkeypatch.setattr(admin_service, "_sync_grafana_password", fake_sync)
|
||||
|
||||
change_password(password, "neuesPasswort123")
|
||||
|
||||
order.append("env-written")
|
||||
assert order == ["grafana", "env-written"]
|
||||
assert "FB_PASSWORD='neuesPasswort123'" in path.read_text()
|
||||
|
||||
|
||||
def test_change_password_grafana_failure_leaves_env_unchanged(
|
||||
client_with_env_file, env_file, monkeypatch
|
||||
):
|
||||
_client, password = client_with_env_file
|
||||
path, _ = env_file
|
||||
before = path.read_text()
|
||||
|
||||
def fail(*a, **kw):
|
||||
raise RuntimeError("Grafana-Passwortänderung fehlgeschlagen: boom")
|
||||
|
||||
monkeypatch.setattr(admin_service, "_sync_grafana_password", fail)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
change_password(password, "neuesPasswort123")
|
||||
|
||||
assert path.read_text() == before
|
||||
|
||||
|
||||
def test_change_password_success_rewrites_env_in_place_preserving_inode(
|
||||
client_with_env_file, env_file, monkeypatch
|
||||
):
|
||||
_client, password = client_with_env_file
|
||||
path, _ = env_file
|
||||
monkeypatch.setattr(admin_service, "_sync_grafana_password",
|
||||
lambda *a, **kw: None)
|
||||
|
||||
inode_before = os.stat(path).st_ino
|
||||
change_password(password, "neuesPasswort123")
|
||||
inode_after = os.stat(path).st_ino
|
||||
|
||||
assert inode_before == inode_after # KRITISCH: kein rename, gleicher Inode
|
||||
content = path.read_text()
|
||||
assert "FB_PASSWORD='neuesPasswort123'" in content
|
||||
assert "FB_GUI_PASSWORD_HASH='" in content
|
||||
assert f"FB_GUI_PASSWORD_HASH='{password}'" not in content
|
||||
|
||||
|
||||
def test_change_password_new_password_logs_in_old_does_not(
|
||||
client_with_env_file, monkeypatch
|
||||
):
|
||||
client, password = client_with_env_file
|
||||
monkeypatch.setattr(admin_service, "_sync_grafana_password",
|
||||
lambda *a, **kw: None)
|
||||
|
||||
change_password(password, "neuesPasswort123")
|
||||
|
||||
r = client.post("/login", data={"username": "admin", "password": "neuesPasswort123"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
client.cookies.clear()
|
||||
r2 = client.post("/login", data={"username": "admin", "password": password},
|
||||
follow_redirects=False)
|
||||
assert r2.status_code == 401
|
||||
|
||||
|
||||
# --- /admin GUI-Route: Auth-Gate + Formular-Fehlerpfade -------------------
|
||||
|
||||
def test_admin_page_requires_gui_session(client_with_env_file):
|
||||
client, _password = client_with_env_file
|
||||
r = client.get("/admin", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_admin_page_reachable_after_login(client_with_env_file):
|
||||
client, password = client_with_env_file
|
||||
client.post("/login", data={"username": "admin", "password": password})
|
||||
r = client.get("/admin")
|
||||
assert r.status_code == 200
|
||||
assert "Passwort ändern" in r.text
|
||||
assert "Regeln neu anwenden" in r.text
|
||||
|
||||
|
||||
def test_admin_passwort_wrong_old_returns_400_with_message(
|
||||
client_with_env_file, monkeypatch
|
||||
):
|
||||
client, password = client_with_env_file
|
||||
monkeypatch.setattr(admin_service, "_sync_grafana_password",
|
||||
lambda *a, **kw: None)
|
||||
client.post("/login", data={"username": "admin", "password": password})
|
||||
|
||||
r = client.post("/admin/passwort", data={
|
||||
"alt": "falsch", "neu": "neuesPasswort123", "neu2": "neuesPasswort123",
|
||||
})
|
||||
|
||||
assert r.status_code == 400
|
||||
assert "falsch" in r.text.lower()
|
||||
|
||||
|
||||
def test_admin_passwort_too_short_returns_400(client_with_env_file):
|
||||
client, password = client_with_env_file
|
||||
client.post("/login", data={"username": "admin", "password": password})
|
||||
|
||||
r = client.post("/admin/passwort", data={
|
||||
"alt": password, "neu": "kurz1", "neu2": "kurz1",
|
||||
})
|
||||
|
||||
assert r.status_code == 400
|
||||
assert "8 Zeichen" in r.text
|
||||
|
||||
|
||||
def test_admin_passwort_mismatch_returns_400(client_with_env_file):
|
||||
client, password = client_with_env_file
|
||||
client.post("/login", data={"username": "admin", "password": password})
|
||||
|
||||
r = client.post("/admin/passwort", data={
|
||||
"alt": password, "neu": "neuesPasswort123", "neu2": "andersPasswort123",
|
||||
})
|
||||
|
||||
assert r.status_code == 400
|
||||
assert "stimmt nicht" in r.text.lower() or "wiederholung" in r.text.lower()
|
||||
|
||||
|
||||
def test_admin_passwort_grafana_failure_returns_400_env_unchanged(
|
||||
client_with_env_file, env_file, monkeypatch
|
||||
):
|
||||
client, password = client_with_env_file
|
||||
path, _ = env_file
|
||||
before = path.read_text()
|
||||
|
||||
def fail(*a, **kw):
|
||||
raise RuntimeError("Grafana-Passwortänderung fehlgeschlagen: boom")
|
||||
|
||||
monkeypatch.setattr(admin_service, "_sync_grafana_password", fail)
|
||||
client.post("/login", data={"username": "admin", "password": password})
|
||||
|
||||
r = client.post("/admin/passwort", data={
|
||||
"alt": password, "neu": "neuesPasswort123", "neu2": "neuesPasswort123",
|
||||
})
|
||||
|
||||
assert r.status_code == 400
|
||||
assert "grafana" in r.text.lower()
|
||||
assert path.read_text() == before
|
||||
|
||||
|
||||
def test_admin_passwort_grafana_read_timeout_returns_400_env_unchanged(
|
||||
client_with_env_file, env_file, monkeypatch
|
||||
):
|
||||
"""F1 (Fable-Reject): ein Hang in der Grafana-Lesephase wirft ein rohes
|
||||
TimeoutError statt eines urllib.error.URLError. Ohne expliziten Fang in
|
||||
_sync_grafana_password schlaegt das bis in den Router durch (500 statt
|
||||
deutscher 400-Fehlermeldung) - siehe app/services/admin.py."""
|
||||
client, password = client_with_env_file
|
||||
path, _ = env_file
|
||||
before = path.read_text()
|
||||
|
||||
def hang(request, timeout=None):
|
||||
raise TimeoutError("timed out")
|
||||
|
||||
monkeypatch.setattr("app.services.admin.urllib.request.urlopen", hang)
|
||||
client.post("/login", data={"username": "admin", "password": password})
|
||||
|
||||
r = client.post("/admin/passwort", data={
|
||||
"alt": password, "neu": "neuesPasswort123", "neu2": "neuesPasswort123",
|
||||
})
|
||||
|
||||
assert r.status_code == 400
|
||||
assert "grafana" in r.text.lower()
|
||||
assert path.read_text() == before
|
||||
|
||||
|
||||
def test_admin_passwort_success_returns_200_with_confirmation(
|
||||
client_with_env_file, monkeypatch
|
||||
):
|
||||
client, password = client_with_env_file
|
||||
calls = []
|
||||
monkeypatch.setattr(admin_service, "_sync_grafana_password",
|
||||
lambda *a, **kw: calls.append(a))
|
||||
client.post("/login", data={"username": "admin", "password": password})
|
||||
|
||||
r = client.post("/admin/passwort", data={
|
||||
"alt": password, "neu": "neuesPasswort123", "neu2": "neuesPasswort123",
|
||||
})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert "erfolgreich" in r.text.lower()
|
||||
assert calls # Grafana wurde tatsaechlich aufgerufen
|
||||
|
||||
# Session-Cookie bleibt nach der Aenderung gueltig (Secret unveraendert)
|
||||
r2 = client.get("/admin")
|
||||
assert r2.status_code == 200
|
||||
|
||||
|
||||
# --- Regeln neu anwenden ---------------------------------------------------
|
||||
|
||||
def test_apply_rules_retroactively_categorizes_uncategorized_confirmed_tx(db):
|
||||
account = Account(bank="Test", iban="DE02120300000000202099", name="Giro")
|
||||
db.add(account)
|
||||
db.flush()
|
||||
category = Category(name="Lebensmittel")
|
||||
db.add(category)
|
||||
db.flush()
|
||||
db.add(CategoryRule(pattern="rewe", category_id=category.id, priority=100))
|
||||
tx_confirmed_uncategorized = Transaction(
|
||||
account_id=account.id, booking_date=date(2026, 1, 5),
|
||||
amount=Decimal("-10.00"), purpose="REWE Markt", counterparty="",
|
||||
status="confirmed", dedup_hash="h1",
|
||||
)
|
||||
tx_draft = Transaction(
|
||||
account_id=account.id, booking_date=date(2026, 1, 6),
|
||||
amount=Decimal("-5.00"), purpose="REWE Markt", counterparty="",
|
||||
status="draft", dedup_hash="h2",
|
||||
)
|
||||
tx_already_categorized = Transaction(
|
||||
account_id=account.id, booking_date=date(2026, 1, 7),
|
||||
amount=Decimal("-5.00"), purpose="REWE Markt", counterparty="",
|
||||
status="confirmed", category_id=category.id, dedup_hash="h3",
|
||||
)
|
||||
db.add_all([tx_confirmed_uncategorized, tx_draft, tx_already_categorized])
|
||||
db.commit()
|
||||
|
||||
hits = apply_rules_retroactively(db)
|
||||
|
||||
assert hits == 1
|
||||
db.refresh(tx_confirmed_uncategorized)
|
||||
db.refresh(tx_draft)
|
||||
assert tx_confirmed_uncategorized.category_id == category.id
|
||||
assert tx_draft.category_id is None # Entwuerfe bleiben unangetastet
|
||||
|
||||
|
||||
def test_apply_category_rules_endpoint_requires_auth(client):
|
||||
assert client.post("/api/category-rules/apply").status_code == 401
|
||||
|
||||
|
||||
def test_apply_category_rules_endpoint_categorizes_and_returns_count(client, db):
|
||||
account = Account(bank="Test", iban="DE02120300000000202098", name="Giro")
|
||||
db.add(account)
|
||||
db.flush()
|
||||
category = Category(name="Lebensmittel")
|
||||
db.add(category)
|
||||
db.flush()
|
||||
db.add(CategoryRule(pattern="rewe", category_id=category.id, priority=100))
|
||||
tx = Transaction(
|
||||
account_id=account.id, booking_date=date(2026, 1, 5),
|
||||
amount=Decimal("-10.00"), purpose="REWE Markt", counterparty="",
|
||||
status="confirmed", dedup_hash="h1",
|
||||
)
|
||||
db.add(tx)
|
||||
db.commit()
|
||||
|
||||
r = client.post("/api/category-rules/apply",
|
||||
headers={"Authorization": "Bearer test-key"})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"categorized": 1}
|
||||
db.refresh(tx)
|
||||
assert tx.category_id == category.id
|
||||
@@ -28,3 +28,35 @@ def test_logout_flow(client):
|
||||
def test_tampered_session_cookie_rejected(client):
|
||||
client.cookies.set("fb_session", "gui.invalid-signature")
|
||||
assert client.get("/api/accounts").status_code == 401
|
||||
|
||||
|
||||
def test_current_password_hash_falls_back_to_env_when_no_env_file(client, monkeypatch):
|
||||
# `client`-Fixture setzt FB_GUI_PASSWORD_HASH direkt und keine FB_ENV_FILE,
|
||||
# der Default-Pfad /data/.env existiert im Testlauf nicht -> Fallback.
|
||||
from app.auth import current_password_hash
|
||||
from app.config import get_settings
|
||||
assert current_password_hash() == get_settings().gui_password_hash
|
||||
|
||||
|
||||
def test_current_password_hash_reads_from_env_file_when_present(
|
||||
client_with_env_file,
|
||||
):
|
||||
from app.auth import current_password_hash, verify_password
|
||||
_client, password = client_with_env_file
|
||||
assert verify_password(password, current_password_hash())
|
||||
|
||||
|
||||
def test_current_password_hash_cache_invalidates_on_file_change(
|
||||
client_with_env_file, env_file,
|
||||
):
|
||||
from app.auth import current_password_hash, hash_password, verify_password
|
||||
path, password = env_file
|
||||
first = current_password_hash()
|
||||
assert verify_password(password, first)
|
||||
|
||||
new_hash = hash_password("andereswort999")
|
||||
path.write_text(f"FB_PASSWORD='andereswort999'\nFB_GUI_PASSWORD_HASH='{new_hash}'\n")
|
||||
|
||||
second = current_password_hash()
|
||||
assert second == new_hash
|
||||
assert verify_password("andereswort999", second)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
@@ -5,3 +7,31 @@ def test_defaults():
|
||||
s = get_settings()
|
||||
assert s.horizon_days == 548
|
||||
assert s.gui_user == "admin"
|
||||
|
||||
|
||||
def test_env_file_defaults_to_container_mount_path(monkeypatch):
|
||||
monkeypatch.delenv("FB_ENV_FILE", raising=False)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert get_settings().env_file == Path("/data/.env")
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_env_file_overridable_via_env_var(monkeypatch, tmp_path):
|
||||
custom = tmp_path / "custom.env"
|
||||
monkeypatch.setenv("FB_ENV_FILE", str(custom))
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert get_settings().env_file == custom
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_grafana_url_default(monkeypatch):
|
||||
monkeypatch.delenv("FB_GRAFANA_URL", raising=False)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert get_settings().grafana_url == "http://localhost:3000"
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
Reference in New Issue
Block a user