Compare commits
22 Commits
353ff6056c
...
main
| Author | SHA256 | Date | |
|---|---|---|---|
| 224baba326 | |||
| dec1cb9075 | |||
| 81a0ebaf9d | |||
| 6ba6ffa9df | |||
| 51d85c8de6 | |||
| 208c0a4df4 | |||
| fa45527faf | |||
| 2610b71b06 | |||
| df5b29d8ff | |||
| a575c57d5b | |||
| 52f527a375 | |||
| fa2d32aa8f | |||
| e061796997 | |||
| 7a8e63dc76 | |||
| 8eee62ee19 | |||
| 7ca64c91fe | |||
| aff8e9b28d | |||
| a520390e31 | |||
| 220d1fc802 | |||
| bf3f56118e | |||
| 28d1267f0c | |||
| e002d8205f |
10
CLAUDE.md
10
CLAUDE.md
@@ -131,7 +131,15 @@ 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.
|
||||
weil das Skript die Container ohnehin bei jedem Lauf neu erstellt. Das Tool ist
|
||||
**produktiv über den Reverse-Proxy erreichbar** unter
|
||||
`https://fbwl.creature-go.com/` (App) + `/grafana/` (Grafana-Unterpfad);
|
||||
aktiviert rein über die zwei optionalen `.env`-Variablen
|
||||
(`FB_GRAFANA_PUBLIC_URL`, `FB_SESSION_COOKIE_SECURE`), leer ⇒ Lokalbetrieb.
|
||||
Kette sv005 (Apache/TLS) → WireGuard → sv006 Traefik → Pod, **vollständig
|
||||
dokumentiert in `docs/reverse-proxy.md`** (dort auch die Regel: Traefik-Backends
|
||||
`10.0.2.2:PORT`, nicht `127.0.0.1`). Achtung: mit `FB_SESSION_COOKIE_SECURE=true`
|
||||
geht Browser-Login nur noch über die HTTPS-Domain (curl-Tests weiter ok).
|
||||
|
||||
**Disaster Recovery = `BIND_DIR`-Backup + Repo + Skript.** Für vollständige
|
||||
Wiederherstellung werden **beide** gebraucht: ein Backup von
|
||||
|
||||
@@ -211,6 +211,29 @@ podman exec "$DB_CTR_NAME" psql -U finance -d finance -c \
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO finance_read;"
|
||||
echo "Role 'finance_read' is ready."
|
||||
|
||||
# --- Reverse-Proxy-Betrieb (optional) ----------------------------------------
|
||||
# FB_GRAFANA_PUBLIC_URL wird nur gesetzt, wenn das Tool hinter einem Reverse
|
||||
# Proxy unter einem Sub-Pfad laufen soll (z.B.
|
||||
# 'https://fb.example.de/grafana/'). Ist sie leer (Default,
|
||||
# Direktbetrieb auf 127.0.0.1), bleibt alles wie bisher: Grafana serviert an
|
||||
# der Wurzel, Health-Check und Passwort-Sync sprechen /api/... ohne Praefix.
|
||||
# Muss VOR dem API-Container berechnet werden, da FB_GRAFANA_URL (unten) den
|
||||
# Praefix schon dort braucht - nicht erst vor dem Grafana-Container.
|
||||
GF_SUBPATH=''
|
||||
GRAFANA_SUBPATH_ARGS=()
|
||||
if [ -n "${FB_GRAFANA_PUBLIC_URL:-}" ]; then
|
||||
# Pfadanteil der oeffentlichen URL extrahieren, Slash(es) am Ende entfernen:
|
||||
# 'https://host/grafana/' -> '/grafana'. Dieser Praefix wird sowohl fuer den
|
||||
# internen Health-Check als auch fuer die interne Grafana-API-URL
|
||||
# (FB_GRAFANA_URL, Passwort-Sync in der GUI) gebraucht, weil
|
||||
# serve_from_sub_path ALLE Grafana-Routen unter den Sub-Pfad haengt.
|
||||
GF_SUBPATH=$(printf '%s' "$FB_GRAFANA_PUBLIC_URL" | sed -E 's#^[a-z]+://[^/]+##; s#/+$##')
|
||||
GRAFANA_SUBPATH_ARGS=(
|
||||
-e "GF_SERVER_ROOT_URL=$FB_GRAFANA_PUBLIC_URL"
|
||||
-e "GF_SERVER_SERVE_FROM_SUB_PATH=true"
|
||||
)
|
||||
fi
|
||||
|
||||
# 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,
|
||||
@@ -226,6 +249,9 @@ podman run -d --name "$API_CTR_NAME" --pod "$POD_NAME" \
|
||||
-e FB_GUI_PASSWORD_HASH \
|
||||
-e FB_INBOX_DIR=/data/inbox \
|
||||
-e FB_UPLOADS_DIR=/data/uploads \
|
||||
-e FB_GRAFANA_PUBLIC_URL="${FB_GRAFANA_PUBLIC_URL:-}" \
|
||||
-e FB_SESSION_COOKIE_SECURE="${FB_SESSION_COOKIE_SECURE:-}" \
|
||||
-e FB_GRAFANA_URL="http://localhost:3000${GF_SUBPATH}" \
|
||||
-v "$DATA_DIR:/data:Z" \
|
||||
-v "$ENV_FILE:/data/.env:Z" \
|
||||
"$API_IMAGE"
|
||||
@@ -240,6 +266,7 @@ podman run -d --name "$GRAFANA_CTR_NAME" --pod "$POD_NAME" \
|
||||
-e GF_SECURITY_ADMIN_PASSWORD="$FB_PASSWORD" \
|
||||
-e GF_SECURITY_ALLOW_EMBEDDING=true \
|
||||
-e GF_SECURITY_COOKIE_SAMESITE=lax \
|
||||
"${GRAFANA_SUBPATH_ARGS[@]}" \
|
||||
-e GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/finanzen.json \
|
||||
-e FINANCE_READ_PASSWORD \
|
||||
-v "$GRAFANA_PROVISIONING_DIR:/etc/grafana/provisioning:Z,ro" \
|
||||
@@ -326,7 +353,7 @@ echo "To view logs: journalctl --user -u pod-${POD_NAME}.service -f"
|
||||
|
||||
# Wait for API and Grafana readiness
|
||||
CHECK_URL_API="http://$HOST_LOCAL_IP:$API_HOST_PORT/login"
|
||||
CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT/api/health"
|
||||
CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT${GF_SUBPATH}/api/health"
|
||||
for attempt in $(seq 1 30); do
|
||||
API_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL_API" || true)
|
||||
GRAFANA_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL_GRAFANA" || true)
|
||||
|
||||
@@ -196,6 +196,11 @@ sequenceDiagram
|
||||
|
||||
## 3. Deployment-Diagramm
|
||||
|
||||
> Reverse-Proxy-Betrieb (eine Subdomain, Grafana als `/grafana/`-Unterpfad):
|
||||
> siehe **`docs/reverse-proxy.md`** (Ausbaustufe 10). Aktivierung rein über
|
||||
> `.env` (`FB_GRAFANA_PUBLIC_URL`, `FB_SESSION_COOKIE_SECURE`); leer = lokaler
|
||||
> Direktbetrieb unverändert.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph HOST["Host wlfb (rootless Podman)"]
|
||||
|
||||
258
docs/reverse-proxy.md
Normal file
258
docs/reverse-proxy.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# Reverse-Proxy-Betrieb (Ausbaustufe 10) — LIVE
|
||||
|
||||
Das Finanzberatungs-Tool ist öffentlich erreichbar unter
|
||||
**`https://fbwl.creature-go.com/`** (App/GUI) mit **Grafana als Unterpfad
|
||||
`https://fbwl.creature-go.com/grafana/`**. Aktiviert wird das ausschließlich
|
||||
über zwei `.env`-Schalter auf sv006; ohne sie läuft der unveränderte
|
||||
Lokalbetrieb auf `127.0.0.1` weiter (per Live-Redeploy + Sub-Pfad-Smoke belegt).
|
||||
|
||||
Die Proxy-Konfiguration (sv005 Apache, sv006 Traefik) liegt außerhalb dieses
|
||||
Repos — dieses Dokument hält den realen Stand fest, damit Änderungen am Tool
|
||||
die Kette nicht brechen.
|
||||
|
||||
## Kette (Datenfluss)
|
||||
|
||||
```
|
||||
Browser ──HTTPS──► sv005 (DesTEngSsv005)
|
||||
Virtualmin/Apache, TLS-Terminierung, WireGuard-IP 10.8.0.1
|
||||
ServerName fbwl.creature-go.com ──ProxyPass──► http://10.8.0.6:8080
|
||||
──WireGuard (wg0)──►
|
||||
sv006 (DesTEngSsv006 — DIESER Rechner)
|
||||
Traefik: rootless-Podman-Pod "traefik_pod" (User trf),
|
||||
Entrypoint "wghttp" published auf 10.8.0.6:8080
|
||||
Router Host(fbwl.creature-go.com) ──► http://10.0.2.2:8096 (App/API)
|
||||
Router Host(fbwl.creature-go.com) && /grafana ──► http://10.0.2.2:8097 (Grafana)
|
||||
finance_pod: 127.0.0.1:8096 (App), 127.0.0.1:8097 (Grafana)
|
||||
```
|
||||
|
||||
**Warum `10.0.2.2`?** Traefik läuft in einem *rootless*-Podman-Pod mit
|
||||
`slirp4netns:allow_host_loopback=true`. Aus Sicht des Traefik-Containers ist
|
||||
`127.0.0.1` der **Container selbst**; die auf dem Host an `127.0.0.1:8096/8097`
|
||||
veröffentlichten Pod-Ports erreicht er über die slirp4netns-Host-Loopback-Adresse
|
||||
**`10.0.2.2`**. Backend-URLs im Traefik-Router deshalb IMMER `10.0.2.2:PORT`,
|
||||
niemals `127.0.0.1:PORT`.
|
||||
|
||||
**Warum funktioniert `X-Forwarded-Proto` sauber?** sv005 hat die WireGuard-IP
|
||||
`10.8.0.1`, und Traefiks Entrypoint `wghttp` vertraut per
|
||||
`forwardedHeaders.trustedIPs: 10.8.0.1/32` genau dieser Quelle. Das von sv005
|
||||
gesetzte `X-Forwarded-Proto: https` wird also übernommen und (via uvicorn
|
||||
`--proxy-headers`) bis zur App durchgereicht.
|
||||
|
||||
## Teil 1 — finance_pod (`.env` auf sv006)
|
||||
|
||||
In `~/.local/share/finance_pod/.env` (alle Werte **single-quoted**):
|
||||
```sh
|
||||
FB_GRAFANA_PUBLIC_URL='https://fbwl.creature-go.com/grafana/'
|
||||
FB_SESSION_COOKIE_SECURE='true'
|
||||
```
|
||||
Übernehmen: `./stop_finance_pod.sh` → `.env` editieren → `./create_pod_finance.sh`.
|
||||
|
||||
Wirkung von `FB_GRAFANA_PUBLIC_URL` (leer ⇒ Lokalbetrieb, Grafana-Fallback
|
||||
`http://<host>:8097`, Grafana an der Wurzel):
|
||||
- `create_pod_finance.sh` startet Grafana mit `GF_SERVER_ROOT_URL` +
|
||||
`GF_SERVER_SERVE_FROM_SUB_PATH=true`;
|
||||
- der **Pfadanteil** der URL (`/grafana`) wird zum Präfix für den internen
|
||||
Grafana-Health-Check UND für die interne `FB_GRAFANA_URL`
|
||||
(`http://localhost:3000/grafana`, für die GUI-Passwortänderung) — beides in
|
||||
`create_pod_finance.sh` automatisch abgeleitet;
|
||||
- alle GUI-Links/iframes bilden sich aus dieser URL (Jinja-Global
|
||||
`grafana_public_base`, siehe `app/routers/gui.py`).
|
||||
|
||||
**Achtung Secure-Cookie:** Mit `FB_SESSION_COOKIE_SECURE='true'` akzeptiert ein
|
||||
**Browser** das Session-Cookie nur über HTTPS — ein direkter Login über
|
||||
`http://127.0.0.1:8096` funktioniert dann nicht mehr, nur noch über die
|
||||
HTTPS-Domain. Automatisierte Tests mit `curl` (`-c/-b`) laufen weiter, weil curl
|
||||
das Secure-Flag ignoriert (bewährt bei Redeploy-Smoke-Tests).
|
||||
|
||||
## Teil 2 — Traefik-Router (sv006, verwaltet als User `trf`)
|
||||
|
||||
Traefik ist ein **geteilter** rootless-Podman-Pod `traefik_pod` (User `trf`,
|
||||
Config unter `/home/trf/.local/share/traefik_pod/`; als `wlfb` NICHT lesbar).
|
||||
Statik: Entrypoint `wghttp` auf `:8080` (published `10.8.0.6:8080`),
|
||||
`forwardedHeaders.trustedIPs: 10.8.0.1/32`; File-Provider `dynamic/` mit
|
||||
`watch: true` (Änderungen laden automatisch, kein Neustart, andere Dienste
|
||||
unberührt).
|
||||
|
||||
**Wichtig — Black Hole:** Es gibt einen Catch-all-Router
|
||||
(`HostRegexp('{any:.*}')`, `priority: 1`, Middleware `ipAllowList 127.0.0.1/32`),
|
||||
der jeden **nicht explizit gerouteten** Host sofort mit **403** abweist. Jeder
|
||||
öffentliche Dienst braucht daher einen eigenen Router mit
|
||||
`entryPoints: ["wghttp"]` und `priority > 1`.
|
||||
|
||||
Router-Datei `dynamic/fbwl.yml`:
|
||||
```yaml
|
||||
http:
|
||||
routers:
|
||||
finance-grafana:
|
||||
rule: "Host(`fbwl.creature-go.com`) && PathPrefix(`/grafana`)"
|
||||
priority: 120 # ueber dem Black Hole (priority 1)
|
||||
service: finance-grafana
|
||||
entryPoints: ["wghttp"]
|
||||
finance-app:
|
||||
rule: "Host(`fbwl.creature-go.com`)"
|
||||
priority: 110
|
||||
service: finance-app
|
||||
entryPoints: ["wghttp"]
|
||||
services:
|
||||
finance-grafana:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://10.0.2.2:8097" # slirp4netns-Host-Loopback, NICHT 127.0.0.1
|
||||
finance-app:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://10.0.2.2:8096"
|
||||
```
|
||||
|
||||
## Teil 3 — sv005 Apache (Virtualmin, Domain fbwl.creature-go.com)
|
||||
|
||||
Virtualmin → Server `fbwl.creature-go.com` → „Edit Directives" (bearbeitet
|
||||
`/etc/apache2/sites-available/fbwl.creature-go.com.conf`). Aufgebaut nach dem
|
||||
Muster des bestehenden `affine.creature-go.com`-vhosts: `/.well-known` lokal
|
||||
(ACME/Let's-Encrypt-Erneuerung), HTTP→HTTPS-Redirect, dann Weiterleitung der
|
||||
ganzen Domain an Traefik (`10.8.0.6:8080`) mit erhaltenem Host-Header. TLS-Cert
|
||||
verwaltet Virtualmin/Let's Encrypt.
|
||||
|
||||
### Port 80
|
||||
```apache
|
||||
SuexecUserGroup #1015 #1011
|
||||
ServerName fbwl.creature-go.com
|
||||
ServerAlias www.fbwl.creature-go.com
|
||||
ServerAlias mail.fbwl.creature-go.com
|
||||
ServerAlias webmail.fbwl.creature-go.com
|
||||
ServerAlias admin.fbwl.creature-go.com
|
||||
DocumentRoot /home/fbwl/public_html
|
||||
ErrorLog /var/log/virtualmin/fbwl.creature-go.com_error_log
|
||||
CustomLog /var/log/virtualmin/fbwl.creature-go.com_access_log combined
|
||||
ScriptAlias /cgi-bin/ /home/fbwl/cgi-bin/
|
||||
DirectoryIndex index.php index.htm index.html
|
||||
<Directory /home/fbwl/public_html>
|
||||
Options -Indexes +IncludesNOEXEC +SymLinksIfOwnerMatch +ExecCGI
|
||||
Require all granted
|
||||
AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch
|
||||
AddHandler fcgid-script .php
|
||||
AddHandler fcgid-script .php8.4
|
||||
FCGIWrapper /home/fbwl/fcgi-bin/php8.4.fcgi .php
|
||||
FCGIWrapper /home/fbwl/fcgi-bin/php8.4.fcgi .php8.4
|
||||
</Directory>
|
||||
<Directory /home/fbwl/cgi-bin>
|
||||
Require all granted
|
||||
AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch
|
||||
</Directory>
|
||||
ProxyPass /.well-known !
|
||||
RewriteEngine on
|
||||
RewriteCond %{HTTP_HOST} =webmail.fbwl.creature-go.com
|
||||
RewriteRule ^/(?!\.well-known)(.*)$ https://fbwl.creature-go.com:20000/ [R=301,L]
|
||||
RewriteCond %{HTTP_HOST} =admin.fbwl.creature-go.com
|
||||
RewriteRule ^/(?!\.well-known)(.*)$ https://fbwl.creature-go.com:10000/ [R=301,L]
|
||||
# --- Finanzberatungs-Tool: HTTP -> HTTPS (ausser ACME-Challenge) ---
|
||||
RewriteCond %{HTTPS} off
|
||||
RewriteCond %{REQUEST_URI} !^/\.well-known/
|
||||
RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]
|
||||
RemoveHandler .php
|
||||
RemoveHandler .php8.4
|
||||
FcgidMaxRequestLen 1073741824
|
||||
```
|
||||
|
||||
### Port 443
|
||||
```apache
|
||||
SuexecUserGroup #1015 #1011
|
||||
ServerName fbwl.creature-go.com
|
||||
ServerAlias www.fbwl.creature-go.com
|
||||
ServerAlias mail.fbwl.creature-go.com
|
||||
ServerAlias webmail.fbwl.creature-go.com
|
||||
ServerAlias admin.fbwl.creature-go.com
|
||||
DocumentRoot /home/fbwl/public_html
|
||||
ErrorLog /var/log/virtualmin/fbwl.creature-go.com_error_log
|
||||
CustomLog /var/log/virtualmin/fbwl.creature-go.com_access_log combined
|
||||
ScriptAlias /cgi-bin/ /home/fbwl/cgi-bin/
|
||||
DirectoryIndex index.php index.htm index.html
|
||||
<Directory /home/fbwl/public_html>
|
||||
Options -Indexes +IncludesNOEXEC +SymLinksIfOwnerMatch +ExecCGI
|
||||
Require all granted
|
||||
AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch
|
||||
AddHandler fcgid-script .php
|
||||
AddHandler fcgid-script .php8.4
|
||||
FCGIWrapper /home/fbwl/fcgi-bin/php8.4.fcgi .php
|
||||
FCGIWrapper /home/fbwl/fcgi-bin/php8.4.fcgi .php8.4
|
||||
</Directory>
|
||||
<Directory /home/fbwl/cgi-bin>
|
||||
Require all granted
|
||||
AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch
|
||||
</Directory>
|
||||
ProxyPass /.well-known !
|
||||
RewriteEngine on
|
||||
RewriteCond %{HTTP_HOST} =webmail.fbwl.creature-go.com
|
||||
RewriteRule ^/(?!\.well-known)(.*)$ https://fbwl.creature-go.com:20000/ [R=301,L]
|
||||
RewriteCond %{HTTP_HOST} =admin.fbwl.creature-go.com
|
||||
RewriteRule ^/(?!\.well-known)(.*)$ https://fbwl.creature-go.com:10000/ [R=301,L]
|
||||
# --- Finanzberatungs-Tool: Weiterleitung an Traefik (sv006, wghttp) ---
|
||||
ProxyPreserveHost On
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
# WebSocket (Grafana Live) an Traefik durchreichen
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule .* ws://10.8.0.6:8080%{REQUEST_URI} [P,L]
|
||||
# Haupt-Weiterleitung (Traefik trennt App vs. /grafana selbst)
|
||||
ProxyPass / http://10.8.0.6:8080/
|
||||
ProxyPassReverse / http://10.8.0.6:8080/
|
||||
RemoveHandler .php
|
||||
RemoveHandler .php8.4
|
||||
FcgidMaxRequestLen 1073741824
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/ssl/virtualmin/17848735353695059/ssl.cert
|
||||
SSLCertificateKeyFile /etc/ssl/virtualmin/17848735353695059/ssl.key
|
||||
SSLProtocol all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1
|
||||
SSLCACertificateFile /etc/ssl/virtualmin/17848735353695059/ssl.ca
|
||||
```
|
||||
|
||||
Hinweis: `www.`/`mail.fbwl…` gehen (wie bei affine) über den Proxy an Traefik
|
||||
und landen dort mangels Router im Black Hole (403) — fürs Tool irrelevant.
|
||||
|
||||
## Cutover-Reihenfolge (falls die Kette neu aufgebaut wird)
|
||||
|
||||
1. DNS `fbwl.creature-go.com` → sv005; Cert via Virtualmin/Let's Encrypt.
|
||||
2. Traefik-Router (Teil 2) ablegen — `watch` lädt automatisch.
|
||||
3. sv005 Apache (Teil 3) eintragen — Apache reload.
|
||||
4. Testen (unten), solange Tool noch im Lokalmodus (`/grafana` kommt erst mit Schritt 5).
|
||||
5. `.env`-Cutover (Teil 1) + Redeploy — **`FB_SESSION_COOKIE_SECURE='true'` zuletzt**.
|
||||
|
||||
## Verifikation
|
||||
|
||||
```bash
|
||||
# Von aussen:
|
||||
curl -sI https://fbwl.creature-go.com/login | head -1 # -> 200
|
||||
curl -sI https://fbwl.creature-go.com/grafana/api/health | head -1 # -> 200
|
||||
curl -I http://fbwl.creature-go.com/ 2>&1 | head -3 # -> 301 auf https
|
||||
|
||||
# Auf sv006 (Kette bis Traefik, Host-Header simuliert):
|
||||
for p in /login /grafana/api/health; do
|
||||
curl -sS --max-time 5 -o /dev/null -w "$p -> %{http_code}\n" \
|
||||
-H 'Host: fbwl.creature-go.com' "http://10.8.0.6:8080$p"
|
||||
done
|
||||
```
|
||||
Der Grafana-iframe erfordert eine Grafana-Session (Embedding an, anonym aus): in
|
||||
der GUI einmal „Grafana anmelden" (→ `…/grafana`) mit dem gemeinsamen Passwort.
|
||||
|
||||
## Für künftige Claude-Sessions — was das für Änderungen am Tool bedeutet
|
||||
|
||||
- **Domain/Links des Tools hängen ausschließlich an `FB_GRAFANA_PUBLIC_URL`**
|
||||
(+ Templates via `grafana_public_base`). Kein Hostname im Code/Image — ein
|
||||
Domainwechsel ist eine reine `.env`-Änderung + Redeploy (plus je ein Eintrag
|
||||
in Traefik-Router und sv005-Apache).
|
||||
- **Neuer öffentlicher Pfad/Dienst hinter derselben Domain**: Traefik-Router in
|
||||
`dynamic/` mit `entryPoints: ["wghttp"]`, `priority > 1`, Backend
|
||||
`http://10.0.2.2:PORT`; an sv005 meist nichts nötig, weil `ProxyPass /` die
|
||||
ganze Domain weiterreicht und Traefik nach Pfad trennt.
|
||||
- **Neuer Port am Pod**: in `create_pod_finance.sh` als `127.0.0.1:PORT`
|
||||
veröffentlichen → Traefik erreicht ihn als `10.0.2.2:PORT`.
|
||||
- **Grafana-Sub-Pfad**: `GF_SERVER_ROOT_URL` + `serve_from_sub_path` und die
|
||||
präfixierten internen URLs werden in `create_pod_finance.sh` aus
|
||||
`FB_GRAFANA_PUBLIC_URL` abgeleitet — den Pfad (`/grafana`) nicht ohne Grund
|
||||
ändern; er steckt auch im Health-Check und in `FB_GRAFANA_URL`.
|
||||
- **Lokaler Test trotz Live-Proxy**: `.env`-Schalter leeren (echter Lokalmodus)
|
||||
ODER mit `curl` testen; ein echter Browser braucht die HTTPS-Domain, weil das
|
||||
Session-Cookie `Secure` ist.
|
||||
- **Traefik ist geteilt** (User `trf`): dessen Config ist als `wlfb` nicht
|
||||
editierbar. Router-/Entrypoint-Änderungen müssen als `trf` erfolgen; den
|
||||
Black-Hole-Router und fremde Dienste nicht anfassen.
|
||||
776
docs/superpowers/plans/2026-07-20-ausbaustufe-10.md
Normal file
776
docs/superpowers/plans/2026-07-20-ausbaustufe-10.md
Normal file
@@ -0,0 +1,776 @@
|
||||
# Ausbaustufe 10: Reverse-Proxy-Tauglichkeit — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Das Finanzberatungs-Tool wird über genau eine Subdomain
|
||||
`https://fb.wolfundlaemmlein.de/` erreichbar, mit Grafana als Unterpfad
|
||||
`/grafana/` — ohne dass der Lokalbetrieb (Direktzugriff auf `127.0.0.1:8096`/
|
||||
`:8097`) sich ändert.
|
||||
|
||||
**Architecture:** Alle proxy-spezifischen Werte kommen ausschließlich aus der
|
||||
`.env` (kein Hostname in Image/Repo). Ist `FB_GRAFANA_PUBLIC_URL` leer, läuft
|
||||
alles exakt wie in v0.9.0. Ist sie gesetzt, (a) betreibt `create_pod_finance.sh`
|
||||
Grafana nativ unter dem Sub-Pfad (`GF_SERVER_ROOT_URL` + `serve_from_sub_path`)
|
||||
und passt internen Health-Check + interne Grafana-API-URL um den `/grafana`-
|
||||
Präfix an, (b) bilden die Templates alle Grafana-Links/iframes aus der
|
||||
öffentlichen URL statt aus `<host>:8097`, (c) setzt der Login das
|
||||
Session-Cookie mit `secure`-Flag, (d) vertraut uvicorn den Proxy-Headern
|
||||
(`X-Forwarded-Proto`/`-Host`). Die eigentliche Traefik-/Apache-Konfiguration
|
||||
liegt außerhalb dieses Repos; docs/ liefert nur Beispiel-Snippets.
|
||||
|
||||
**Tech Stack:** FastAPI/Starlette, Jinja2, uvicorn, Podman (rootless Pod,
|
||||
systemd `--user`), Grafana OSS 12.1.0, Bash-Deployskript.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Lokalbetrieb bleibt bit-identisch**, solange `FB_GRAFANA_PUBLIC_URL` leer
|
||||
ist. Jede Änderung MUSS diesen Fall als No-Op behandeln.
|
||||
- **Kein Hostname/keine Domain als Funktionswert ins Repo oder Image.**
|
||||
Proxy-Werte nur via `.env` (`FB_GRAFANA_PUBLIC_URL`,
|
||||
`FB_SESSION_COOKIE_SECURE`). **Quellcode inkl. Kommentaren und Tests bleibt
|
||||
domain-neutral** — nur die Beispiel-Domain `fb.example.de`, niemals die echte
|
||||
Domain. **Ausnahme: die Deployment-Doku `docs/reverse-proxy.md`** beschreibt
|
||||
genau diese Installation und darf die echte Domain
|
||||
`fb.wolfundlaemmlein.de` nennen (öffentlicher DNS-Name, keine Kontodaten).
|
||||
(Nutzerentscheidung 2026-07-21: Code neutral, Doku echt.)
|
||||
- **`.env`-Werte single-quoted** (`KEY='wert'`) — `create_pod_finance.sh`
|
||||
sourced per `set -a; . "$ENV_FILE"`.
|
||||
- **Zwei getrennte Grafana-URLs:** `FB_GRAFANA_URL` (intern, Server→Grafana,
|
||||
Passwort-Sync) ≠ `FB_GRAFANA_PUBLIC_URL` (öffentlich, Browser-Links). Nie
|
||||
vermischen.
|
||||
- **Geld = `decimal.Decimal`** (hier nicht berührt, gilt generell).
|
||||
- **Deutsch** für GUI-Texte, Kommentare, Commit-Messages. Datumsformat
|
||||
TT.MM.JJJJ.
|
||||
- **Test-Suite bleibt grün, Basis 199 Tests** (`cd finance && .venv/bin/python
|
||||
-m pytest -q`). Jede neue Funktion bekommt Tests → Endzahl > 199.
|
||||
- **Fable-Testagent-Gate je Task vor dem Commit** (Nutzer-Vorgabe, CLAUDE.md).
|
||||
- **Release am Ende als v0.10.0** (`finance/VERSION`), Redeploy, Live-Check
|
||||
Lokalbetrieb unverändert.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Config — neue Settings `grafana_public_url` + `session_cookie_secure`
|
||||
|
||||
**Files:**
|
||||
- Modify: `finance/app/config.py`
|
||||
- Test: `finance/tests/test_config.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `Settings.grafana_public_url: str` (aus `FB_GRAFANA_PUBLIC_URL`,
|
||||
Default `""`), `Settings.session_cookie_secure: bool` (aus
|
||||
`FB_SESSION_COOKIE_SECURE`, Default `False`). Bool-Parsing via Modul-Helfer
|
||||
`_env_bool(value: str) -> bool` (truthy: `1/true/yes/on`, case-insensitiv).
|
||||
|
||||
- [x] **Step 1: Failing Tests schreiben** — an `finance/tests/test_config.py`
|
||||
anhängen (oben `import pytest` ergänzen, falls nicht vorhanden):
|
||||
|
||||
```python
|
||||
def test_grafana_public_url_default(monkeypatch):
|
||||
monkeypatch.delenv("FB_GRAFANA_PUBLIC_URL", raising=False)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert get_settings().grafana_public_url == ""
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_grafana_public_url_from_env(monkeypatch):
|
||||
monkeypatch.setenv("FB_GRAFANA_PUBLIC_URL", "https://fb.example.de/grafana/")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert get_settings().grafana_public_url == "https://fb.example.de/grafana/"
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_session_cookie_secure_default_false(monkeypatch):
|
||||
monkeypatch.delenv("FB_SESSION_COOKIE_SECURE", raising=False)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert get_settings().session_cookie_secure is False
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("val,expected", [
|
||||
("true", True), ("True", True), ("1", True), ("yes", True), ("on", True),
|
||||
("false", False), ("0", False), ("", False), ("nope", False),
|
||||
])
|
||||
def test_session_cookie_secure_parsing(monkeypatch, val, expected):
|
||||
monkeypatch.setenv("FB_SESSION_COOKIE_SECURE", val)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert get_settings().session_cookie_secure is expected
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
```
|
||||
|
||||
- [x] **Step 2: Tests laufen lassen, Fehlschlag prüfen**
|
||||
|
||||
Run: `cd finance && .venv/bin/python -m pytest tests/test_config.py -q`
|
||||
Expected: FAIL (`AttributeError: ... 'grafana_public_url'` / `session_cookie_secure`).
|
||||
|
||||
- [x] **Step 3: Implementieren** — `finance/app/config.py`. Bool-Helfer vor
|
||||
`get_settings` einfügen, zwei Felder ins `@dataclass Settings` und zwei
|
||||
Zeilen in den `Settings(...)`-Konstruktor:
|
||||
|
||||
```python
|
||||
def _env_bool(value: str) -> bool:
|
||||
"""Interpretiert einen Env-Wert als Wahrheitswert. Truthy sind (case-
|
||||
insensitiv) 1/true/yes/on; alles andere (inkl. leer) ist False."""
|
||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||
```
|
||||
|
||||
Ins `Settings`-Dataclass (nach `grafana_url: str`) ergänzen:
|
||||
|
||||
```python
|
||||
grafana_public_url: str
|
||||
session_cookie_secure: bool
|
||||
```
|
||||
|
||||
In `get_settings()` in den `return Settings(...)`-Aufruf (nach
|
||||
`grafana_url=...`) ergänzen:
|
||||
|
||||
```python
|
||||
# Öffentliche Grafana-Basis-URL für Browser-Links/iframes hinter einem
|
||||
# Reverse Proxy (z.B. 'https://fb.wolfundlaemmlein.de/grafana/'). Leer =
|
||||
# Direktbetrieb, Templates fallen auf http://<host>:8097 zurück. NICHT
|
||||
# zu verwechseln mit grafana_url (intern, Server→Grafana).
|
||||
grafana_public_url=e("FB_GRAFANA_PUBLIC_URL", ""),
|
||||
# Session-Cookie mit secure-Flag ausliefern (nur über HTTPS gültig).
|
||||
# Für den Reverse-Proxy-Betrieb; Default False für lokalen HTTP-Zugriff.
|
||||
session_cookie_secure=_env_bool(e("FB_SESSION_COOKIE_SECURE", "")),
|
||||
```
|
||||
|
||||
- [x] **Step 4: Tests grün**
|
||||
|
||||
Run: `cd finance && .venv/bin/python -m pytest tests/test_config.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 5: Volle Suite**
|
||||
|
||||
Run: `cd finance && .venv/bin/python -m pytest -q`
|
||||
Expected: PASS (> 199).
|
||||
|
||||
- [x] **Step 6: Fable-Gate + Commit**
|
||||
|
||||
```bash
|
||||
git add finance/app/config.py finance/tests/test_config.py
|
||||
git commit -m "feat: FB_GRAFANA_PUBLIC_URL + FB_SESSION_COOKIE_SECURE in Settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Session-Cookie `secure`-Flag im Login setzen
|
||||
|
||||
**Files:**
|
||||
- Modify: `finance/app/main.py:44-53` (Login-Handler)
|
||||
- Test: `finance/tests/test_auth.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Settings.session_cookie_secure` (Task 1).
|
||||
- Löst die OFFENE ENTSCHEIDUNG aus Ausbaustufe 2 (Session-Cookie ohne
|
||||
`secure`) auf: jetzt per Env konfigurierbar, Default unverändert (kein
|
||||
`secure` im Lokalbetrieb).
|
||||
|
||||
- [x] **Step 1: Failing Tests** — an `finance/tests/test_auth.py` anhängen.
|
||||
Sicherstellen, dass oben importiert ist: `from app.config import get_settings`.
|
||||
Die Tests nutzen die vorhandene `client`-Fixture (setzt Passwort-Hash) und
|
||||
schalten das Flag zur Laufzeit um (Login liest `get_settings()` je Request):
|
||||
|
||||
```python
|
||||
def test_login_cookie_secure_when_enabled(client, monkeypatch):
|
||||
monkeypatch.setenv("FB_SESSION_COOKIE_SECURE", "true")
|
||||
get_settings.cache_clear()
|
||||
r = client.post("/login", data={"username": "admin", "password": "geheim"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert "secure" in r.headers["set-cookie"].lower()
|
||||
|
||||
|
||||
def test_login_cookie_not_secure_by_default(client, monkeypatch):
|
||||
monkeypatch.delenv("FB_SESSION_COOKIE_SECURE", raising=False)
|
||||
get_settings.cache_clear()
|
||||
r = client.post("/login", data={"username": "admin", "password": "geheim"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert "secure" not in r.headers["set-cookie"].lower()
|
||||
```
|
||||
|
||||
- [x] **Step 2: Fehlschlag prüfen**
|
||||
|
||||
Run: `cd finance && .venv/bin/python -m pytest tests/test_auth.py::test_login_cookie_secure_when_enabled -q`
|
||||
Expected: FAIL (`assert "secure" in ...`, Cookie hat noch kein secure-Flag).
|
||||
|
||||
- [x] **Step 3: Implementieren** — in `finance/app/main.py` den
|
||||
`set_cookie`-Aufruf im Login um `secure=` ergänzen:
|
||||
|
||||
```python
|
||||
@app.post("/login")
|
||||
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, 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,
|
||||
max_age=auth.MAX_AGE, samesite="lax",
|
||||
secure=s.session_cookie_secure)
|
||||
return resp
|
||||
```
|
||||
|
||||
- [x] **Step 4: Tests grün**
|
||||
|
||||
Run: `cd finance && .venv/bin/python -m pytest tests/test_auth.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 5: Volle Suite**
|
||||
|
||||
Run: `cd finance && .venv/bin/python -m pytest -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 6: Fable-Gate + Commit**
|
||||
|
||||
```bash
|
||||
git add finance/app/main.py finance/tests/test_auth.py
|
||||
git commit -m "feat: Session-Cookie secure-Flag per FB_SESSION_COOKIE_SECURE"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Grafana-Links über `FB_GRAFANA_PUBLIC_URL` (Jinja-Global + Templates)
|
||||
|
||||
**Files:**
|
||||
- Modify: `finance/app/routers/gui.py` (Import + Jinja-Global)
|
||||
- Modify: `finance/app/templates/base.html:20`
|
||||
- Modify: `finance/app/templates/index.html:80,82`
|
||||
- Modify: `finance/app/templates/szenarien.html:191`
|
||||
- Test: `finance/tests/test_gui.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Settings.grafana_public_url` (Task 1).
|
||||
- Produces: Jinja-Global `grafana_public_base(request) -> str`. Rückgabe: bei
|
||||
gesetzter `FB_GRAFANA_PUBLIC_URL` diese ohne abschließende Slashes; sonst
|
||||
`http://<request-host>:8097`. Templates hängen bei Bedarf `/d/finanzen/...`
|
||||
an — mit sub-path-URL ergibt das `.../grafana/d/finanzen/...`.
|
||||
|
||||
- [x] **Step 1: Failing Tests** — an `finance/tests/test_gui.py` anhängen. Oben
|
||||
`from app.config import get_settings` ergänzen, falls nicht vorhanden:
|
||||
|
||||
```python
|
||||
def test_grafana_links_use_public_url_when_set(client, monkeypatch):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
monkeypatch.setenv("FB_GRAFANA_PUBLIC_URL", "https://fb.example.de/grafana/")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
# iframe + Anmelde-Link nutzen die öffentliche URL, kein :8097 mehr.
|
||||
assert "https://fb.example.de/grafana/d/finanzen/finanzen" in r.text
|
||||
assert ":8097" not in r.text
|
||||
# Nav-Link im base-Template ebenfalls.
|
||||
assert 'href="https://fb.example.de/grafana"' in r.text
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_grafana_links_fallback_to_host_port_when_unset(client, monkeypatch):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
monkeypatch.delenv("FB_GRAFANA_PUBLIC_URL", raising=False)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert ":8097/d/finanzen/finanzen" in r.text
|
||||
assert "fb.example.de" not in r.text
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
```
|
||||
|
||||
- [x] **Step 2: Fehlschlag prüfen**
|
||||
|
||||
Run: `cd finance && .venv/bin/python -m pytest tests/test_gui.py::test_grafana_links_use_public_url_when_set -q`
|
||||
Expected: FAIL (Templates nutzen noch `<host>:8097`, `:8097` ist im Text).
|
||||
|
||||
- [x] **Step 3a: Jinja-Global implementieren** — `finance/app/routers/gui.py`.
|
||||
Import ergänzen (bei den `from app...`-Imports):
|
||||
|
||||
```python
|
||||
from app.config import get_settings
|
||||
```
|
||||
|
||||
Nach dem Block, der `templates` konfiguriert (`templates.env.globals[...]`),
|
||||
einfügen:
|
||||
|
||||
```python
|
||||
def grafana_public_base(request: Request) -> str:
|
||||
"""Öffentliche Grafana-Basis-URL für Browser-Links/iframes. Ist
|
||||
FB_GRAFANA_PUBLIC_URL gesetzt (Reverse-Proxy-Betrieb, z.B.
|
||||
'https://fb.example.de/grafana/'), wird sie ohne abschließenden
|
||||
Slash zurückgegeben; sonst der lokale Fallback http://<host>:8097
|
||||
(Direktbetrieb ohne Proxy). Templates hängen bei Bedarf '/d/...' an."""
|
||||
public = get_settings().grafana_public_url
|
||||
if public:
|
||||
return public.rstrip("/")
|
||||
host = request.url.hostname or "127.0.0.1"
|
||||
return f"http://{host}:8097"
|
||||
|
||||
|
||||
templates.env.globals["grafana_public_base"] = grafana_public_base
|
||||
```
|
||||
|
||||
- [x] **Step 3b: Templates umstellen.**
|
||||
|
||||
`finance/app/templates/base.html` Zeile 20:
|
||||
|
||||
```html
|
||||
<a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a>
|
||||
```
|
||||
|
||||
`finance/app/templates/index.html` Zeilen 80 und 82:
|
||||
|
||||
```html
|
||||
<iframe class="grafana" src="{{ grafana_public_base(request) }}/d/finanzen/finanzen?orgId=1&kiosk"></iframe>
|
||||
```
|
||||
```html
|
||||
<a href="{{ grafana_public_base(request) }}" target="_blank">Grafana anmelden</a>
|
||||
```
|
||||
|
||||
`finance/app/templates/szenarien.html` Zeile 191:
|
||||
|
||||
```html
|
||||
Kurven in <a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a> ansehen.
|
||||
```
|
||||
|
||||
- [x] **Step 4: Tests grün**
|
||||
|
||||
Run: `cd finance && .venv/bin/python -m pytest tests/test_gui.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 5: Kontrollgriff — keine hartkodierten `:8097` mehr in Templates**
|
||||
|
||||
Run: `grep -rn ":8097" finance/app/templates`
|
||||
Expected: keine Treffer.
|
||||
|
||||
- [x] **Step 6: Volle Suite**
|
||||
|
||||
Run: `cd finance && .venv/bin/python -m pytest -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 7: Fable-Gate + Commit**
|
||||
|
||||
```bash
|
||||
git add finance/app/routers/gui.py finance/app/templates/base.html \
|
||||
finance/app/templates/index.html finance/app/templates/szenarien.html \
|
||||
finance/tests/test_gui.py
|
||||
git commit -m "feat: Grafana-Links aus FB_GRAFANA_PUBLIC_URL (Sub-Pfad-tauglich)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: uvicorn Proxy-Headers im Entrypoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `finance/entrypoint.sh`
|
||||
- Test: `finance/tests/test_entrypoint.py` (Create)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: uvicorn startet mit `--proxy-headers --forwarded-allow-ips='*'`,
|
||||
damit Starlette hinter dem Proxy `X-Forwarded-Proto`/`-Host` auswertet
|
||||
(korrektes https/Host in `request.url`). `*` ist vertretbar, weil der Pod
|
||||
nur an `127.0.0.1` gebunden ist und ausschließlich der lokale Traefik ihn
|
||||
erreicht (dokumentiert in docs/reverse-proxy.md, Task 6).
|
||||
|
||||
- [x] **Step 1: Failing Test** — `finance/tests/test_entrypoint.py` anlegen:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
ENTRYPOINT = Path(__file__).resolve().parent.parent / "entrypoint.sh"
|
||||
|
||||
|
||||
def test_entrypoint_enables_proxy_headers():
|
||||
text = ENTRYPOINT.read_text()
|
||||
assert "--proxy-headers" in text
|
||||
assert "--forwarded-allow-ips" in text
|
||||
```
|
||||
|
||||
- [x] **Step 2: Fehlschlag prüfen**
|
||||
|
||||
Run: `cd finance && .venv/bin/python -m pytest tests/test_entrypoint.py -q`
|
||||
Expected: FAIL (Flags fehlen noch).
|
||||
|
||||
- [x] **Step 3: Implementieren** — `finance/entrypoint.sh` vollständig:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
set -e
|
||||
alembic upgrade head
|
||||
# --proxy-headers + --forwarded-allow-ips='*': hinter dem Reverse Proxy
|
||||
# (Traefik→Apache/TLS) wertet uvicorn X-Forwarded-Proto/-Host aus, damit
|
||||
# request.url das öffentliche https/Host statt des pod-internen http sieht.
|
||||
# '*' ist vertretbar, weil der Pod nur an 127.0.0.1 gebunden ist und nur der
|
||||
# lokale Traefik ihn erreicht (siehe docs/reverse-proxy.md). Im Direktbetrieb
|
||||
# ohne Proxy sendet niemand X-Forwarded-*, also bleibt das Verhalten gleich.
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 \
|
||||
--proxy-headers --forwarded-allow-ips='*'
|
||||
```
|
||||
|
||||
- [x] **Step 4: Test grün + Shell-Syntaxcheck**
|
||||
|
||||
Run: `cd finance && .venv/bin/python -m pytest tests/test_entrypoint.py -q && sh -n entrypoint.sh && echo OK`
|
||||
Expected: PASS + `OK`.
|
||||
|
||||
- [x] **Step 5: Volle Suite**
|
||||
|
||||
Run: `cd finance && .venv/bin/python -m pytest -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 6: Fable-Gate + Commit**
|
||||
|
||||
```bash
|
||||
git add finance/entrypoint.sh finance/tests/test_entrypoint.py
|
||||
git commit -m "feat: uvicorn Proxy-Headers fuer Reverse-Proxy-Betrieb"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `create_pod_finance.sh` — Grafana Sub-Pfad + sub-path-bewusste interne URLs
|
||||
|
||||
**Files:**
|
||||
- Modify: `create_pod_finance.sh` (Repo-Wurzel)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `FB_GRAFANA_PUBLIC_URL`, `FB_SESSION_COOKIE_SECURE` aus der `.env`
|
||||
(via `set -a; . "$ENV_FILE"`). Beide sind NICHT Teil der generierten
|
||||
Standard-`.env` — der Nutzer trägt sie beim Umzug hinter den Proxy manuell
|
||||
(single-quoted) ein. Fehlen sie, ist der Lokalbetrieb unverändert.
|
||||
- Produces: Im Sub-Pfad-Modus lauffähige Grafana-Instanz unter `/grafana/`,
|
||||
interner Health-Check + interne Grafana-API-URL (`FB_GRAFANA_URL`) mit
|
||||
`/grafana`-Präfix. Reines Shell-Skript ohne Unit-Test; Verifikation über
|
||||
`bash -n`, isolierten Test der Sub-Pfad-Extraktion und den Live-Redeploy in
|
||||
Task 7.
|
||||
|
||||
- [x] **Step 1: Sub-Pfad-Berechnung einfügen** — in `create_pod_finance.sh`
|
||||
VOR dem **API**-`podman run` (`podman run -d --name "$API_CTR_NAME" ...`)
|
||||
diesen Block einfügen. WICHTIG: nicht erst vor dem Grafana-Container — der
|
||||
API-Container läuft im Skript zuerst und verwendet bereits `${GF_SUBPATH}`
|
||||
(siehe Step 3), also muss der Block davor stehen, sonst wäre `FB_GRAFANA_URL`
|
||||
im Sub-Pfad-Modus fälschlich präfixlos:
|
||||
|
||||
```bash
|
||||
# --- Reverse-Proxy-Betrieb (optional) ----------------------------------------
|
||||
# FB_GRAFANA_PUBLIC_URL wird nur gesetzt, wenn das Tool hinter einem Reverse
|
||||
# Proxy unter einem Sub-Pfad laufen soll (z.B.
|
||||
# 'https://fb.example.de/grafana/'). Ist sie leer (Default,
|
||||
# Direktbetrieb auf 127.0.0.1), bleibt alles wie bisher: Grafana serviert an
|
||||
# der Wurzel, Health-Check und Passwort-Sync sprechen /api/... ohne Praefix.
|
||||
GF_SUBPATH=''
|
||||
GRAFANA_SUBPATH_ARGS=()
|
||||
if [ -n "${FB_GRAFANA_PUBLIC_URL:-}" ]; then
|
||||
# Pfadanteil der oeffentlichen URL extrahieren, Slash(es) am Ende entfernen:
|
||||
# 'https://host/grafana/' -> '/grafana'. Dieser Praefix wird sowohl fuer den
|
||||
# internen Health-Check als auch fuer die interne Grafana-API-URL
|
||||
# (FB_GRAFANA_URL, Passwort-Sync in der GUI) gebraucht, weil
|
||||
# serve_from_sub_path ALLE Grafana-Routen unter den Sub-Pfad haengt.
|
||||
GF_SUBPATH=$(printf '%s' "$FB_GRAFANA_PUBLIC_URL" | sed -E 's#^[a-z]+://[^/]+##; s#/+$##')
|
||||
GRAFANA_SUBPATH_ARGS=(
|
||||
-e "GF_SERVER_ROOT_URL=$FB_GRAFANA_PUBLIC_URL"
|
||||
-e "GF_SERVER_SERVE_FROM_SUB_PATH=true"
|
||||
)
|
||||
fi
|
||||
```
|
||||
|
||||
- [x] **Step 2: Grafana-Container um die Sub-Pfad-Args ergänzen** — im
|
||||
`podman run -d --name "$GRAFANA_CTR_NAME" ...`-Aufruf eine Zeile
|
||||
einfügen (z.B. direkt nach `-e GF_SECURITY_COOKIE_SAMESITE=lax \`):
|
||||
|
||||
```bash
|
||||
"${GRAFANA_SUBPATH_ARGS[@]}" \
|
||||
```
|
||||
|
||||
(Bei leerem Array expandiert das unter bash zu nichts — Lokalbetrieb
|
||||
unverändert.)
|
||||
|
||||
- [x] **Step 3: API-Container um die drei Env-Durchreichungen ergänzen** — im
|
||||
`podman run -d --name "$API_CTR_NAME" ...`-Aufruf nach der Zeile
|
||||
`-e FB_UPLOADS_DIR=/data/uploads \` einfügen:
|
||||
|
||||
```bash
|
||||
-e FB_GRAFANA_PUBLIC_URL="${FB_GRAFANA_PUBLIC_URL:-}" \
|
||||
-e FB_SESSION_COOKIE_SECURE="${FB_SESSION_COOKIE_SECURE:-}" \
|
||||
-e FB_GRAFANA_URL="http://localhost:3000${GF_SUBPATH}" \
|
||||
```
|
||||
|
||||
(`FB_GRAFANA_URL` erhält im Lokalbetrieb `http://localhost:3000` — identisch
|
||||
zum bisherigen Config-Default; im Sub-Pfad-Modus `http://localhost:3000/grafana`,
|
||||
damit die GUI-Passwortänderung die Grafana-Admin-API unter dem Präfix trifft.)
|
||||
|
||||
- [x] **Step 4: Grafana-Health-Check-URL sub-path-bewusst machen** — die Zeile
|
||||
|
||||
```bash
|
||||
CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT/api/health"
|
||||
```
|
||||
|
||||
ersetzen durch:
|
||||
|
||||
```bash
|
||||
CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT${GF_SUBPATH}/api/health"
|
||||
```
|
||||
|
||||
- [x] **Step 5: Statische Verifikation**
|
||||
|
||||
Run: `bash -n create_pod_finance.sh && echo SYNTAX-OK`
|
||||
Expected: `SYNTAX-OK`.
|
||||
|
||||
Sub-Pfad-Extraktion isoliert prüfen (darf NICHT die echte Domain benutzen):
|
||||
|
||||
```bash
|
||||
for u in 'https://fb.example.de/grafana/' 'https://fb.example.de/grafana' 'http://host.tld/g/' ''; do
|
||||
printf '%s -> [%s]\n' "$u" "$(printf '%s' "$u" | sed -E 's#^[a-z]+://[^/]+##; s#/+$##')"
|
||||
done
|
||||
```
|
||||
Expected:
|
||||
```
|
||||
https://fb.example.de/grafana/ -> [/grafana]
|
||||
https://fb.example.de/grafana -> [/grafana]
|
||||
http://host.tld/g/ -> [/g]
|
||||
-> []
|
||||
```
|
||||
|
||||
- [x] **Step 6: Fable-Gate (adversariale Skript-Review) + Commit**
|
||||
|
||||
Fable prüft insbesondere: Lokalbetrieb (leere Variable) ist echter No-Op
|
||||
(leeres Array, leerer Präfix, `FB_GRAFANA_URL=http://localhost:3000`);
|
||||
Sub-Pfad-Extraktion robust; keine Domain im Skript.
|
||||
|
||||
```bash
|
||||
git add create_pod_finance.sh
|
||||
git commit -m "feat: create_pod_finance.sh Grafana-Sub-Pfad + interne URLs sub-path-bewusst"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Doku — Reverse-Proxy-Snippets (Traefik + Apache) und Env-Variablen
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/reverse-proxy.md`
|
||||
- Modify: `docs/ARCHITEKTUR.md` (Verweis im Deployment-Abschnitt)
|
||||
- Modify: `CLAUDE.md` (zwei neue `.env`-Variablen in den Betriebsnotizen)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: Betriebsdoku für den Proxy-Umzug. Beispiel-Snippets sind
|
||||
Vorlagen — die eigentliche Proxy-Konfiguration macht der Nutzer.
|
||||
|
||||
- [x] **Step 1: `docs/reverse-proxy.md` anlegen** mit folgendem Inhalt:
|
||||
|
||||
````markdown
|
||||
# Reverse-Proxy-Betrieb (Ausbaustufe 10)
|
||||
|
||||
Ziel: Erreichbarkeit über **eine** Subdomain
|
||||
`https://fb.wolfundlaemmlein.de/`, Grafana als Unterpfad `/grafana/`.
|
||||
|
||||
Kette: Internet → **sv003** (Apache, TLS-Terminierung) → WireGuard-VPN →
|
||||
**sv006** (Traefik, ohne TLS, `10.8.0.6:8080`) → finance_pod
|
||||
(`127.0.0.1:8096` GUI/API, `127.0.0.1:8097` Grafana).
|
||||
|
||||
Die Proxy-Konfiguration selbst (Traefik, Apache) liegt **außerhalb** dieses
|
||||
Repos — die folgenden Snippets sind Vorlagen. Tool-seitig genügt es, in der
|
||||
`.env` unter `~/.local/share/finance_pod/.env` zwei Variablen zu setzen und
|
||||
`./create_pod_finance.sh` erneut auszuführen.
|
||||
|
||||
## `.env`-Schalter (single-quoted!)
|
||||
|
||||
```sh
|
||||
FB_GRAFANA_PUBLIC_URL='https://fb.wolfundlaemmlein.de/grafana/'
|
||||
FB_SESSION_COOKIE_SECURE='true'
|
||||
```
|
||||
|
||||
- `FB_GRAFANA_PUBLIC_URL` — öffentliche Grafana-Basis-URL (mit `/grafana/`,
|
||||
abschließender Slash empfohlen). Bewirkt:
|
||||
- `create_pod_finance.sh` startet Grafana mit `GF_SERVER_ROOT_URL` +
|
||||
`GF_SERVER_SERVE_FROM_SUB_PATH=true`;
|
||||
- interner Health-Check und interne Grafana-API-URL (`FB_GRAFANA_URL`, für
|
||||
die GUI-Passwortänderung) erhalten automatisch den `/grafana`-Präfix;
|
||||
- alle GUI-Links/iframes zeigen auf die öffentliche URL statt `<host>:8097`.
|
||||
- **Leer lassen** = unveränderter Direktbetrieb auf `127.0.0.1`.
|
||||
- `FB_SESSION_COOKIE_SECURE='true'` — Session-Cookie nur über HTTPS. Vor der
|
||||
Internet-Freigabe setzen. Default (leer/`false`) für lokalen HTTP-Zugriff.
|
||||
|
||||
Nach dem Editieren: `./create_pod_finance.sh` (übernimmt die `.env`
|
||||
unverändert, baut Pod/Container neu). HTTPS/HSTS und Login-Rate-Limiting sind
|
||||
Proxy-Sache (Apache auf sv003 bzw. fail2ban) — außerhalb dieses Repos.
|
||||
|
||||
## Traefik (sv006) — Datei-Provider, Beispiel
|
||||
|
||||
Kein Prefix-Stripping für `/grafana` (Grafana serviert dank
|
||||
`serve_from_sub_path` selbst unter dem Sub-Pfad):
|
||||
|
||||
```yaml
|
||||
# /etc/traefik/dynamic/finance.yml
|
||||
http:
|
||||
routers:
|
||||
finance-grafana:
|
||||
rule: "Host(`fb.wolfundlaemmlein.de`) && PathPrefix(`/grafana`)"
|
||||
priority: 20
|
||||
service: finance-grafana
|
||||
entryPoints: [web]
|
||||
finance-app:
|
||||
rule: "Host(`fb.wolfundlaemmlein.de`)"
|
||||
priority: 10
|
||||
service: finance-app
|
||||
entryPoints: [web]
|
||||
services:
|
||||
finance-grafana:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1:8097"
|
||||
finance-app:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1:8096"
|
||||
```
|
||||
|
||||
## Apache (sv003) — VHost, Beispiel
|
||||
|
||||
TLS terminiert hier; Weiterleitung an Traefik über die WireGuard-IP. `X-
|
||||
Forwarded-Proto https` ist wichtig, damit uvicorn (mit `--proxy-headers`) das
|
||||
öffentliche Schema erkennt:
|
||||
|
||||
```apache
|
||||
<VirtualHost *:443>
|
||||
ServerName fb.wolfundlaemmlein.de
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/fb.wolfundlaemmlein.de/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/fb.wolfundlaemmlein.de/privkey.pem
|
||||
|
||||
ProxyPreserveHost On
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
ProxyPass / http://10.8.0.6:8080/
|
||||
ProxyPassReverse / http://10.8.0.6:8080/
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
## Zurück in den Lokalbetrieb
|
||||
|
||||
`FB_GRAFANA_PUBLIC_URL` und `FB_SESSION_COOKIE_SECURE` aus der `.env`
|
||||
entfernen (oder leeren) und `./create_pod_finance.sh` erneut ausführen.
|
||||
````
|
||||
|
||||
- [x] **Step 2: Verweis in `docs/ARCHITEKTUR.md`** — im Deployment-Abschnitt
|
||||
(Abschnitt 3) einen Hinweis auf die neue Doku ergänzen, z.B.:
|
||||
|
||||
```markdown
|
||||
> Reverse-Proxy-Betrieb (eine Subdomain, Grafana als `/grafana/`-Unterpfad):
|
||||
> siehe **`docs/reverse-proxy.md`** (Ausbaustufe 10). Aktivierung rein über
|
||||
> `.env` (`FB_GRAFANA_PUBLIC_URL`, `FB_SESSION_COOKIE_SECURE`); leer = lokaler
|
||||
> Direktbetrieb unverändert.
|
||||
```
|
||||
|
||||
- [x] **Step 3: `CLAUDE.md`** — im Abschnitt zur `.env` die zwei neuen
|
||||
Variablen kurz erwähnen (ein Satz), inkl. Verweis auf `docs/reverse-proxy.md`.
|
||||
|
||||
- [x] **Step 4: Fable-Gate (Faktencheck gegen Skript/Config/Templates) + Commit**
|
||||
|
||||
Fable prüft: Snippets stimmen mit dem tatsächlichen Verhalten aus Task 1–5
|
||||
überein (Ports 8096/8097, kein Prefix-Stripping, `serve_from_sub_path`,
|
||||
Env-Namen exakt).
|
||||
|
||||
```bash
|
||||
git add docs/reverse-proxy.md docs/ARCHITEKTUR.md CLAUDE.md
|
||||
git commit -m "docs: Reverse-Proxy-Snippets (Traefik/Apache) + .env-Schalter"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Release v0.10.0 — Version, Redeploy, Live-Check Lokalbetrieb
|
||||
|
||||
**Files:**
|
||||
- Modify: `finance/VERSION` (0.9.0 → 0.10.0)
|
||||
- Modify: `docs/superpowers/plans/2026-07-20-ausbaustufe-10.md` (Haken)
|
||||
- Modify: `.superpowers/sdd/progress.md` (Ledger-Eintrag)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: alle vorherigen Tasks.
|
||||
- Live-Verifikation, dass der **Lokalbetrieb** (leere Proxy-Variablen) mit
|
||||
v0.10.0 unverändert funktioniert.
|
||||
|
||||
- [x] **Step 1: Version hochzählen**
|
||||
|
||||
`finance/VERSION`:
|
||||
```
|
||||
0.10.0
|
||||
```
|
||||
|
||||
- [x] **Step 2: Volle Suite**
|
||||
|
||||
Run: `cd finance && .venv/bin/python -m pytest -q`
|
||||
Expected: PASS (> 199).
|
||||
|
||||
- [x] **Step 3: Redeploy (Lokalbetrieb, `.env` OHNE Proxy-Variablen)**
|
||||
|
||||
Run: `./create_pod_finance.sh`
|
||||
Expected: Läuft durch bis „API is reachable ... (200)" und „Grafana is
|
||||
reachable ... (200)"; Service enabled+active. Der Health-Check spricht ohne
|
||||
Präfix `:8097/api/health` (weil `FB_GRAFANA_PUBLIC_URL` leer).
|
||||
|
||||
- [x] **Step 4: Live-Check Lokalbetrieb unverändert**
|
||||
|
||||
```bash
|
||||
# Login → 303 + Cookie (Cookie OHNE Secure, da FB_SESSION_COOKIE_SECURE leer)
|
||||
curl -si -c /tmp/fb_cookies -X POST http://127.0.0.1:8096/login \
|
||||
-d 'username=admin' -d "password=$FB_PASSWORD" | grep -i 'HTTP/\|set-cookie'
|
||||
# Version-Endpoint
|
||||
curl -s -b /tmp/fb_cookies http://127.0.0.1:8096/api/version
|
||||
# Grafana-Fallback-Link im Dashboard zeigt :8097 (kein fb.* / kein /grafana)
|
||||
curl -s -b /tmp/fb_cookies http://127.0.0.1:8096/ | grep -o 'http://[^"]*:8097[^"]*d/finanzen[^"]*' | head -1
|
||||
# Seiten 200
|
||||
for p in / /buchungen /salden /planung /szenarien /import /admin /hilfe; do
|
||||
printf '%s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' -b /tmp/fb_cookies http://127.0.0.1:8096$p)"
|
||||
done
|
||||
# Grafana direkt erreichbar (Wurzel, kein Sub-Pfad)
|
||||
curl -s -o /dev/null -w 'grafana:%{http_code}\n' http://127.0.0.1:8097/api/health
|
||||
rm -f /tmp/fb_cookies
|
||||
```
|
||||
Expected: Login `303` + `set-cookie: fb_session=...` **ohne** `Secure`;
|
||||
`/api/version` → `{"version":"0.10.0"}`; Grafana-Link enthält `:8097/d/finanzen`
|
||||
und **kein** `/grafana`; alle Seiten `200`; `grafana:200`.
|
||||
|
||||
- [x] **Step 5: Plan-Haken + Ledger** — alle Task-Checkboxen dieses Plans
|
||||
setzen; in `.superpowers/sdd/progress.md` einen A10-Abschluss-Eintrag
|
||||
ergänzen (Commit-Range, Fable-Befunde je Task, Live-Check-Ergebnis; **keine**
|
||||
echten Kontodaten/Domain-Secrets).
|
||||
|
||||
- [x] **Step 6: Fable-Release-Gate + Commit**
|
||||
|
||||
Fable verifiziert: Suite grün, Version live `0.10.0`, Lokalbetrieb im
|
||||
Live-Check unverändert (Cookie ohne Secure, Grafana-Fallback `:8097`),
|
||||
Bestandsdaten (3 Konten / 1968 Buchungen) unberührt.
|
||||
|
||||
```bash
|
||||
git add finance/VERSION docs/superpowers/plans/2026-07-20-ausbaustufe-10.md \
|
||||
.superpowers/sdd/progress.md
|
||||
git commit -m "chore: Release v0.10.0 (Reverse-Proxy-Tauglichkeit)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (Controller)
|
||||
|
||||
**Spec-Coverage:**
|
||||
1. Grafana Sub-Pfad (`GF_SERVER_ROOT_URL` + `serve_from_sub_path`) → Task 5 ✓
|
||||
2. `FB_GRAFANA_PUBLIC_URL` in Templates, leer ⇒ `<host>:8097`-Fallback,
|
||||
kein Hostname im Repo → Task 1 (Config) + Task 3 (Global/Templates) ✓
|
||||
3. Härtung: secure-Cookie per Env (löst A2-Entscheidung) → Task 1+2;
|
||||
uvicorn Proxy-Headers → Task 4 ✓
|
||||
4. Doku-Snippets Traefik + Apache in docs/ → Task 6 ✓
|
||||
- Zusätzlich abgedeckt (Folgewirkung von serve_from_sub_path): interner
|
||||
Health-Check + interne Grafana-API-URL sub-path-bewusst → Task 5 ✓
|
||||
- Release v0.10.0 + Live-Check Lokalbetrieb → Task 7 ✓
|
||||
|
||||
**Typ-/Namens-Konsistenz:** `grafana_public_url`/`session_cookie_secure`
|
||||
(Task 1) → konsumiert in Task 2 (`s.session_cookie_secure`) und Task 3
|
||||
(`grafana_public_base`). Env-Namen `FB_GRAFANA_PUBLIC_URL`,
|
||||
`FB_SESSION_COOKIE_SECURE`, `FB_GRAFANA_URL` überall identisch. `GF_SUBPATH`
|
||||
konsistent in Task 5.
|
||||
196
docs/superpowers/plans/2026-07-20-ausbaustufe-9.md
Normal file
196
docs/superpowers/plans/2026-07-20-ausbaustufe-9.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Ausbaustufe 9 Implementation Plan — Vorschlags-Algorithmus v2 (v0.9.0)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||
|
||||
**Goal:** `suggest_recurring` erkennt monatliche/vierteljährliche/jährliche Serien mit letztem Betrag, Aktiv-Check, Betrags-Clustern, Umfirmierungs-Merge und robustem Bestandsabgleich; GUI zeigt Rhythmus/Start/Hinweis; Release v0.9.0 mit Live-Gate gegen die echten Daten.
|
||||
|
||||
**Architecture:** Vollständiger Rewrite von `app/services/suggestions.py` (reine Session-in/dict-out-Funktion, Parameter als Modul-Konstanten); `SuggestionOut`-Erweiterung in `routers/planning.py`; Template-Anpassung der Vorschlags-Tabelle. Kein Datenmodell-/Migrationsbedarf.
|
||||
|
||||
**Tech Stack:** SQLAlchemy 2, Pydantic v2, Jinja2, pytest (synthetische Daten).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md` — die dortigen Abschnitte „Algorithmus" (8 Schritte, Konstanten) und „Tests" sind bindend und Teil dieses Plans.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Beträge `Decimal` (keine float-Arithmetik, auch nicht in Toleranzvergleichen — relative Differenzen als `Decimal`-Quotienten).
|
||||
- `date.today()` nur an EINER Stelle (Parameter `today: date | None = None` der Hauptfunktion, Default heute) — Tests injizieren ein festes Datum.
|
||||
- Tests ausschließlich mit synthetischen Daten (DATENSCHUTZ: keine echten Namen/Beträge aus der Live-DB in Tests/Commits).
|
||||
- GUI deutsch, TT.MM.JJJJ, `|eur`, `|de_label`; API Punkt-Dezimal.
|
||||
- Fable-Testagent-Gate je Task VOR Commit; Ledger-Eintrag je Task.
|
||||
- Testlauf: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis 187 passed, muss grün bleiben (drei bestehende Suggestion-Tests DÜRFEN an die neue Semantik angepasst werden, siehe Task 1 Step 4).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Algorithmus-Rewrite + API-Schema
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `app/services/suggestions.py`
|
||||
- Modify: `app/routers/planning.py` (`SuggestionOut`)
|
||||
- Modify: `tests/test_planning_api.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `suggest_recurring(session, today: date | None = None) -> list[dict]` mit Keys `name, amount (Decimal), rhythm, due_day, start_date (date|None), category_id, hinweis (str)`; `SuggestionOut` mit denselben Feldern (`start_date: date | None = None`, `hinweis: str = ""`). Task 2 rendert genau diese Felder.
|
||||
|
||||
- [x] **Step 1: Failing Tests** — in `tests/test_planning_api.py` die drei bestehenden Suggestion-Tests ERSETZEN/ERWEITERN durch die Spec-Fälle (Helper zum Anlegen synthetischer Buchungen schreiben; `dedup_hash` eindeutig, `status="confirmed"`; ein Account genügt; `today=date(2026, 7, 20)` in alle Aufrufe injizieren):
|
||||
|
||||
```python
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
def _tx(db, acc_id, d, amount, cp, cat=None):
|
||||
db.add(Transaction(account_id=acc_id, booking_date=d, amount=Decimal(amount),
|
||||
purpose="p", counterparty=cp, category_id=cat,
|
||||
status="confirmed", dedup_hash=f"h-{cp}-{d}-{amount}"))
|
||||
|
||||
TODAY = date(2026, 7, 20)
|
||||
|
||||
def test_suggest_letzter_betrag_bei_preiserhoehung(db):
|
||||
acc = _acc(db) # Helper: Account anlegen, gibt id zurueck
|
||||
for d, a in [(date(2026, 3, 1), "-190.65"), (date(2026, 4, 1), "-202.94"),
|
||||
(date(2026, 5, 4), "-202.94"), (date(2026, 6, 1), "-202.94"),
|
||||
(date(2026, 7, 1), "-202.94")]:
|
||||
_tx(db, acc, d, a, "Entis Lebensversicherung AG")
|
||||
db.commit()
|
||||
out = suggest_recurring(db, today=TODAY)
|
||||
assert len(out) == 1
|
||||
s = out[0]
|
||||
assert s["amount"] == Decimal("-202.94") and s["rhythm"] == "monthly"
|
||||
assert s["due_day"] == 1 and s["start_date"] is None
|
||||
|
||||
def test_suggest_quartal_mit_phase(db):
|
||||
acc = _acc(db)
|
||||
for d in [date(2025, 9, 15), date(2025, 12, 15), date(2026, 3, 16), date(2026, 6, 15)]:
|
||||
_tx(db, acc, d, "-55.08", "Rundfunk ARD ZDF")
|
||||
db.commit()
|
||||
out = suggest_recurring(db, today=TODAY)
|
||||
assert len(out) == 1
|
||||
assert out[0]["rhythm"] == "quarterly"
|
||||
assert out[0]["start_date"] == date(2026, 6, 15) and out[0]["due_day"] == 15
|
||||
|
||||
def test_suggest_jahr_mit_zwei_belegen(db):
|
||||
acc = _acc(db)
|
||||
for d, a in [(date(2025, 6, 16), "-409.92"), (date(2026, 6, 16), "-467.33")]:
|
||||
_tx(db, acc, d, a, "Kraftfahrer-Schutz e.V.")
|
||||
db.commit()
|
||||
out = suggest_recurring(db, today=TODAY)
|
||||
assert len(out) == 1
|
||||
assert out[0]["rhythm"] == "yearly" and out[0]["amount"] == Decimal("-467.33")
|
||||
assert out[0]["start_date"] == date(2026, 6, 16)
|
||||
assert "409.92" in out[0]["hinweis"] # Betrag zuletzt gestiegen
|
||||
|
||||
def test_suggest_tote_serie_kein_vorschlag(db):
|
||||
acc = _acc(db)
|
||||
for m in (9, 10, 11, 12):
|
||||
_tx(db, acc, date(2025, m, 1), "-35.00", "WWK Alt")
|
||||
db.commit()
|
||||
assert suggest_recurring(db, today=TODAY) == []
|
||||
|
||||
def test_suggest_umfirmierung_merge(db):
|
||||
acc = _acc(db)
|
||||
for m in (11, 12):
|
||||
_tx(db, acc, date(2025, m, 1), "-190.65", "Heidelberger Leben")
|
||||
for m in (1, 2, 3):
|
||||
_tx(db, acc, date(2026, m, 2), "-190.65", "Entis Lebensversicherung")
|
||||
for m in (4, 5, 6, 7):
|
||||
_tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung")
|
||||
db.commit()
|
||||
out = suggest_recurring(db, today=TODAY)
|
||||
assert len(out) == 1
|
||||
assert "Entis" in out[0]["name"] and out[0]["amount"] == Decimal("-202.94")
|
||||
|
||||
def test_suggest_bestandsabgleich_trotz_preisdrift(db):
|
||||
acc = _acc(db)
|
||||
db.add(RecurringItem(name="Entis Lebensversicherung AG", amount=Decimal("-190.65"),
|
||||
rhythm="monthly", due_day=1))
|
||||
for m in (4, 5, 6, 7):
|
||||
_tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung AG")
|
||||
db.commit()
|
||||
assert suggest_recurring(db, today=TODAY) == [] # Namens-Match schlaegt an
|
||||
|
||||
def test_suggest_zwei_vertraege_getrennt(db):
|
||||
acc = _acc(db)
|
||||
for m in (4, 5, 6, 7):
|
||||
_tx(db, acc, date(2026, m, 1), "-346.23", "Heidelberger LV")
|
||||
_tx(db, acc, date(2026, m, 2), "-145.21", "Heidelberger LV")
|
||||
db.commit()
|
||||
out = suggest_recurring(db, today=TODAY)
|
||||
assert len(out) == 2
|
||||
assert {s["amount"] for s in out} == {Decimal("-346.23"), Decimal("-145.21")}
|
||||
```
|
||||
|
||||
(`_acc`-Helper analog bestehender Tests; `RecurringItem`/`Transaction`-Importe existieren.) Die drei Alt-Tests (`three_consecutive_months_with_year_wrap`, `two_months_no_suggestion`, `excludes_existing_recurring_item`) an die neue Signatur/Semantik anpassen: feste `today`-Injektion; Daten ggf. ins Fenster schieben; der Exclusion-Test bleibt inhaltlich gültig (Name-Match).
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_planning_api.py -q` → neue Tests FAIL
|
||||
|
||||
- [x] **Step 2: Rewrite `app/services/suggestions.py`** gemäß Spec-Abschnitt „Algorithmus" (8 Schritte, Konstanten `WINDOW_DAYS=460`, Rhythmus-Tabelle monthly 25-36/≥3, quarterly 80-105/≥3, yearly 330-400/≥2, `STEP_DAYS={"monthly":30,"quarterly":91,"yearly":365}`, `ACTIVITY_FACTOR` 7/4 als `Fraction` oder Tage-Vergleich ganzzahlig, Cluster 35 %, Merge 25 %, Bestand 10 % — alle Toleranzvergleiche als `Decimal`). Struktur: `_norm`, `_rel_diff`, `_amount_clusters` (greedy gegen letztes Mitglied, gleiches Vorzeichen), `_classify` (Median der Abstände), Merge-Pass je Konto über alle Serien, `_covered_by_existing`, Hauptfunktion `suggest_recurring(session, today=None)`. Deutsche Docstrings/Kommentare zur Begründung der Toleranzen.
|
||||
|
||||
- [x] **Step 3: `SuggestionOut` erweitern** — `routers/planning.py`:
|
||||
|
||||
```python
|
||||
class SuggestionOut(BaseModel):
|
||||
name: str
|
||||
amount: Decimal
|
||||
rhythm: str
|
||||
due_day: int
|
||||
start_date: date | None = None
|
||||
category_id: int | None = None
|
||||
hinweis: str = ""
|
||||
```
|
||||
|
||||
- [x] **Step 4: Tests + Suite grün** — `.venv/bin/python -m pytest -q` → PASS (Alt-Test-Anpassungen im Report begründen).
|
||||
|
||||
- [x] **Step 5: Fable-Testagent-Abnahme** (Faktencheck: Toleranz-Arithmetik Decimal-rein; Aktiv-Check-Grenzen; Merge-Bedingungen; keine `date.today()`-Streuung; Alt-Test-Anpassungen berechtigt). Erst nach VERIFIED weiter.
|
||||
|
||||
- [x] **Step 6: Commit** — `git add finance/app/services/suggestions.py finance/app/routers/planning.py finance/tests/test_planning_api.py && git commit -m "feat: Vorschlags-Algorithmus v2 (Rhythmen, letzter Betrag, Aktiv-Check, Merge)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: GUI — Rhythmus/Start/Hinweis in der Vorschlags-Tabelle
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/templates/planning.html` (Fieldset „Vorschläge aus Buchungen")
|
||||
- Modify: `tests/test_gui.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `SuggestionOut`-Felder aus Task 1; Filter `|eur`/`|de_label`.
|
||||
|
||||
- [x] **Step 1: Failing GUI-Test** — in `tests/test_gui.py` (synthetische Serie seeden, `/planung` laden):
|
||||
|
||||
```python
|
||||
def test_vorschlaege_zeigen_rhythmus_und_start(client, db):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
acc = Account(bank="dkb", iban="DE-SUG-1", name="S", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
for d in (date(2025, 9, 15), date(2025, 12, 15), date(2026, 3, 16), date(2026, 6, 15)):
|
||||
db.add(Transaction(account_id=acc.id, booking_date=d, amount=Decimal("-55.08"),
|
||||
purpose="p", counterparty="Rundfunk Synth", status="confirmed",
|
||||
dedup_hash=f"sug-{d}"))
|
||||
db.commit()
|
||||
r = client.get("/planung").text
|
||||
assert "vierteljährlich" in r # de_label des Rhythmus
|
||||
assert "15.06.2026" in r # Start-Spalte TT.MM.JJJJ
|
||||
assert 'name="start_date"' in r # hidden input der Uebernahme
|
||||
```
|
||||
|
||||
WICHTIG: Der Test hängt von `date.today()` der App ab (Aktiv-Check!) — Serie so legen, dass sie um den echten Testlauf-Zeitpunkt herum aktiv ist, oder (besser) `suggest_recurring` in `planung_page` unverändert lassen und den Test mit relativen Daten um `date.today()` konstruieren (letzte Buchung ≤ 45 Tage vor heute, Quartalsschritte rückwärts). Die Variante mit relativen Daten umsetzen; die obigen Fixdaten sind als Muster zu verstehen und auf `date.today()`-relative Werte umzustellen (inkl. erwartetem Start-String via `.strftime('%d.%m.%Y')`).
|
||||
|
||||
- [x] **Step 2: Template** — Vorschlags-Tabelle: Kopf `Name | Betrag | Rhythmus | Fälligkeitstag | Start | (Aktion)`; Zellen `{{ s.rhythm|de_label }}`, `{{ s.start_date.strftime('%d.%m.%Y') if s.start_date else '–' }}`; Betrag-Zelle ergänzt `{% if s.hinweis %}<span class="muted">{{ s.hinweis }}</span>{% endif %}`; Übernahme-Formular: hidden inputs unverändert plus `<input type="hidden" name="start_date" value="{{ s.start_date.isoformat() if s.start_date else '' }}">` (json-form macht leer → null). Hinweistext unter dem Fieldset: „Erkannt werden monatliche, vierteljährliche und jährliche Serien; Betrag = jeweils letzte Buchung."
|
||||
|
||||
- [x] **Step 3: Suite grün**; **Step 4: Fable-Abnahme** (Live-Approximation: Rendering + Übernahme-Roundtrip eines Quartals-Vorschlags inkl. start_date); **Step 5: Commit** `feat: Vorschlaege mit Rhythmus, Start und Hinweis`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Release v0.9.0 + Live-Gate gegen echte Daten
|
||||
|
||||
- [x] **Step 1:** Suite final; `VERSION` → 0.9.0; Commit; `./create_pod_finance.sh`; `/api/version` == 0.9.0.
|
||||
- [x] **Step 2: Fable-Release-Gate (LIVE, lesend):** `GET /api/recurring/suggestions` gegen die echte DB. Prüfen: (a) KEINER der bestehenden ~41 Fixposten wird erneut vorgeschlagen (Bestandsabgleich wirkt, auch bei gedrifteten Beträgen); (b) keine bekannten toten Serien (gelöschte PayPal-−4,99-Serie, ausgelaufene WWK-−35-Police) im Ergebnis; (c) verbleibende Vorschläge einzeln gegen die Buchungen plausibilisieren (echte aktive Serie? korrekte Werte?). Ergebnisliste NUR im Chat/Bericht, nie committen. Bei Fehlklassifikationen: Befund zurück an Task 1 (Toleranzen), Fix + Re-Gate.
|
||||
- [x] **Step 3:** Ledger (generisch) + Plan-Häkchen + Push; Kandidatenliste dem Nutzer berichten.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec-Abdeckung: Algorithmus/Schema → Task 1; GUI → Task 2; Release/Live-Gate → Task 3. Testfälle der Spec vollständig in Task 1 Step 1 kodiert.
|
||||
- Platzhalter: Task 1 Step 2 verweist bewusst auf den bindenden Spec-Abschnitt (8 nummerierte Schritte + Konstanten) statt den vollen Code zu duplizieren; alle Schnittstellen/Konstanten sind exakt benannt.
|
||||
- Typ-Konsistenz: `suggest_recurring(session, today)`-Signatur = Testaufrufe; `SuggestionOut`-Felder = Template-Zugriffe (`s.rhythm`, `s.start_date`, `s.hinweis`).
|
||||
@@ -0,0 +1,104 @@
|
||||
# Design — Ausbaustufe 9: Vorschlags-Algorithmus v2 (v0.9.0)
|
||||
|
||||
> Status: vom Nutzer freigegeben (Chat 2026-07-20, Direktdurchlauf). Anlass:
|
||||
> Der bisherige `suggest_recurring` gruppiert nach exaktem Betrag (jede
|
||||
> Preiserhöhung zerreißt die Serie), erkennt nur monatliche Serien, prüft
|
||||
> keine Aktivität (schlägt tote Serien vor) und nutzt Median-Werte. Ein
|
||||
> manueller Vollabgleich (A8-Datenpflege, siehe Ledger) fand 16 fehlende
|
||||
> Posten — der Algorithmus soll solche Serien künftig selbst finden.
|
||||
|
||||
## Anforderungen (Nutzer)
|
||||
|
||||
- Betrag = **letzte** Buchung, nicht Median (Preissteigerungen relevant).
|
||||
- Erkennung **monatlich, vierteljährlich, jährlich**.
|
||||
- Empfänger-Gruppierung robust (Schreibweisen, Preisänderungen,
|
||||
Umfirmierungen); mehrere Verträge desselben Anbieters getrennt.
|
||||
- Keine „Leichen": abgerissene Serien werden nicht vorgeschlagen.
|
||||
- Kein Wiedervorschlagen bereits gepflegter Posten (auch bei zwischenzeitlich
|
||||
geändertem Betrag).
|
||||
|
||||
## Algorithmus (`app/services/suggestions.py`, vollständiger Rewrite)
|
||||
|
||||
Parameter als Modul-Konstanten (Toleranzen zentral änderbar):
|
||||
`WINDOW_DAYS=460` (~15 Monate), Rhythmen mit Intervallgrenzen und
|
||||
Mindestbelegen: monthly 25–36 Tage/≥3, quarterly 80–105/≥3, yearly
|
||||
330–400/≥2; `ACTIVITY_FACTOR=1.75`; Betrags-Cluster-Toleranz 35 %;
|
||||
Merge-Toleranz 25 %; Bestandsabgleich-Toleranz 10 %.
|
||||
|
||||
1. **Datenbasis:** bestätigte Buchungen der letzten `WINDOW_DAYS`, je Konto.
|
||||
2. **Gruppierung:** Schlüssel = (account_id, normalisierter Empfänger)
|
||||
(`casefold`, Whitespace kollabiert).
|
||||
3. **Betrags-Cluster** innerhalb der Gruppe (chronologisch, greedy gegen das
|
||||
jeweils letzte Cluster-Mitglied, gleiches Vorzeichen, relative Differenz
|
||||
≤ 35 %) — trennt parallele Verträge, hält Preisdrift zusammen.
|
||||
4. **Rhythmus je Cluster:** Median der Buchungsabstände gegen die
|
||||
Intervallgrenzen; Mindestbelege je Rhythmus.
|
||||
5. **Aktiv-Check:** letzte Buchung ≤ `ACTIVITY_FACTOR` × Rhythmus-Schrittweite
|
||||
(30/91/365 Tage) her, sonst kein Vorschlag.
|
||||
6. **Umfirmierungs-Merge** (über Gruppengrenzen, je Konto): Serie A endet,
|
||||
Serie B beginnt danach (Lücke 0,4–1,6 Schrittweiten), gleicher Rhythmus,
|
||||
Fälligkeitstag ±3, Betrag ±25 % → eine Serie; Name/Betrag der neueren.
|
||||
7. **Vorschlagswerte:** Name = Empfänger-Schreibweise der neuesten Buchung;
|
||||
Betrag = neueste Buchung; Fälligkeitstag = Tag der neuesten Buchung;
|
||||
`start_date` = Datum der neuesten Buchung bei quarterly/yearly (Phase!),
|
||||
sonst None; Kategorie = häufigste in der Serie; `hinweis` = Text
|
||||
„Betrag zuletzt gestiegen (vorher X)" wenn die vorletzte Buchung
|
||||
betragskleiner war, sonst leer.
|
||||
8. **Bestandsabgleich:** Vorschlag entfällt, wenn ein `RecurringItem`
|
||||
existiert mit (a) Namens-Substring-Match (normalisiert, in beide
|
||||
Richtungen) ODER (b) gleichem Rhythmus + Fälligkeitstag ±2 + Betrag
|
||||
±10 %.
|
||||
|
||||
## API/GUI
|
||||
|
||||
- `SuggestionOut` (routers/planning.py): + `start_date: date | None`,
|
||||
+ `hinweis: str = ""`.
|
||||
- Vorschlags-Tabelle (planning.html): Spalten Rhythmus (`|de_label`) und
|
||||
Start (TT.MM.JJJJ bzw. „–"); `hinweis` als `muted`-Text hinter dem Betrag;
|
||||
„Vorschlag übernehmen" überträgt `start_date` mit (hidden input).
|
||||
- Hinweistext unter der Tabelle aktualisiert: monatliche/vierteljährliche/
|
||||
jährliche Serien, Betrag = letzte Buchung.
|
||||
|
||||
## Tests (synthetische Daten, keine Fixtures)
|
||||
|
||||
Preiserhöhungs-Serie → letzter Betrag + hinweis; Quartals-/Jahres-Serie mit
|
||||
korrektem start_date; tote Serie (letzte Buchung zu alt) → kein Vorschlag;
|
||||
Umbenennungs-Merge → ein Vorschlag mit neuem Namen; Bestandsabgleich:
|
||||
existierender Posten mit altem Betrag verhindert Wiedervorschlag; zwei
|
||||
parallele Verträge eines Anbieters → zwei getrennte Vorschläge; bestehende
|
||||
drei Suggestion-Tests an die neue Semantik anpassen.
|
||||
|
||||
## Release
|
||||
|
||||
`VERSION` → 0.9.0, Redeploy, **Live-Gate gegen echte Daten**: kein einziger
|
||||
der bestehenden Fixposten darf erneut vorgeschlagen werden; keine als
|
||||
beendet bekannten Serien (z.B. gelöschte PayPal-Leiche, ausgelaufene
|
||||
WWK-Police) im Ergebnis; verbleibende Vorschläge werden dem Nutzer als
|
||||
Kandidatenliste berichtet (nur Chat, kein Commit). Fable-Gate je Task.
|
||||
|
||||
**Außerhalb des Scopes:** halbjährliche Rhythmen (nicht im Datenmodell),
|
||||
automatische Übernahme ohne Nutzer-Klick, Einnahmen-Prognose des
|
||||
Geschäftskontos.
|
||||
|
||||
## Nachtrag (nach Live-Release-Gate, gleiche Session)
|
||||
|
||||
Das erste Live-Gate scheiterte an einem Duplikat: ein kuratierter
|
||||
„variabel"-Fixposten unter Alias-Namen des Anbieters wurde vom
|
||||
Bestandsabgleich (a)/(b) nicht erkannt. Daraus zwei Ergänzungen:
|
||||
|
||||
- **Bestandsabgleich-Regel (c) Token-Match:** Vorschlag entfällt auch, wenn
|
||||
ein Fixposten mit gleichem Rhythmus, Fälligkeitstag ±2 und mindestens
|
||||
einem gemeinsamen Namens-Token (≥ 5 Zeichen, normalisiert, Split an
|
||||
Nicht-Alphanumerik) existiert.
|
||||
- **Volatilitäts-Hinweis:** Wurde die neueste Buchung einer Empfänger-Gruppe
|
||||
durch den Betrags-Cluster-Split abgetrennt UND gehört sie zu keiner
|
||||
anderen qualifizierten Serie der Gruppe, erhält der Vorschlag den Zusatz
|
||||
„Beträge schwanken stark – letzte Buchung weicht ab" (keine
|
||||
Unterdrückung; die Ausnahme verhindert False-Positives bei parallelen
|
||||
Verträgen desselben Anbieters).
|
||||
|
||||
Bewiesene Pipeline-Eigenschaft (bindend fürs Verständnis): der
|
||||
Umfirmierungs-Merge kann die Vorschlagsanzahl nie ändern (Aktiv-Check/
|
||||
Fenster erledigen das allein); sein Nutzen ist Kategorie-/Historien-
|
||||
Kontinuität. Nach einer Umfirmierung entsteht eine Vorschlags-Lücke, bis
|
||||
der neue Name selbst die Mindestbelege erreicht.
|
||||
@@ -1 +1 @@
|
||||
0.8.0
|
||||
0.10.0
|
||||
|
||||
@@ -5,6 +5,12 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _env_bool(value: str) -> bool:
|
||||
"""Interpretiert einen Env-Wert als Wahrheitswert. Truthy sind (case-
|
||||
insensitiv) 1/true/yes/on; alles andere (inkl. leer) ist False."""
|
||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
database_url: str
|
||||
@@ -18,6 +24,8 @@ class Settings:
|
||||
horizon_days: int
|
||||
env_file: Path
|
||||
grafana_url: str
|
||||
grafana_public_url: str
|
||||
session_cookie_secure: bool
|
||||
|
||||
|
||||
@lru_cache
|
||||
@@ -38,4 +46,12 @@ def get_settings() -> Settings:
|
||||
# 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"),
|
||||
# Öffentliche Grafana-Basis-URL für Browser-Links/iframes hinter einem
|
||||
# Reverse Proxy (z.B. 'https://fb.example.de/grafana/'). Leer =
|
||||
# Direktbetrieb, Templates fallen auf http://<host>:8097 zurück. NICHT
|
||||
# zu verwechseln mit grafana_url (intern, Server→Grafana).
|
||||
grafana_public_url=e("FB_GRAFANA_PUBLIC_URL", ""),
|
||||
# Session-Cookie mit secure-Flag ausliefern (nur über HTTPS gültig).
|
||||
# Für den Reverse-Proxy-Betrieb; Default False für lokalen HTTP-Zugriff.
|
||||
session_cookie_secure=_env_bool(e("FB_SESSION_COOKIE_SECURE", "")),
|
||||
)
|
||||
|
||||
@@ -49,7 +49,8 @@ def login(username: str = Form(...), password: str = Form(...)):
|
||||
return HTMLResponse("Login fehlgeschlagen", status_code=401)
|
||||
resp = RedirectResponse("/", status_code=303)
|
||||
resp.set_cookie(auth.COOKIE, auth.make_session_token(), httponly=True,
|
||||
max_age=auth.MAX_AGE, samesite="lax")
|
||||
max_age=auth.MAX_AGE, samesite="lax",
|
||||
secure=s.session_cookie_secure)
|
||||
return resp
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import COOKIE, session_valid
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.engine.loans import add_months
|
||||
from app.engine.recurrence import occurrences
|
||||
@@ -30,6 +31,22 @@ PAGE_SIZE = 50
|
||||
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
templates.env.globals["app_version"] = get_version()
|
||||
|
||||
|
||||
def grafana_public_base(request: Request) -> str:
|
||||
"""Öffentliche Grafana-Basis-URL für Browser-Links/iframes. Ist
|
||||
FB_GRAFANA_PUBLIC_URL gesetzt (Reverse-Proxy-Betrieb, z.B.
|
||||
'https://fb.example.de/grafana/'), wird sie ohne abschließenden
|
||||
Slash zurückgegeben; sonst der lokale Fallback http://<host>:8097
|
||||
(Direktbetrieb ohne Proxy). Templates hängen bei Bedarf '/d/...' an."""
|
||||
public = get_settings().grafana_public_url
|
||||
if public:
|
||||
return public.rstrip("/")
|
||||
host = request.url.hostname or "127.0.0.1"
|
||||
return f"http://{host}:8097"
|
||||
|
||||
|
||||
templates.env.globals["grafana_public_base"] = grafana_public_base
|
||||
templates.env.filters["eur"] = eur
|
||||
templates.env.filters["de_label"] = de_label
|
||||
router = APIRouter()
|
||||
|
||||
@@ -56,7 +56,9 @@ class SuggestionOut(BaseModel):
|
||||
amount: Decimal
|
||||
rhythm: str
|
||||
due_day: int
|
||||
start_date: date | None = None
|
||||
category_id: int | None = None
|
||||
hinweis: str = ""
|
||||
|
||||
|
||||
def _check_category(session: Session, category_id: int | None) -> None:
|
||||
|
||||
@@ -1,52 +1,371 @@
|
||||
"""Vorschlagsalgorithmus fuer wiederkehrende Buchungen (Ausbaustufe 9, v2).
|
||||
|
||||
Ersetzt die reine exakte-Betrags-Gruppierung (v1) durch: Empfaenger-Cluster
|
||||
mit Toleranz (haelt Preisdrift in einer Serie zusammen, trennt aber parallele
|
||||
Vertraege desselben Anbieters), Rhythmus-Erkennung ueber den Median der
|
||||
Buchungsabstaende (monatlich/vierteljaehrlich/jaehrlich statt nur monatlich),
|
||||
einen Aktiv-Check (keine "Leichen"-Serien) sowie einen Merge-Pass fuer
|
||||
Umfirmierungen (Anbieter aendert den Namen, die Serie laeuft inhaltlich
|
||||
weiter). Bindende Spec:
|
||||
docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md.
|
||||
|
||||
Alle Betrags-Toleranzvergleiche verwenden ausschliesslich `Decimal`
|
||||
(CLAUDE.md: "Decimal, nicht float" - Rundungsfehler bei Geldbetraegen sind
|
||||
inakzeptabel). Tage-Vergleiche (Rhythmus, Aktiv-Check, Merge-Luecke) sind
|
||||
ganzzahlige Tage-Arithmetik, niemals float/Decimal-Bruchteile von Tagen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import statistics
|
||||
from collections import Counter, defaultdict
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.formats import eur
|
||||
from app.models.tables import RecurringItem, Transaction
|
||||
|
||||
# Betrachtungsfenster (Schritt 1): ~15 Monate. Muss mindestens die zwei
|
||||
# Belege einer jaehrlichen Serie (bis zu 400 Tage auseinander) plus etwas
|
||||
# Puffer fuer Cluster-/Merge-Bildung abdecken.
|
||||
WINDOW_DAYS = 460
|
||||
|
||||
def _max_consecutive_months(months: list[tuple[int, int]]) -> int:
|
||||
if not months:
|
||||
return 0
|
||||
best = current = 1
|
||||
for prev, cur in zip(months, months[1:]):
|
||||
prev_idx = prev[0] * 12 + prev[1]
|
||||
cur_idx = cur[0] * 12 + cur[1]
|
||||
current = current + 1 if cur_idx == prev_idx + 1 else 1
|
||||
best = max(best, current)
|
||||
return best
|
||||
# Rhythmus-Tabelle: (min_tage, max_tage, mindestbelege) je Rhythmus. Der
|
||||
# Median der Buchungsabstaende einer Serie muss ins Intervall fallen, UND es
|
||||
# muessen mindestens so viele Buchungen vorliegen (ein einzelner Zufallstreffer
|
||||
# mit "passendem" Abstand soll nicht als Serie gelten).
|
||||
RHYTHMS: dict[str, tuple[int, int, int]] = {
|
||||
"monthly": (25, 36, 3),
|
||||
"quarterly": (80, 105, 3),
|
||||
"yearly": (330, 400, 2),
|
||||
}
|
||||
|
||||
# Nominelle Schrittweite je Rhythmus in Tagen - Referenzwert fuer Aktiv-Check
|
||||
# und Merge-Luecken-Fenster (Schritt 5/6).
|
||||
STEP_DAYS: dict[str, int] = {"monthly": 30, "quarterly": 91, "yearly": 365}
|
||||
|
||||
# Aktiv-Check (Schritt 5): die letzte Buchung darf hoechstens das 1,75-fache
|
||||
# der Rhythmus-Schrittweite zurueckliegen, sonst gilt die Serie als beendet
|
||||
# ("Leiche") und wird nicht vorgeschlagen. Als Fraction 7/4 ausgedrueckt und
|
||||
# ganzzahlig verglichen (delta_tage * 4 <= schrittweite * 7), damit keine
|
||||
# Gleitkomma-Rundung ueber "aktiv"/"inaktiv" entscheidet.
|
||||
ACTIVITY_FACTOR_NUM = 7
|
||||
ACTIVITY_FACTOR_DEN = 4
|
||||
|
||||
# Relative Toleranzen (immer als Decimal verglichen, nie float):
|
||||
CLUSTER_TOL = Decimal("0.35") # Schritt 3: Betrags-Cluster (haelt Preisdrift zusammen)
|
||||
MERGE_TOL = Decimal("0.25") # Schritt 6: Umfirmierungs-Merge ueber Gruppengrenzen
|
||||
BESTAND_TOL = Decimal("0.10") # Schritt 8: Bestandsabgleich gegen RecurringItem
|
||||
|
||||
# Merge-Luecke (Schritt 6): die Zeit zwischen dem Ende von Serie A und dem
|
||||
# Beginn von Serie B muss zwischen dem 0,4- und 1,6-fachen der
|
||||
# Rhythmus-Schrittweite liegen (als ganzzahlige Bruchvergleiche, aus
|
||||
# demselben Grund wie beim Aktiv-Check).
|
||||
MERGE_GAP_MIN_NUM, MERGE_GAP_MIN_DEN = 4, 10 # 0.4
|
||||
MERGE_GAP_MAX_NUM, MERGE_GAP_MAX_DEN = 16, 10 # 1.6
|
||||
MERGE_DUE_DAY_TOL = 3 # Schritt 6: Faelligkeitstag-Toleranz in Tagen
|
||||
BESTAND_DUE_DAY_TOL = 2 # Schritt 8: Faelligkeitstag-Toleranz in Tagen
|
||||
|
||||
# Bestandsabgleich, Token-Match (Live-Gate-Fund, Nachtrag 4): kuratierte
|
||||
# Fixposten tragen haeufig einen Alias-/Variabel-Namen, der weder Substring
|
||||
# noch betragsaehnlich zum automatisch erkannten Vorschlag ist (Muster:
|
||||
# ein Sammel-Fixposten fuer eine Kreditkartenabrechnung mit variablem Betrag
|
||||
# unter einem Alias-Namen des Anbieters deckt den vom Algorithmus erkannten
|
||||
# Vorschlag desselben Anbieters unter seinem regulaeren Empfaenger-Namen
|
||||
# nicht ab, weil weder Substring noch Betrags-Toleranz greifen). Ein
|
||||
# gemeinsames, hinreichend spezifisches Namens-Token (>=5 Zeichen, um
|
||||
# generische Woerter wie "Bank" nicht faelschlich matchen zu lassen) bei
|
||||
# gleichem Rhythmus und nahem Faelligkeitstag gilt als ausreichendes Indiz
|
||||
# fuer denselben Fixposten.
|
||||
TOKEN_MIN_LEN = 5
|
||||
|
||||
# Volatilitaets-Hinweis (Live-Gate A9-Fund, Nachtrag 4): wenn der
|
||||
# Betrags-Cluster-Split (Schritt 3) die neueste Buchung der Empfaenger-Gruppe
|
||||
# abgetrennt hat (weil sie zu stark vom Serien-Betrag abweicht), ist der
|
||||
# vorgeschlagene Betrag ggf. schon wieder veraltet - keine Unterdrueckung,
|
||||
# nur ein Warnhinweis fuer die Nutzerin/den Nutzer.
|
||||
VOLATILITAETS_HINWEIS = "Beträge schwanken stark – letzte Buchung weicht ab"
|
||||
|
||||
|
||||
def suggest_recurring(session: Session) -> list[dict]:
|
||||
def _norm(name: str) -> str:
|
||||
"""Normalisiert einen Empfaenger-Namen fuer Gruppen- und
|
||||
Substring-Vergleich: Bankexporte schreiben denselben Empfaenger nicht
|
||||
einheitlich (Gross-/Kleinschreibung, mehrfache Leerzeichen), das ist fuer
|
||||
die Erkennung irrelevant."""
|
||||
return " ".join(name.split()).casefold()
|
||||
|
||||
|
||||
def _tokens(name: str) -> set[str]:
|
||||
"""Zerlegt einen normalisierten Namen an Nicht-Alphanumerik in Tokens
|
||||
(fuer den Token-Match im Bestandsabgleich, Schritt 8). Nur Tokens ab
|
||||
TOKEN_MIN_LEN Zeichen zaehlen, damit kurze generische Woerter ("eG",
|
||||
"AG", "Bank") keine falschen Treffer erzeugen."""
|
||||
return {tok for tok in re.split(r"[^a-z0-9]+", _norm(name)) if len(tok) >= TOKEN_MIN_LEN}
|
||||
|
||||
|
||||
def _rel_diff(a: Decimal, b: Decimal) -> Decimal:
|
||||
"""Relative Differenz von Betrag a zur Referenz b (immer >= 0), als
|
||||
Decimal. b=0 kommt praktisch nicht vor (eine Nullbuchung bildet keine
|
||||
erkennbare Serie); fuer diesen Sonderfall gilt "keine Aehnlichkeit"."""
|
||||
if b == 0:
|
||||
return Decimal("Infinity") if a != 0 else Decimal("0")
|
||||
return abs(a - b) / abs(b)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Series:
|
||||
"""Eine erkannte Serie: chronologisch sortierte Buchungen eines
|
||||
Betrags-Clusters mit zugeordnetem Rhythmus.
|
||||
|
||||
`volatile` markiert, dass der Cluster-Split (Schritt 3) innerhalb der
|
||||
Empfaenger-Gruppe eine NEUERE, betragsmaessig abweichende Buchung
|
||||
abgetrennt hat - der hier vorgeschlagene Betrag koennte also schon
|
||||
wieder veraltet sein (siehe VOLATILITAETS_HINWEIS)."""
|
||||
items: list[Transaction]
|
||||
rhythm: str
|
||||
volatile: bool = False
|
||||
|
||||
@property
|
||||
def first(self) -> Transaction:
|
||||
return self.items[0]
|
||||
|
||||
@property
|
||||
def last(self) -> Transaction:
|
||||
return self.items[-1]
|
||||
|
||||
|
||||
def _amount_clusters(items: list[Transaction]) -> list[list[Transaction]]:
|
||||
"""Schritt 3: teilt chronologisch sortierte Buchungen einer
|
||||
Empfaenger-Gruppe in Betrags-Cluster. Eine Buchung haengt sich an das
|
||||
Cluster, dessen zuletzt aufgenommenes Mitglied gleiches Vorzeichen und
|
||||
eine relative Differenz <= CLUSTER_TOL hat (greedy, erstes passendes
|
||||
Cluster gewinnt) - das haelt eine langsam driftende Serie (Preiserhoehung)
|
||||
zusammen, trennt aber parallele Vertraege mit deutlich anderem Betrag."""
|
||||
clusters: list[list[Transaction]] = []
|
||||
for t in items:
|
||||
for cluster in clusters:
|
||||
last = cluster[-1]
|
||||
same_sign = (t.amount > 0) == (last.amount > 0)
|
||||
if same_sign and _rel_diff(Decimal(t.amount), Decimal(last.amount)) <= CLUSTER_TOL:
|
||||
cluster.append(t)
|
||||
break
|
||||
else:
|
||||
clusters.append([t])
|
||||
return clusters
|
||||
|
||||
|
||||
def _classify(dates: list[date]) -> str | None:
|
||||
"""Schritt 4: bestimmt den Rhythmus einer Serie ueber den Median der
|
||||
Buchungsabstaende (robust gegen einzelne Ausreisser, z.B.
|
||||
Wochenend-/Feiertagsverschiebung einer einzelnen Buchung)."""
|
||||
if len(dates) < 2:
|
||||
return None
|
||||
gaps = [(b - a).days for a, b in zip(dates, dates[1:])]
|
||||
median_gap = statistics.median(gaps)
|
||||
for rhythm, (lo, hi, min_belege) in RHYTHMS.items():
|
||||
if len(dates) >= min_belege and lo <= median_gap <= hi:
|
||||
return rhythm
|
||||
return None
|
||||
|
||||
|
||||
def _merge_gap_ok(gap_days: int, step: int) -> bool:
|
||||
"""Schritt 6: Luecke zwischen Serienende und -beginn im Fenster
|
||||
[0,4; 1,6] * Schrittweite (ganzzahliger Bruchvergleich, keine Rundung)."""
|
||||
return (gap_days * MERGE_GAP_MIN_DEN >= MERGE_GAP_MIN_NUM * step
|
||||
and gap_days * MERGE_GAP_MAX_DEN <= MERGE_GAP_MAX_NUM * step)
|
||||
|
||||
|
||||
def _mergeable(a: _Series, b: _Series) -> bool:
|
||||
"""Prueft die Umfirmierungs-Merge-Bedingungen aus Schritt 6 fuer ein
|
||||
Paar (A endet, B beginnt danach): gleicher Rhythmus, plausible Luecke,
|
||||
Faelligkeitstag nah beieinander (Transitionspunkte: letzte Buchung von A
|
||||
gegen erste Buchung von B), Betrag nicht sprunghaft veraendert."""
|
||||
if a.rhythm != b.rhythm:
|
||||
return False
|
||||
if a.last.booking_date >= b.first.booking_date:
|
||||
return False
|
||||
step = STEP_DAYS[a.rhythm]
|
||||
gap = (b.first.booking_date - a.last.booking_date).days
|
||||
if not _merge_gap_ok(gap, step):
|
||||
return False
|
||||
if abs(a.last.booking_date.day - b.first.booking_date.day) > MERGE_DUE_DAY_TOL:
|
||||
return False
|
||||
same_sign = (a.last.amount > 0) == (b.first.amount > 0)
|
||||
if not same_sign:
|
||||
return False
|
||||
return _rel_diff(Decimal(b.first.amount), Decimal(a.last.amount)) <= MERGE_TOL
|
||||
|
||||
|
||||
def _try_merge(series_list: list[_Series]) -> list[_Series]:
|
||||
"""Schritt 6: fasst Serien desselben Kontos ueber Gruppengrenzen
|
||||
(unterschiedlicher normalisierter Empfaenger-Name, z.B. nach einer
|
||||
Umfirmierung) zusammen, solange `_mergeable` zutrifft. Laeuft iterativ
|
||||
bis zum Fixpunkt, damit eine bereits gemergte Serie mit einer weiteren,
|
||||
noch juengeren Serie erneut zusammengefasst werden kann (z.B. zwei
|
||||
Umbenennungen hintereinander)."""
|
||||
series_list = list(series_list)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for i, a in enumerate(series_list):
|
||||
for j, b in enumerate(series_list):
|
||||
if i == j or not _mergeable(a, b):
|
||||
continue
|
||||
merged = _Series(
|
||||
items=sorted(a.items + b.items, key=lambda t: t.booking_date),
|
||||
rhythm=a.rhythm,
|
||||
volatile=a.volatile or b.volatile,
|
||||
)
|
||||
series_list = [s for k, s in enumerate(series_list) if k not in (i, j)]
|
||||
series_list.append(merged)
|
||||
changed = True
|
||||
break
|
||||
if changed:
|
||||
break
|
||||
return series_list
|
||||
|
||||
|
||||
def _covered_by_existing(cand_name: str, cand_amount: Decimal, rhythm: str, due_day: int,
|
||||
existing: list[RecurringItem]) -> bool:
|
||||
"""Schritt 8 (Bestandsabgleich): ein Vorschlag entfaellt, wenn er bereits
|
||||
als Fixposten gepflegt ist - ueber einen von drei Wegen:
|
||||
(a) Namens-Substring-Match (normalisiert, in beide Richtungen: sowohl
|
||||
Kurz- als auch Langschreibweisen kommen in der Praxis in beiden
|
||||
Datenquellen vor);
|
||||
(b) Rhythmus + Faelligkeitstag + Betrag innerhalb enger Toleranz (falls
|
||||
der Fixposten unter einem ganz anderen Namen gepflegt wurde);
|
||||
(c) Token-Match: gleicher Rhythmus, Faelligkeitstag-Differenz <= 2 UND
|
||||
mindestens ein gemeinsames Namens-Token (>=5 Zeichen) - faengt
|
||||
kuratierte Alias-/Variabel-Fixposten, deren Name UND Betrag stark
|
||||
vom automatisch erkannten Vorschlag abweichen (Live-Gate-Fund: ein
|
||||
Sammel-Fixposten unter Alias-Namen des Anbieters deckt den
|
||||
automatisch erkannten Vorschlag desselben Anbieters unter seinem
|
||||
regulaeren Empfaenger-Namen ab, obwohl weder (a) noch (b) greifen)."""
|
||||
cand_norm = _norm(cand_name)
|
||||
cand_tokens = _tokens(cand_name)
|
||||
for item in existing:
|
||||
item_norm = _norm(item.name)
|
||||
if cand_norm in item_norm or item_norm in cand_norm:
|
||||
return True
|
||||
if (item.rhythm == rhythm
|
||||
and abs(item.due_day - due_day) <= BESTAND_DUE_DAY_TOL
|
||||
and _rel_diff(cand_amount, Decimal(item.amount)) <= BESTAND_TOL):
|
||||
return True
|
||||
if (item.rhythm == rhythm
|
||||
and abs(item.due_day - due_day) <= BESTAND_DUE_DAY_TOL
|
||||
and cand_tokens & _tokens(item.name)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def suggest_recurring(session: Session, today: date | None = None) -> list[dict]:
|
||||
"""Ermittelt Vorschlaege fuer wiederkehrende Posten aus bestaetigten
|
||||
Buchungen der letzten WINDOW_DAYS Tage. `today` ist ausschliesslich zu
|
||||
Testzwecken injizierbar (deterministischer Aktiv-Check) - im
|
||||
Produktivbetrieb liefert der Default `date.today()`. Reihenfolge der
|
||||
Schritte gemaess Spec, mit einer bewussten Umstellung gegenueber der
|
||||
Nummerierung dort: der Aktiv-Check (Schritt 5) laeuft NACH dem
|
||||
Umfirmierungs-Merge (Schritt 6) auf der ggf. gemergten Serie - sonst
|
||||
wuerde eine per Umfirmierung fortgesetzte Serie an ihrem alten,
|
||||
laengst inaktiven Teil scheitern, bevor der Merge sie retten kann."""
|
||||
if today is None:
|
||||
today = date.today()
|
||||
cutoff = today - timedelta(days=WINDOW_DAYS)
|
||||
|
||||
# Schritt 1: Datenbasis.
|
||||
txs = session.execute(
|
||||
select(Transaction).where(Transaction.status == "confirmed")
|
||||
select(Transaction)
|
||||
.where(Transaction.status == "confirmed", Transaction.booking_date >= cutoff)
|
||||
).scalars().all()
|
||||
groups: dict[tuple, list[Transaction]] = defaultdict(list)
|
||||
for t in txs:
|
||||
groups[(t.account_id, t.counterparty, t.amount)].append(t)
|
||||
|
||||
existing = {(r.name, Decimal(r.amount))
|
||||
for r in session.execute(select(RecurringItem)).scalars()}
|
||||
# Schritt 2: Gruppierung je (Konto, normalisierter Empfaenger).
|
||||
groups: dict[tuple[int, str], list[Transaction]] = {}
|
||||
for t in txs:
|
||||
groups.setdefault((t.account_id, _norm(t.counterparty)), []).append(t)
|
||||
|
||||
# Schritt 3+4: je Gruppe Betrags-Cluster bilden und Rhythmus klassifizieren.
|
||||
series_by_account: dict[int, list[_Series]] = {}
|
||||
for (account_id, _name_norm), items in groups.items():
|
||||
items_sorted = sorted(items, key=lambda t: t.booking_date)
|
||||
clusters = _amount_clusters(items_sorted)
|
||||
classified = [(cluster, _classify([t.booking_date for t in cluster]))
|
||||
for cluster in clusters]
|
||||
# Fuer den Volatilitaets-Check zaehlt eine neuere Buchung nur dann als
|
||||
# "abgetrennt", wenn sie NICHT bereits zu einem ANDEREN qualifizierten
|
||||
# (klassifizierten) Cluster derselben Gruppe gehoert - sonst waeren
|
||||
# zwei parallele, stabile Vertraege (jeder fuer sich eine gueltige
|
||||
# eigene Serie) faelschlich als "volatil" markiert, nur weil der
|
||||
# jeweils andere Vertrag zufaellig spaeter im Monat faellig ist
|
||||
# (Nachtrag 3b, Fable-Gate-Korrektur nach dem ersten Live-Gate-Fund).
|
||||
qualified_items = {t for cluster, rhythm in classified if rhythm is not None
|
||||
for t in cluster}
|
||||
for cluster, rhythm in classified:
|
||||
if rhythm is None:
|
||||
continue
|
||||
# Volatilitaets-Hinweis: hat der Cluster-Split innerhalb DIESER
|
||||
# Empfaenger-Gruppe (gleiches Konto, gleiches Vorzeichen) eine
|
||||
# NEUERE Buchung in einen UNQUALIFIZIERTEN Cluster abgetrennt
|
||||
# (z.B. eine einzelne Ausreisser-Buchung, die allein keine Serie
|
||||
# bildet), ist der hier vorgeschlagene (letzte) Betrag ggf. schon
|
||||
# veraltet.
|
||||
cluster_sign = cluster[-1].amount > 0
|
||||
volatile = any(
|
||||
(t.amount > 0) == cluster_sign
|
||||
and t.booking_date > cluster[-1].booking_date
|
||||
and t not in qualified_items
|
||||
for t in items_sorted
|
||||
)
|
||||
series_by_account.setdefault(account_id, []).append(
|
||||
_Series(items=cluster, rhythm=rhythm, volatile=volatile))
|
||||
|
||||
existing = list(session.execute(select(RecurringItem)).scalars())
|
||||
|
||||
suggestions: list[dict] = []
|
||||
for (_account_id, counterparty, amount), items in groups.items():
|
||||
months = sorted({(t.booking_date.year, t.booking_date.month) for t in items})
|
||||
if _max_consecutive_months(months) < 3:
|
||||
continue
|
||||
name = counterparty
|
||||
if (name, Decimal(amount)) in existing:
|
||||
continue
|
||||
due_day = int(statistics.median(sorted(t.booking_date.day for t in items)))
|
||||
cat_counts = Counter(t.category_id for t in items if t.category_id is not None)
|
||||
category_id = cat_counts.most_common(1)[0][0] if cat_counts else None
|
||||
suggestions.append({
|
||||
"name": name,
|
||||
"amount": Decimal(amount),
|
||||
"rhythm": "monthly",
|
||||
"due_day": due_day,
|
||||
"category_id": category_id,
|
||||
})
|
||||
for series_list in series_by_account.values():
|
||||
# Schritt 6: Umfirmierungs-Merge ueber Gruppengrenzen, je Konto.
|
||||
for s in _try_merge(series_list):
|
||||
# Schritt 5: Aktiv-Check auf der (ggf. gemergten) finalen Serie.
|
||||
step = STEP_DAYS[s.rhythm]
|
||||
delta_tage = (today - s.last.booking_date).days
|
||||
if delta_tage * ACTIVITY_FACTOR_DEN > step * ACTIVITY_FACTOR_NUM:
|
||||
continue
|
||||
|
||||
# Schritt 7: Vorschlagswerte aus der neuesten Buchung.
|
||||
last = s.last
|
||||
name = last.counterparty
|
||||
amount = Decimal(last.amount)
|
||||
due_day = last.booking_date.day
|
||||
start_date = last.booking_date if s.rhythm in ("quarterly", "yearly") else None
|
||||
cat_counts = Counter(t.category_id for t in s.items if t.category_id is not None)
|
||||
category_id = cat_counts.most_common(1)[0][0] if cat_counts else None
|
||||
|
||||
hinweis = ""
|
||||
if len(s.items) >= 2:
|
||||
previous = Decimal(s.items[-2].amount)
|
||||
# "Gestiegen" bezieht sich auf den Betragswert (Ausgaben sind
|
||||
# negativ: gestiegen heisst betragsmaessig groesser, also
|
||||
# abs(neu) > abs(alt)), nicht auf das Vorzeichen.
|
||||
if abs(amount) > abs(previous):
|
||||
hinweis = f"Betrag zuletzt gestiegen (vorher {eur(abs(previous))} €)"
|
||||
|
||||
if s.volatile:
|
||||
hinweis = f"{hinweis} {VOLATILITAETS_HINWEIS}".strip()
|
||||
|
||||
# Schritt 8: Bestandsabgleich.
|
||||
if _covered_by_existing(name, amount, s.rhythm, due_day, existing):
|
||||
continue
|
||||
|
||||
suggestions.append({
|
||||
"name": name,
|
||||
"amount": amount,
|
||||
"rhythm": s.rhythm,
|
||||
"due_day": due_day,
|
||||
"start_date": start_date,
|
||||
"category_id": category_id,
|
||||
"hinweis": hinweis,
|
||||
})
|
||||
return suggestions
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<a href="/szenarien">Szenarien</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>
|
||||
<a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit">Logout</button>
|
||||
</form>
|
||||
|
||||
@@ -77,8 +77,8 @@
|
||||
</table>
|
||||
|
||||
<h2>Grafana-Dashboard</h2>
|
||||
<iframe class="grafana" src="http://{{ request.url.hostname or '127.0.0.1' }}:8097/d/finanzen/finanzen?orgId=1&kiosk"></iframe>
|
||||
<iframe class="grafana" src="{{ grafana_public_base(request) }}/d/finanzen/finanzen?orgId=1&kiosk"></iframe>
|
||||
<p class="hint">Kein Diagramm sichtbar? Einmal in
|
||||
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank">Grafana anmelden</a>
|
||||
<a href="{{ grafana_public_base(request) }}" target="_blank">Grafana anmelden</a>
|
||||
(gleiches Passwort wie hier).</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -88,15 +88,19 @@
|
||||
{% if suggestions %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th></th></tr>
|
||||
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th>Start</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in suggestions %}
|
||||
<tr>
|
||||
<td>{{ s.name }}</td>
|
||||
<td class="{{ 'neg' if s.amount < 0 else '' }}">{{ s.amount|eur }} €</td>
|
||||
<td class="{{ 'neg' if s.amount < 0 else '' }}">
|
||||
{{ s.amount|eur }} €
|
||||
{% if s.hinweis %}<span class="muted">{{ s.hinweis }}</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ s.rhythm|de_label }}</td>
|
||||
<td>{{ s.due_day }}</td>
|
||||
<td>{{ s.start_date.strftime('%d.%m.%Y') if s.start_date else '–' }}</td>
|
||||
<td>
|
||||
<form class="inline-form" hx-ext="json-form" hx-post="/api/recurring" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
@@ -105,6 +109,7 @@
|
||||
<input type="hidden" name="rhythm" value="{{ s.rhythm }}">
|
||||
<input type="hidden" name="due_day" data-type="int" value="{{ s.due_day }}">
|
||||
<input type="hidden" name="category_id" data-type="int" value="{{ s.category_id if s.category_id is not none else '' }}">
|
||||
<input type="hidden" name="start_date" value="{{ s.start_date.isoformat() if s.start_date else '' }}">
|
||||
<button type="submit">Vorschlag übernehmen</button>
|
||||
</form>
|
||||
</td>
|
||||
@@ -113,8 +118,9 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Keine Vorschläge — erkannt werden Serien aus mindestens 3 Monaten gleichartiger Buchungen.</p>
|
||||
<p class="muted">Keine Vorschläge.</p>
|
||||
{% endif %}
|
||||
<p class="muted">Erkannt werden monatliche, vierteljährliche und jährliche Serien; Betrag = jeweils letzte Buchung.</p>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
Tiefpunkt: {{ row.result.low_point_balance|eur }} € am {{ row.result.low_point_date.strftime('%d.%m.%Y') }}<br>
|
||||
{% if row.result.below_zero_date %}Unterschreitet 0 € ab {{ row.result.below_zero_date.strftime('%d.%m.%Y') }}<br>{% endif %}
|
||||
{% if row.result.below_threshold_date %}Unterschreitet Warnschwelle ab {{ row.result.below_threshold_date.strftime('%d.%m.%Y') }}<br>{% endif %}
|
||||
Kurven in <a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a> ansehen.
|
||||
Kurven in <a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a> ansehen.
|
||||
</p>
|
||||
{% else %}
|
||||
<p>Noch nicht durchgerechnet.</p>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
alembic upgrade head
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
# --proxy-headers + --forwarded-allow-ips='*': hinter dem Reverse Proxy
|
||||
# (Traefik→Apache/TLS) wertet uvicorn X-Forwarded-Proto/-Host aus, damit
|
||||
# request.url das öffentliche https/Host statt des pod-internen http sieht.
|
||||
# '*' ist vertretbar, weil der Pod nur an 127.0.0.1 gebunden ist und nur der
|
||||
# lokale Traefik ihn erreicht (siehe docs/reverse-proxy.md). Im Direktbetrieb
|
||||
# ohne Proxy sendet niemand X-Forwarded-*, also bleibt das Verhalten gleich.
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 \
|
||||
--proxy-headers --forwarded-allow-ips='*'
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"tags": ["finanzen"],
|
||||
"time": {
|
||||
"from": "now-1y",
|
||||
"to": "now"
|
||||
"to": "now+19M"
|
||||
},
|
||||
"refresh": "",
|
||||
"panels": [
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
def test_api_requires_key(client):
|
||||
assert client.get("/api/accounts").status_code == 401
|
||||
r = client.get("/api/accounts", headers={"Authorization": "Bearer test-key"})
|
||||
@@ -60,3 +63,21 @@ def test_current_password_hash_cache_invalidates_on_file_change(
|
||||
second = current_password_hash()
|
||||
assert second == new_hash
|
||||
assert verify_password("andereswort999", second)
|
||||
|
||||
|
||||
def test_login_cookie_secure_when_enabled(client, monkeypatch):
|
||||
monkeypatch.setenv("FB_SESSION_COOKIE_SECURE", "true")
|
||||
get_settings.cache_clear()
|
||||
r = client.post("/login", data={"username": "admin", "password": "geheim"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert "secure" in r.headers["set-cookie"].lower()
|
||||
|
||||
|
||||
def test_login_cookie_not_secure_by_default(client, monkeypatch):
|
||||
monkeypatch.delenv("FB_SESSION_COOKIE_SECURE", raising=False)
|
||||
get_settings.cache_clear()
|
||||
r = client.post("/login", data={"username": "admin", "password": "geheim"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert "secure" not in r.headers["set-cookie"].lower()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
@@ -35,3 +37,43 @@ def test_grafana_url_default(monkeypatch):
|
||||
assert get_settings().grafana_url == "http://localhost:3000"
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_grafana_public_url_default(monkeypatch):
|
||||
monkeypatch.delenv("FB_GRAFANA_PUBLIC_URL", raising=False)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert get_settings().grafana_public_url == ""
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_grafana_public_url_from_env(monkeypatch):
|
||||
monkeypatch.setenv("FB_GRAFANA_PUBLIC_URL", "https://fb.example.de/grafana/")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert get_settings().grafana_public_url == "https://fb.example.de/grafana/"
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_session_cookie_secure_default_false(monkeypatch):
|
||||
monkeypatch.delenv("FB_SESSION_COOKIE_SECURE", raising=False)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert get_settings().session_cookie_secure is False
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("val,expected", [
|
||||
("true", True), ("True", True), ("1", True), ("yes", True), ("on", True),
|
||||
("false", False), ("0", False), ("", False), ("nope", False),
|
||||
])
|
||||
def test_session_cookie_secure_parsing(monkeypatch, val, expected):
|
||||
monkeypatch.setenv("FB_SESSION_COOKIE_SECURE", val)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert get_settings().session_cookie_secure is expected
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
9
finance/tests/test_entrypoint.py
Normal file
9
finance/tests/test_entrypoint.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from pathlib import Path
|
||||
|
||||
ENTRYPOINT = Path(__file__).resolve().parent.parent / "entrypoint.sh"
|
||||
|
||||
|
||||
def test_entrypoint_enables_proxy_headers():
|
||||
text = ENTRYPOINT.read_text()
|
||||
assert "--proxy-headers" in text
|
||||
assert "--forwarded-allow-ips" in text
|
||||
51
finance/tests/test_grafana_dashboard.py
Normal file
51
finance/tests/test_grafana_dashboard.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Regressionstests fuer das provisionierte Grafana-Dashboard finanzen.json.
|
||||
|
||||
Hintergrund: Kein Panel nutzt $__timeFilter, jedes liefert alle Zeilen und
|
||||
Grafana beschneidet auf das Dashboard-Zeitfenster. Das Szenario-Vergleich-Panel
|
||||
zeichnet die Projektion (Zukunft, horizon_days ab heute). Steht 'to' auf 'now',
|
||||
wird die gesamte Kurve ab morgen abgeschnitten (nur die ~4 Tage <= heute
|
||||
sichtbar, Y-Achse auf deren schmalen Bereich fixiert). Dieser Test haelt fest,
|
||||
dass 'to' weit genug in die Zukunft reicht.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
DASHBOARD = Path(__file__).resolve().parent.parent / "grafana" / "dashboards" / "finanzen.json"
|
||||
|
||||
_UNIT_DAYS = {"y": 365, "M": 30, "w": 7, "d": 1, "h": 1 / 24}
|
||||
|
||||
|
||||
def _relative_to_days(expr: str) -> float:
|
||||
"""Grafana-Relativausdruck ('now', 'now+19M', 'now-1y') -> vorzeichen-
|
||||
behafteter Tages-Offset relativ zu 'now' (Zukunft positiv)."""
|
||||
expr = expr.strip()
|
||||
if expr == "now":
|
||||
return 0.0
|
||||
m = re.fullmatch(r"now([+-])(\d+)([yMwdh])", expr)
|
||||
if not m:
|
||||
raise ValueError(f"Unerwarteter Grafana-Zeitausdruck: {expr!r}")
|
||||
sign = 1 if m.group(1) == "+" else -1
|
||||
return sign * int(m.group(2)) * _UNIT_DAYS[m.group(3)]
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
return json.loads(DASHBOARD.read_text())
|
||||
|
||||
|
||||
def test_dashboard_has_szenario_panel():
|
||||
titles = [p.get("title") for p in _load().get("panels", [])]
|
||||
assert "Szenario-Vergleich" in titles
|
||||
|
||||
|
||||
def test_time_window_covers_projection_horizon():
|
||||
d = _load()
|
||||
horizon = get_settings().horizon_days
|
||||
to_days = _relative_to_days(d["time"]["to"])
|
||||
assert to_days >= horizon, (
|
||||
f"time.to reicht nur {to_days} Tage in die Zukunft, deckt aber den "
|
||||
f"Projektionshorizont von {horizon} Tagen nicht ab -> Projektionskurve "
|
||||
f"wird abgeschnitten"
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from app.config import get_settings
|
||||
from app.formats import eur
|
||||
from app.models.tables import (Account, Loan, PlannedItem, RecurringItem,
|
||||
Scenario, ScenarioModifier, ScenarioPlannedItem)
|
||||
@@ -246,6 +247,33 @@ def test_planning_page_shows_empty_suggestions_hint(client):
|
||||
assert r.status_code == 200
|
||||
assert "Vorschläge aus Buchungen" in r.text
|
||||
assert "Keine Vorschläge" in r.text
|
||||
assert ("Erkannt werden monatliche, vierteljährliche und jährliche "
|
||||
"Serien; Betrag = jeweils letzte Buchung.") in r.text
|
||||
|
||||
|
||||
def test_vorschlaege_zeigen_rhythmus_und_start(client, db):
|
||||
# Synthetische vierteljaehrliche Serie relativ zu date.today(), da die
|
||||
# Route suggest_recurring() ohne today-Injektion aufruft (echter
|
||||
# Aktiv-Check gegen date.today()). Schrittweite ~91 Tage rueckwaerts,
|
||||
# letzte Buchung 30 Tage vor heute (innerhalb des Aktiv-Fensters).
|
||||
from app.models.tables import Transaction
|
||||
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
acc = Account(bank="dkb", iban="DE-SUG-1", name="S", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
today = date.today()
|
||||
booking_dates = [today - timedelta(days=d) for d in (303, 212, 121, 30)]
|
||||
for d in booking_dates:
|
||||
db.add(Transaction(account_id=acc.id, booking_date=d, amount=Decimal("-55.08"),
|
||||
purpose="p", counterparty="Rundfunk Synth", status="confirmed",
|
||||
dedup_hash=f"sug-{d.isoformat()}"))
|
||||
db.commit()
|
||||
r = client.get("/planung").text
|
||||
letzte_buchung = booking_dates[-1]
|
||||
assert "vierteljährlich" in r
|
||||
assert letzte_buchung.strftime("%d.%m.%Y") in r
|
||||
assert 'name="start_date"' in r
|
||||
|
||||
|
||||
def test_salden_page_stichtag_and_month_overview(client, db):
|
||||
@@ -426,3 +454,32 @@ def test_neuer_eintrag_formular_struktur(client, db):
|
||||
assert 'class="value-label"' in r # dynamisches Wert-Label
|
||||
assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern
|
||||
assert '<hr' in r # Durchrechnen abgesetzt
|
||||
|
||||
|
||||
def test_grafana_links_use_public_url_when_set(client, monkeypatch):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
monkeypatch.setenv("FB_GRAFANA_PUBLIC_URL", "https://fb.example.de/grafana/")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
# iframe + Anmelde-Link nutzen die öffentliche URL, kein :8097 mehr.
|
||||
assert "https://fb.example.de/grafana/d/finanzen/finanzen" in r.text
|
||||
assert ":8097" not in r.text
|
||||
# Nav-Link im base-Template ebenfalls.
|
||||
assert 'href="https://fb.example.de/grafana"' in r.text
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_grafana_links_fallback_to_host_port_when_unset(client, monkeypatch):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
monkeypatch.delenv("FB_GRAFANA_PUBLIC_URL", raising=False)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert ":8097/d/finanzen/finanzen" in r.text
|
||||
assert "fb.example.de" not in r.text
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from itertools import count
|
||||
|
||||
from app.models.tables import Account, Category, RecurringItem, Transaction
|
||||
from app.services.suggestions import suggest_recurring
|
||||
from app.services.suggestions import _Series, _try_merge, suggest_recurring
|
||||
|
||||
# Feste Vergleichs-"heute" fuer alle Vorschlags-Tests (Ausbaustufe 9): macht
|
||||
# den Aktiv-Check deterministisch, ohne echtes date.today() im Testlauf.
|
||||
TODAY = date(2026, 7, 20)
|
||||
|
||||
H = {"Authorization": "Bearer test-key"}
|
||||
|
||||
@@ -72,42 +77,60 @@ def test_loan_in_scenario_keeps_balance_positive(client, db):
|
||||
assert Decimal(body["low_point_balance"]) > Decimal("0")
|
||||
|
||||
|
||||
def _tx(acc, d, amount, counterparty, dedup, category_id=None):
|
||||
return Transaction(account_id=acc.id, booking_date=d, amount=Decimal(amount),
|
||||
purpose="", counterparty=counterparty, status="confirmed",
|
||||
dedup_hash=dedup, category_id=category_id)
|
||||
_iban_seq = count(1)
|
||||
|
||||
|
||||
def _acc(db) -> int:
|
||||
"""Legt ein Konto an und gibt dessen id zurueck. IBAN ist je Aufruf
|
||||
eindeutig (falls ein Test mehrere Konten braucht), Praefix "DE" plus
|
||||
laufende Nummer reicht dafuer aus."""
|
||||
acc = Account(bank="dkb", iban=f"DE{next(_iban_seq):032d}"[:34], name="G", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
return acc.id
|
||||
|
||||
|
||||
def _tx(db, acc_id, d, amount, cp, cat=None):
|
||||
"""Legt eine bestaetigte Buchung an. dedup_hash aus den Nutzdaten
|
||||
abgeleitet reicht fuer Testzwecke (muss nur innerhalb eines Tests
|
||||
eindeutig sein)."""
|
||||
db.add(Transaction(account_id=acc_id, booking_date=d, amount=Decimal(amount),
|
||||
purpose="p", counterparty=cp, category_id=cat,
|
||||
status="confirmed", dedup_hash=f"h-{cp}-{d}-{amount}"))
|
||||
|
||||
|
||||
def test_suggest_recurring_three_consecutive_months_with_year_wrap(db):
|
||||
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
acc = _acc(db)
|
||||
cat = Category(name="Miete")
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
# Dez 2025 -> Jan 2026 -> Feb 2026: 3 aufeinanderfolgende Monate ueber den
|
||||
# Jahreswechsel hinweg (prueft die Monats-Linearisierung y*12+m).
|
||||
db.add(_tx(acc, date(2025, 12, 1), "-600.00", "Vermieter", "h1", cat.id))
|
||||
db.add(_tx(acc, date(2026, 1, 15), "-600.00", "Vermieter", "h2", cat.id))
|
||||
db.add(_tx(acc, date(2026, 2, 28), "-600.00", "Vermieter", "h3", cat.id))
|
||||
# Jahreswechsel hinweg, gleichmaessiger 31-Tage-Abstand (Median im
|
||||
# monatlichen Fenster 25-36 Tage; die alte Jahreswechsel-Pruefung galt der
|
||||
# Monats-Linearisierung, die es im neuen Tage-basierten Median-Ansatz
|
||||
# nicht mehr braucht).
|
||||
_tx(db, acc, date(2025, 12, 1), "-600.00", "Vermieter", cat.id)
|
||||
_tx(db, acc, date(2026, 1, 1), "-600.00", "Vermieter", cat.id)
|
||||
_tx(db, acc, date(2026, 2, 1), "-600.00", "Vermieter", cat.id)
|
||||
db.commit()
|
||||
|
||||
out = suggest_recurring(db)
|
||||
out = suggest_recurring(db, today=date(2026, 2, 10))
|
||||
|
||||
# Median der Tage [1, 15, 28] = 15; Betrag unveraendert uebernommen.
|
||||
# due_day = Tag der NEUESTEN Buchung (nicht mehr Median, Spec Schritt 7);
|
||||
# Betrag unveraendert, keine Preissteigerung -> hinweis leer.
|
||||
assert out == [{"name": "Vermieter", "amount": Decimal("-600.00"),
|
||||
"rhythm": "monthly", "due_day": 15, "category_id": cat.id}]
|
||||
"rhythm": "monthly", "due_day": 1, "start_date": None,
|
||||
"category_id": cat.id, "hinweis": ""}]
|
||||
|
||||
|
||||
def test_suggest_recurring_two_months_no_suggestion(db):
|
||||
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
db.add(_tx(acc, date(2026, 3, 10), "-50.00", "Zweimonatig", "h1"))
|
||||
db.add(_tx(acc, date(2026, 4, 10), "-50.00", "Zweimonatig", "h2"))
|
||||
acc = _acc(db)
|
||||
_tx(db, acc, date(2026, 3, 10), "-50.00", "Zweimonatig")
|
||||
_tx(db, acc, date(2026, 4, 10), "-50.00", "Zweimonatig")
|
||||
db.commit()
|
||||
|
||||
assert suggest_recurring(db) == []
|
||||
# Nur 2 Belege: Mindestbelege fuer monthly (>=3) nicht erreicht.
|
||||
assert suggest_recurring(db, today=date(2026, 4, 20)) == []
|
||||
|
||||
|
||||
def test_recurring_ende_vor_start_wird_abgelehnt(client):
|
||||
@@ -135,15 +158,213 @@ def test_recurring_start_ende_roundtrip_und_patch_validierung(client):
|
||||
|
||||
|
||||
def test_suggest_recurring_excludes_existing_recurring_item(db):
|
||||
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
db.add(_tx(acc, date(2026, 1, 5), "-30.00", "Streaming", "h1"))
|
||||
db.add(_tx(acc, date(2026, 2, 5), "-30.00", "Streaming", "h2"))
|
||||
db.add(_tx(acc, date(2026, 3, 5), "-30.00", "Streaming", "h3"))
|
||||
acc = _acc(db)
|
||||
_tx(db, acc, date(2026, 1, 5), "-30.00", "Streaming")
|
||||
_tx(db, acc, date(2026, 2, 5), "-30.00", "Streaming")
|
||||
_tx(db, acc, date(2026, 3, 5), "-30.00", "Streaming")
|
||||
db.add(RecurringItem(name="Streaming", amount=Decimal("-30.00"),
|
||||
rhythm="monthly", due_day=5))
|
||||
db.commit()
|
||||
|
||||
# Gleicher Name + Betrag wie ein bereits vorhandenes RecurringItem -> ausgelassen.
|
||||
assert suggest_recurring(db) == []
|
||||
# Gleicher Name wie ein bereits vorhandenes RecurringItem -> Bestandsabgleich
|
||||
# (Schritt 8, Namens-Match) greift, unabhaengig vom Betrag.
|
||||
assert suggest_recurring(db, today=date(2026, 3, 20)) == []
|
||||
|
||||
|
||||
# --------------------------------------------------- Ausbaustufe 9: Algorithmus v2
|
||||
# Synthetische Faelle aus der Spec (siehe
|
||||
# docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md).
|
||||
|
||||
def test_suggest_letzter_betrag_bei_preiserhoehung(db):
|
||||
acc = _acc(db)
|
||||
for d, a in [(date(2026, 3, 1), "-190.65"), (date(2026, 4, 1), "-202.94"),
|
||||
(date(2026, 5, 4), "-202.94"), (date(2026, 6, 1), "-202.94"),
|
||||
(date(2026, 7, 1), "-202.94")]:
|
||||
_tx(db, acc, d, a, "Entis Lebensversicherung AG")
|
||||
db.commit()
|
||||
out = suggest_recurring(db, today=TODAY)
|
||||
assert len(out) == 1
|
||||
s = out[0]
|
||||
assert s["amount"] == Decimal("-202.94") and s["rhythm"] == "monthly"
|
||||
assert s["due_day"] == 1 and s["start_date"] is None
|
||||
|
||||
|
||||
def test_suggest_quartal_mit_phase(db):
|
||||
acc = _acc(db)
|
||||
for d in [date(2025, 9, 15), date(2025, 12, 15), date(2026, 3, 16), date(2026, 6, 15)]:
|
||||
_tx(db, acc, d, "-55.08", "Rundfunk ARD ZDF")
|
||||
db.commit()
|
||||
out = suggest_recurring(db, today=TODAY)
|
||||
assert len(out) == 1
|
||||
assert out[0]["rhythm"] == "quarterly"
|
||||
assert out[0]["start_date"] == date(2026, 6, 15) and out[0]["due_day"] == 15
|
||||
|
||||
|
||||
def test_suggest_jahr_mit_zwei_belegen(db):
|
||||
acc = _acc(db)
|
||||
for d, a in [(date(2025, 6, 16), "-409.92"), (date(2026, 6, 16), "-467.33")]:
|
||||
_tx(db, acc, d, a, "Kraftfahrer-Schutz e.V.")
|
||||
db.commit()
|
||||
out = suggest_recurring(db, today=TODAY)
|
||||
assert len(out) == 1
|
||||
assert out[0]["rhythm"] == "yearly" and out[0]["amount"] == Decimal("-467.33")
|
||||
assert out[0]["start_date"] == date(2026, 6, 16)
|
||||
assert "409,92" in out[0]["hinweis"] # Betrag zuletzt gestiegen (deutsches Format)
|
||||
|
||||
|
||||
def test_suggest_tote_serie_kein_vorschlag(db):
|
||||
acc = _acc(db)
|
||||
for m in (9, 10, 11, 12):
|
||||
_tx(db, acc, date(2025, m, 1), "-35.00", "WWK Alt")
|
||||
db.commit()
|
||||
assert suggest_recurring(db, today=TODAY) == []
|
||||
|
||||
|
||||
def test_suggest_umfirmierung_merge(db):
|
||||
acc = _acc(db)
|
||||
for m in (11, 12):
|
||||
_tx(db, acc, date(2025, m, 1), "-190.65", "Heidelberger Leben")
|
||||
for m in (1, 2, 3):
|
||||
_tx(db, acc, date(2026, m, 2), "-190.65", "Entis Lebensversicherung")
|
||||
for m in (4, 5, 6, 7):
|
||||
_tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung")
|
||||
db.commit()
|
||||
out = suggest_recurring(db, today=TODAY)
|
||||
assert len(out) == 1
|
||||
assert "Entis" in out[0]["name"] and out[0]["amount"] == Decimal("-202.94")
|
||||
|
||||
|
||||
def test_suggest_bestandsabgleich_trotz_preisdrift(db):
|
||||
acc = _acc(db)
|
||||
db.add(RecurringItem(name="Entis Lebensversicherung AG", amount=Decimal("-190.65"),
|
||||
rhythm="monthly", due_day=1))
|
||||
for m in (4, 5, 6, 7):
|
||||
_tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung AG")
|
||||
db.commit()
|
||||
assert suggest_recurring(db, today=TODAY) == [] # Namens-Match schlaegt an
|
||||
|
||||
|
||||
def test_suggest_zwei_vertraege_getrennt(db):
|
||||
acc = _acc(db)
|
||||
for m in (4, 5, 6, 7):
|
||||
_tx(db, acc, date(2026, m, 1), "-346.23", "Heidelberger LV")
|
||||
_tx(db, acc, date(2026, m, 2), "-145.21", "Heidelberger LV")
|
||||
db.commit()
|
||||
out = suggest_recurring(db, today=TODAY)
|
||||
assert len(out) == 2
|
||||
assert {s["amount"] for s in out} == {Decimal("-346.23"), Decimal("-145.21")}
|
||||
# Nachtrag 3b: beide Vertraege sind fuer sich genommen stabile,
|
||||
# qualifizierte Serien - die jeweils neuere Buchung des ANDEREN Vertrags
|
||||
# gehoert selbst zu einer qualifizierten Serie und darf deshalb NICHT als
|
||||
# "abgetrennte neueste Buchung" gewertet werden (sonst waere einer der
|
||||
# beiden faelschlich als "volatil" markiert, nur weil der andere Vertrag
|
||||
# einen Tag spaeter faellig ist).
|
||||
assert all(s["hinweis"] == "" for s in out)
|
||||
|
||||
|
||||
def test_try_merge_kombiniert_serien_ueber_gruppengrenzen():
|
||||
# Direkter, isolierter Test der Merge-Mechanik (Schritt 6): siehe Report
|
||||
# fuer den rechnerischen Nachweis, dass ein End-to-End-Szenario, in dem
|
||||
# die ALTE und die NEUE Serie GLEICHZEITIG unabhaengig voneinander den
|
||||
# Aktiv-Check bestehen, fuer keinen der drei Rhythmen innerhalb von
|
||||
# WINDOW_DAYS=460 konstruierbar ist (die alte Serie ist beim Aktiv-Check
|
||||
# immer laengst "tot", sobald die neue genug eigene Belege hat, bzw. bei
|
||||
# yearly passt die noetige Gesamtspanne nicht ins Fenster). Deshalb hier
|
||||
# `_try_merge` direkt gegen zwei synthetische `_Series` geprueft, ganz ohne
|
||||
# DB/Fenster/Aktiv-Check-Interaktion.
|
||||
a = _Series(items=[
|
||||
Transaction(booking_date=date(2026, 1, 3), amount=Decimal("-50.00"),
|
||||
counterparty="Alte Firma GmbH", category_id=None),
|
||||
Transaction(booking_date=date(2026, 2, 3), amount=Decimal("-50.00"),
|
||||
counterparty="Alte Firma GmbH", category_id=None),
|
||||
Transaction(booking_date=date(2026, 3, 3), amount=Decimal("-50.00"),
|
||||
counterparty="Alte Firma GmbH", category_id=None),
|
||||
], rhythm="monthly")
|
||||
b = _Series(items=[
|
||||
Transaction(booking_date=date(2026, 4, 5), amount=Decimal("-52.00"),
|
||||
counterparty="Neue Firma GmbH", category_id=None),
|
||||
Transaction(booking_date=date(2026, 5, 5), amount=Decimal("-52.00"),
|
||||
counterparty="Neue Firma GmbH", category_id=None),
|
||||
Transaction(booking_date=date(2026, 6, 5), amount=Decimal("-52.00"),
|
||||
counterparty="Neue Firma GmbH", category_id=None),
|
||||
], rhythm="monthly")
|
||||
|
||||
merged = _try_merge([a, b])
|
||||
|
||||
# Luecke A-Ende->B-Anfang = 33 Tage (in [12,48]), Faelligkeitstag 3 vs 5
|
||||
# (Differenz 2 <= 3), Betrag +4% (<=25%) -> Bedingungen erfuellt, genau
|
||||
# EINE kombinierte Serie mit allen 6 Buchungen, juengste zuerst.
|
||||
assert len(merged) == 1
|
||||
assert len(merged[0].items) == 6
|
||||
assert merged[0].last.counterparty == "Neue Firma GmbH"
|
||||
|
||||
|
||||
def test_suggest_umfirmierung_merge_verschiebt_kategorie_mehrheit(db):
|
||||
# End-to-End-Nachweis, dass Schritt 6 tatsaechlich in `suggest_recurring`
|
||||
# verdrahtet ist: da die Anzahl der Vorschlaege sich (bewiesenermassen,
|
||||
# siehe Report) end-to-end NICHT als Diskriminator eignet (die alte Serie
|
||||
# faellt so oder so per Aktiv-Check heraus), wird hier die
|
||||
# Kategorie-Mehrheit als Diskriminator genutzt - die haengt direkt davon
|
||||
# ab, ob die Buchungen der alten Serie ueber den Merge in die Zaehlung
|
||||
# eingehen. Alte Serie: 4 Buchungen Kategorie A. Neue Serie: 3 Buchungen
|
||||
# Kategorie B. Ohne Merge zaehlen nur die 3 B-Buchungen (Mehrheit B). Mit
|
||||
# Merge kommen die 4 A-Buchungen dazu und kippen die Mehrheit auf A.
|
||||
acc = _acc(db)
|
||||
cat_a = Category(name="Alt-Kategorie")
|
||||
cat_b = Category(name="Neu-Kategorie")
|
||||
db.add(cat_a)
|
||||
db.add(cat_b)
|
||||
db.flush()
|
||||
for d in [date(2026, 1, 3), date(2026, 2, 3), date(2026, 3, 3), date(2026, 4, 3)]:
|
||||
_tx(db, acc, d, "-50.00", "Alte Firma GmbH", cat_a.id)
|
||||
for d in [date(2026, 5, 5), date(2026, 6, 5), date(2026, 7, 5)]:
|
||||
_tx(db, acc, d, "-52.00", "Neue Firma GmbH", cat_b.id)
|
||||
db.commit()
|
||||
|
||||
out = suggest_recurring(db, today=date(2026, 7, 20))
|
||||
|
||||
assert len(out) == 1
|
||||
assert out[0]["name"] == "Neue Firma GmbH" and out[0]["amount"] == Decimal("-52.00")
|
||||
# Kategorie-Mehrheit kippt durch den Merge von B (3) auf A (4):
|
||||
assert out[0]["category_id"] == cat_a.id
|
||||
|
||||
|
||||
# ------------------------------------------------- Nachtrag 4 (Live-Gate-Fund)
|
||||
# Live-Gate-Fund (Muster, keine echten Kontodaten - Namen/Betraege hier rein
|
||||
# synthetisch): ein kuratiertes Sammel-Fixposten unter Alias-Namen des
|
||||
# Anbieters ("Kreditkarten-Abrechnung ... (variabel)") deckte den vom
|
||||
# Algorithmus erkannten Vorschlag desselben Anbieters unter dessen
|
||||
# regulaerem Empfaenger-Namen nicht ab, weil weder Substring- noch
|
||||
# Betrags-Toleranz-Regel griffen.
|
||||
|
||||
def test_suggest_alias_recurring_item_token_match(db):
|
||||
acc = _acc(db)
|
||||
db.add(RecurringItem(name="Kreditkarten-Abrechnung Musterbank (variabel)",
|
||||
amount=Decimal("-250.00"), rhythm="monthly", due_day=7))
|
||||
for d, a in [(date(2026, 4, 5), "-560.00"), (date(2026, 5, 5), "-575.00"),
|
||||
(date(2026, 6, 5), "-590.00"), (date(2026, 7, 5), "-575.00")]:
|
||||
_tx(db, acc, d, a, "Musterbank Neustadt eG")
|
||||
db.commit()
|
||||
|
||||
# Substring-Match (a) schlaegt fehl (kein Teilstring gemeinsam), Betrags-
|
||||
# Toleranz (b) auch (-575 vs. -250.00, >10%) - erst der Token-Match (c)
|
||||
# ueber das gemeinsame Token "musterbank" (Rhythmus gleich, due_day 5 vs. 7
|
||||
# -> Differenz 2 <= 2) deckt den Vorschlag ab.
|
||||
assert suggest_recurring(db, today=TODAY) == []
|
||||
|
||||
|
||||
def test_suggest_volatilitaetshinweis_bei_abgetrennter_neuester_buchung(db):
|
||||
acc = _acc(db)
|
||||
for d in [date(2026, 1, 5), date(2026, 2, 5), date(2026, 3, 5), date(2026, 4, 5)]:
|
||||
_tx(db, acc, d, "-200.00", "Schwankender Anbieter GmbH")
|
||||
# Neueste Buchung weicht >35% vom Serien-Betrag ab -> eigener Cluster,
|
||||
# klassifiziert selbst nicht (nur 1 Buchung) -> die vorgeschlagene Serie
|
||||
# bleibt die -200.00-Serie, aber mit Volatilitaets-Warnhinweis.
|
||||
_tx(db, acc, date(2026, 5, 5), "-600.00", "Schwankender Anbieter GmbH")
|
||||
db.commit()
|
||||
|
||||
out = suggest_recurring(db, today=date(2026, 5, 20))
|
||||
|
||||
assert len(out) == 1
|
||||
assert out[0]["amount"] == Decimal("-200.00") # Betrag NICHT durch die 600er-Buchung verfaelscht
|
||||
assert "schwanken" in out[0]["hinweis"].lower()
|
||||
|
||||
53
stop_finance_pod.sh
Executable file
53
stop_finance_pod.sh
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Fährt den finance_pod kontrolliert herunter: stoppt den systemd-User-Service
|
||||
# (der den Pod verwaltet) und danach - falls noch vorhanden - den Pod selbst.
|
||||
# Gedacht als expliziter Wartungsschritt, z.B. vor dem Editieren der .env
|
||||
# (Reverse-Proxy-Umzug, siehe docs/reverse-proxy.md) und dem erneuten Ausführen
|
||||
# von ./create_pod_finance.sh.
|
||||
#
|
||||
# KEIN Datenverlust: Postgres-/Grafana-Daten liegen im Bind-Mount
|
||||
# ~/.local/share/finance_pod und bleiben beim Stoppen erhalten. Der Pod wird
|
||||
# nur GESTOPPT, nicht entfernt - das Entfernen/Neuerstellen erledigt
|
||||
# create_pod_finance.sh ohnehin bei jedem Lauf.
|
||||
|
||||
set -e
|
||||
|
||||
POD_NAME='finance_pod'
|
||||
SERVICE="pod-${POD_NAME}.service"
|
||||
|
||||
# 1. systemd-User-Service stoppen (er startet und verwaltet den Pod). Nur
|
||||
# stoppen, nicht disablen - create_pod_finance.sh macht am Ende wieder
|
||||
# `enable --now`. `stop` allein hält den Service bis dahin unten.
|
||||
if systemctl --user list-units --type=service --all 2>/dev/null | \
|
||||
grep -q "$SERVICE"; then
|
||||
echo "Stoppe systemd-User-Service $SERVICE ..."
|
||||
systemctl --user stop "$SERVICE" || true
|
||||
else
|
||||
echo "Service $SERVICE ist nicht registriert - nichts zu stoppen."
|
||||
fi
|
||||
|
||||
# 2. Falls der Pod noch läuft (z.B. unmanaged außerhalb systemd gestartet),
|
||||
# ebenfalls stoppen. --ignore: kein Fehler, wenn er nicht existiert.
|
||||
if podman pod exists "$POD_NAME"; then
|
||||
echo "Stoppe Pod $POD_NAME ..."
|
||||
podman pod stop --ignore --time 15 "$POD_NAME" || true
|
||||
else
|
||||
echo "Pod $POD_NAME existiert nicht."
|
||||
fi
|
||||
|
||||
# 3. Status zur Kontrolle ausgeben.
|
||||
echo
|
||||
echo "--- Status nach dem Herunterfahren ---"
|
||||
printf 'Service aktiv? '
|
||||
systemctl --user is-active "$SERVICE" || true
|
||||
echo "Pod/Container:"
|
||||
podman pod ps --filter "name=$POD_NAME" || true
|
||||
|
||||
echo
|
||||
echo "Fertig heruntergefahren."
|
||||
# echo "Fertig heruntergefahren. Nächste Schritte:"
|
||||
# echo " 1. .env editieren: \$EDITOR ~/.local/share/finance_pod/.env"
|
||||
# echo " (Reverse-Proxy: FB_GRAFANA_PUBLIC_URL + FB_SESSION_COOKIE_SECURE"
|
||||
# echo " single-quoted setzen - siehe docs/reverse-proxy.md)"
|
||||
# echo " 2. Neu ausrollen: ./create_pod_finance.sh"
|
||||
Reference in New Issue
Block a user