commit 6f5e704739c7edf9aa20d445aff8de4a296a7a66 Author: Florian Heinz Date: Tue Apr 21 21:17:36 2026 +0200 release: publish saldo 0.1.0 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2207ad8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.gitignore +.DS_Store +.idea +.vscode +.pytest_cache +.mypy_cache +htmlcov +__pycache__ +*.py[cod] +.venv +venv +instance +.env +.env.local +*.db +*.sqlite +*.sqlite3 +app/static/uploads diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4f4e461 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +SECRET_KEY=set-a-long-random-secret +DATABASE_URL=sqlite:////app/data/saldo.db +SALDO_DATA_DIR=/app/data +SALDO_ADMIN_USERNAME=admin +SALDO_ADMIN_PASSWORD= +SALDO_ADMIN_EMAIL=admin@example.invalid +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:admin@example.invalid +SALDO_ALLOCATION_TARGET_RULES={"sparen":{"recommended_pct":0.18,"min_pct":0.15,"max_pct":0.20,"label":"Sparen"},"urlaub":{"recommended_pct":0.06,"min_pct":0.05,"max_pct":0.08,"label":"Urlaub"},"freizeit":{"recommended_pct":0.07,"min_pct":0.05,"max_pct":0.10,"label":"Freizeit"}} +SALDO_STRONG_INCOME_CHANGE_THRESHOLD=150 +SALDO_SESSION_COOKIE_SECURE=1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a7258b --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Python cache and virtual environments +__pycache__/ +*.py[cod] +.venv/ +venv/ + +# Test and local tooling output +.pytest_cache/ +.mypy_cache/ +.coverage +htmlcov/ + +# Local environment files +.env +.env.local + +# Local runtime data +instance/ +*.db +*.sqlite +*.sqlite3 + +# Uploaded media stored in the data directory +app/static/uploads/ + +# Editor and OS artifacts +.DS_Store +.idea/ +.vscode/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..98f1e33 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,51 @@ +# Saldo Architektur + +## Zielbild + +Saldo ist eine mobile-first Flask-PWA für monatliche Haushaltsplanung. Der Schwerpunkt liegt auf wiederkehrenden Kosten, variablen Einkommen, Restverteilung und externer Beteiligung ohne Benutzerkonto. + +## Schichten + +`app/__init__.py` +Initialisiert App-Factory, Extensions, Logging, Blueprints und CLI-Kommandos. + +`app/models.py` +Enthält das relationale Kernmodell für Monate, Kostenstruktur, Einkommen, Verteilungen, Beteiligungsregeln und Benachrichtigungen. + +`app/services/month_service.py` +Domänenlogik für automatische Monatsanlage, Seed-Fallback, Kopieren eines Monats und Berechnung zentraler Kennzahlen. + +`app/services/allocation_service.py` +Regel-Engine für Vorschlagsverteilungen. Gesperrte Zielkonten bleiben unangetastet, der restliche Betrag wird gewichtet verteilt. + +`app/services/comparison_service.py` +Vergleicht Monate und erzeugt Deltas sowie Top-Veränderungen. + +`app/services/share_service.py` +Berechnet interne und externe Anteile pro Eintrag und aggregiert externe Forderungen pro Monat. + +`app/services/notification_service.py` +Erstellt In-App-Hinweise und triggert optional Web-Push für Monatsende, fehlende Verteilung und starke Einkommensänderungen. + +## Request Flow + +1. Authentifizierter Request startet. +2. `before_request` ruft `MonthService.ensure_month()` auf. +3. Falls der aktuelle Kalendermonat fehlt, wird er automatisch aus dem jüngsten Monat erzeugt. +4. Views lesen die Monatszusammenfassung über `compute_summary()`. +5. Änderungen an Einkommen oder Planwerten triggern `refresh_suggestions()`. +6. Benutzer übernehmen Vorschläge komplett oder zielkontenweise. + +## Datenhaltung + +- Standard: SQLite +- Pfad per `DATABASE_URL` oder `SALDO_DATA_DIR` +- SQLAlchemy-Modelle sind bewusst nicht SQLite-spezifisch, damit ein Wechsel auf PostgreSQL später möglich bleibt + +## Deployment + +- WSGI-Entry: `wsgi.py` +- Produktionsserver: Gunicorn +- Healthcheck: `/health` +- Migrationen: Flask-Migrate/Alembic in `migrations/` +- Cloudron-Datenpfad: `/app/data` diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..353ef5d --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,20 @@ +# Changelog + +## 0.1.0 - 2026-04-21 + +- Initiale produktionsnahe MVP-Version von Saldo vorbereitet. +- Flask-PWA mit installierbarer Oberfläche, Manifest, Service Worker und Web-Push integriert. +- Mehrbenutzer-Login mit Rollen `admin` und `editor` umgesetzt. +- Automatische Monatsanlage mit Kopie des Vormonats und Seed-Fallback ergänzt. +- Moderne mobile-first Planung in Karten und Dialogen aufgebaut. +- Einkommen, Budgets, Einträge, Split-Personen und Gemeinschaftskonten direkt in der Planungsansicht pflegbar gemacht. +- Restverteilung für Sparen, Urlaub, Freizeit und persönliche Auszahlung automatisiert. +- Persönliche Auszahlung logisch an den Restbetrag gekoppelt. +- Externe Beteiligungen mit Monatsaggregation und klickbarer Detailansicht umgesetzt. +- Auswertungen mit Drilldown-Kuchendiagramm, Zuordnungsdiagrammen und Budget-Linienverlauf ergänzt. +- Dark Mode, Avatare und Avatar-Upload eingebaut. +- Erststart-Setup im Browser ergänzt; keine Beispielzugänge und keine Beispielpersonen mehr im echten App-Seed. +- Release-Bereinigung für öffentliche Nutzung ergänzt: neutrale Seed-Daten, Anzeigenamen, freie Einkommenszeilen, vereinfachte Planungsdialoge und automatische Basisdaten für Fresh-Install/Cloudron. +- Sicherheitsgrundlagen ergänzt: zufälliger Secret-Key-Fallback, SameSite/HttpOnly-Cookies und einfacher CSRF-Schutz für Formulare. +- Cloudron-Vorbereitung mit Manifest, Dockerfile, Startskript und Datenpfad auf `/app/data` ergänzt. +- Git-Hygiene ergänzt, damit lokale Datenbank, Uploads und Testartefakte nicht versioniert werden. diff --git a/CloudronManifest.json b/CloudronManifest.json new file mode 100644 index 0000000..6205ba8 --- /dev/null +++ b/CloudronManifest.json @@ -0,0 +1,29 @@ +{ + "id": "com.example.saldo", + "title": "Saldo", + "author": "Saldo Contributors", + "description": "file://DESCRIPTION.md", + "tagline": "Haushalt gemeinsam planen, verteilen und aktuell halten", + "version": "0.1.0", + "upstreamVersion": "0.1.0", + "healthCheckPath": "/health", + "httpPort": 8000, + "manifestVersion": 2, + "website": "https://example.invalid/saldo", + "contactEmail": "admin@example.invalid", + "documentationUrl": "https://example.invalid/saldo-docs", + "packagerName": "Saldo Contributors", + "packagerUrl": "https://example.invalid/saldo", + "changelog": "file://CHANGELOG", + "postInstallMessage": "file://POSTINSTALL.md", + "addons": { + "localstorage": {} + }, + "tags": [ + "budget", + "haushalt", + "pwa", + "finanzen", + "planung" + ] +} diff --git a/DESCRIPTION.md b/DESCRIPTION.md new file mode 100644 index 0000000..d7d9fc4 --- /dev/null +++ b/DESCRIPTION.md @@ -0,0 +1,12 @@ +Saldo ist eine installierbare Flask-PWA für Mehrbenutzer-Haushaltsplanung. + +Die App konzentriert sich auf wiederkehrende Monatsplanung statt klassische Buchhaltung: + +- variable Einkommen pro Monat +- Budgets und Unterbudgets für wiederkehrende Kosten +- automatische Restverteilung auf Spar- und persönliche Auszahlungsziele +- externe Beteiligte ohne eigenen App-Zugang +- gemeinsamer Blick auf Änderungen zum Vormonat +- mobile-first Bedienung mit Karten, Dialogen und Auswertungen + +Saldo ist für den Alltag gedacht: schnell öffnen, Zahlen anpassen, Vorschläge prüfen und Unterkonten aktuell halten. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9f0d18a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + FLASK_APP=wsgi:app \ + SALDO_DATA_DIR=/app/data + +WORKDIR /app/code + +COPY requirements.txt /app/code/requirements.txt +RUN pip install --upgrade pip && pip install -r /app/code/requirements.txt + +COPY . /app/code + +RUN chmod +x /app/code/start.sh + +EXPOSE 8000 + +CMD ["/app/code/start.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..00b5f75 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +Saldo Private Use License 1.0 + +Copyright (c) 2026 hnzio + +Die Nutzung, Ausführung, private Vervielfältigung und private Anpassung dieser +Software sind natürlichen Personen für rein private, nicht-kommerzielle Zwecke +kostenfrei gestattet. + +Erlaubt ist: + +1. Die Software privat zu nutzen. +2. Die Software für den eigenen privaten Bedarf anzupassen. +3. Sicherheitskopien für die eigene private Nutzung anzulegen. + +Nicht erlaubt ist ohne vorherige schriftliche Genehmigung des Rechteinhabers: + +1. Jede kommerzielle Nutzung. +2. Der Verkauf, die Vermietung oder Unterlizenzierung. +3. Das öffentliche Bereitstellen für Dritte, insbesondere als gehosteter Dienst. +4. Die Weiterverbreitung in veränderter oder unveränderter Form außerhalb der + eigenen privaten Nutzung. + +Diese Software wird "wie besehen" und ohne Gewähr bereitgestellt. Es besteht +keine ausdrückliche oder stillschweigende Zusicherung hinsichtlich +Funktionsfähigkeit, Eignung für einen bestimmten Zweck oder Nichtverletzung von +Rechten Dritter. + +Für separate kommerzielle oder weitergehende Nutzungsrechte ist eine +individuelle Freigabe durch den Rechteinhaber erforderlich. diff --git a/POSTINSTALL.md b/POSTINSTALL.md new file mode 100644 index 0000000..0378f66 --- /dev/null +++ b/POSTINSTALL.md @@ -0,0 +1,14 @@ +Saldo ist installiert. + +Wichtige erste Schritte: + +1. Öffne die App unter `$CLOUDRON-APP-ORIGIN`. +2. Lege beim ersten Aufruf über die Setup-Seite den ersten Admin an. +3. Hinterlege `SECRET_KEY`, VAPID-Keys und die gewünschten Admin-Zugangsdaten als App-Umgebungsvariablen. +4. Prüfe nach dem ersten Start die Grundstruktur und passe Budgets, Personen und Konten an euren Alltag an. + +Hinweise: + +- Laufzeitdaten, SQLite-Datenbank und Uploads liegen in `/app/data`. +- Beim Start führt die App automatisch Migrationen aus. +- Für periodische Erinnerungen kann `python -m flask run-reminders` als Scheduled Task genutzt werden. diff --git a/README.md b/README.md new file mode 100644 index 0000000..df7c2c7 --- /dev/null +++ b/README.md @@ -0,0 +1,231 @@ +# Saldo + +Saldo ist eine moderne Flask-PWA für Mehrbenutzer-Haushaltsplanung. Die App fokussiert sich auf Monatsplanung, Überweisungsverteilungen, variable Einkommen, Restbetragsvorschläge und externe Kostenbeteiligung, statt klassische Buchhaltung nachzubauen. + +Aktueller Projektstand: `0.1.0` + +## Features + +- automatische Monatsanlage beim ersten Öffnen eines neuen Kalendermonats +- Kopieren des Vormonats inklusive Einkommen, Planwerten, Verteilungen und Beteiligungslogik +- variable Einkommenszeilen pro Monat +- verpflichtende Anzeigenamen für Nutzer, sichtbar in Avataren, Splits und Budgetdetails +- Vorschlags-Engine für `Sparen`, `Urlaub`, `Freizeit` und `Persönliche Auszahlung` +- externe Kostenbeteiligte ohne App-Login +- PWA mit Manifest und Service Worker +- Web Push mit VAPID-Keys +- Rollenmodell mit `admin` und `editor` +- keine offene Registrierung +- Healthcheck unter `/health` + +## Stack + +- Python 3 +- Flask +- SQLAlchemy +- Flask-Login +- Flask-Migrate / Alembic +- Jinja Templates +- leichtes Vanilla JS +- Chart.js +- SQLite als Default +- Gunicorn + +## Projektstruktur + +```text +app/ + admin/ Admin-Views für Benutzer und Beteiligte + auth/ Login/Logout + main/ Übersicht, Auswertungen, Healthcheck + months/ Monatsliste, Locking, manuelles Anlegen + planning/ Monatliche Bearbeitung von Einkommen, Kosten, Verteilungen + services/ Domänenlogik für Monate, Vorschläge, Vergleiche, Shares, Push + static/ CSS, JS, Icons, Manifest, Service Worker + templates/ Jinja-Templates + models.py SQLAlchemy-Modelle + seed.py Seed-Daten +migrations/ Alembic-Migrationen +tests/ Pytest-Suite +``` + +## Lokal starten + +```bash +python -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +export FLASK_APP=wsgi:app +python -m flask db upgrade +python -m flask seed +python -m flask run --debug +``` + +Die App läuft lokal standardmäßig mit SQLite im `instance`- bzw. konfigurierten Datenverzeichnis. +Beim ersten Aufruf öffnet sich automatisch die Setup-Seite zur Anlage des ersten Admins. + +Wichtig für lokale Tests: + +- die lokale Datenbank liegt standardmäßig in `instance/saldo.db` +- Uploads und andere Laufzeitdaten liegen ebenfalls im Datenverzeichnis +- diese Dateien werden über `.gitignore` bewusst nicht versioniert +- dadurch kannst du lokal testen, später Änderungen pullen und danach einfach wieder `python -m flask db upgrade` ausführen + +## Wichtige Umgebungsvariablen + +Siehe [.env.example](/home/hnzio/Projekte/saldo/.env.example). + +- `SECRET_KEY` +- `DATABASE_URL` +- `SALDO_DATA_DIR` +- `SALDO_ADMIN_USERNAME` +- `SALDO_ADMIN_PASSWORD` +- `SALDO_ADMIN_EMAIL` +- `VAPID_PUBLIC_KEY` +- `VAPID_PRIVATE_KEY` +- `VAPID_SUBJECT` +- `SALDO_ALLOCATION_TARGET_RULES` +- `SALDO_STRONG_INCOME_CHANGE_THRESHOLD` + +## CLI-Kommandos + +```bash +python -m flask create-admin --username admin2 --display-name "Admin 2" --email admin2@example.com --password secret +python -m flask bootstrap-admin +python -m flask seed +python -m flask run-reminders +``` + +`seed` legt nur die Grundstruktur an. `bootstrap-admin` bleibt als optionaler CLI-Weg verfügbar. +`run-reminders` kann auf Cloudron als Scheduled Task oder per Cron ausgeführt werden. + +## Cloudron-Hinweise + +Vorbereitete Paketdateien: + +- `CloudronManifest.json` +- `Dockerfile` +- `start.sh` +- `DESCRIPTION.md` +- `CHANGELOG` +- `POSTINSTALL.md` + +Cloudron-Release-Stand: + +- App-Version: `0.1.0` +- Healthcheck: `/health` +- interner Port: `8000` +- persistente Daten: `/app/data` +- Build-Hygiene: `.dockerignore` schließt lokale DBs, `instance/`, Test-Caches und Editor-Dateien aus + +Empfohlene Env-Konfiguration für eine Cloudron-Subdomain wie `https://saldo.example.com`: + +```bash +SECRET_KEY= +DATABASE_URL=sqlite:////app/data/saldo.db +SALDO_DATA_DIR=/app/data +SALDO_ADMIN_USERNAME=admin +SALDO_ADMIN_PASSWORD= +SALDO_ADMIN_EMAIL=admin@example.invalid +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:admin@example.invalid +``` + +Startkommando: + +```bash +gunicorn -c gunicorn.conf.py wsgi:app +``` + +Im Container übernimmt `start.sh` vor dem Start automatisch: + +```bash +python -m flask db upgrade +python -m flask seed +``` + +Wenn du mit der Cloudron CLI paketierst oder hochlädst: + +- die lokale SQLite-Datei aus `instance/` wird nicht mitgeschickt +- lokale `.env`-Dateien werden nicht mitgeschickt +- Test-/Cache-Dateien und Editor-Metadaten bleiben ebenfalls draußen +- produktive Daten sollen weiterhin ausschließlich in `/app/data` liegen + +Cloudron-Kompatibilität in diesem Projekt: + +- persistente Daten im konfigurierbaren Datenverzeichnis +- Logging über stdout/stderr +- Healthcheck-Route `/health` +- keine Benutzer-Selbstregistrierung +- Erststart-Setup im Browser für den ersten Admin +- optionaler Admin-Bootstrap per CLI +- Reminder-Check als separater wiederverwendbarer CLI-Task +- neutrale Basisdaten werden auf Fresh-Install automatisch angelegt + +Hinweis für spätere Update-Verteilung: + +- Laut Cloudron-Publishing-Workflow wird nach dem ersten Build die Datei `CloudronVersions.json` mit `cloudron versions add` erzeugt und anschließend öffentlich bereitgestellt. +- Damit erscheinen neue Versionen später automatisch als Community-App-Updates. + +## Basisdaten + +Das Seed-Script legt an: + +- Standardkonten, Kategorien und Einträge +- Gemeinschaftskonten-Grundstruktur + +Nicht angelegt werden: + +- Beispielzugänge +- Beispielpersonen +- Demo-Monatswerte + +## Tests + +```bash +python -m pytest -q +``` + +Aktueller Stand: + +- 31 Tests grün + +## Hinweise zur Kernlogik + +`MonthService.ensure_month()`: +- legt den aktuellen Kalendermonat automatisch an +- übernimmt beim Anlegen den letzten vorhandenen Monat +- fällt beim ersten Start auf einen Seed-Standardmonat zurück + +`AllocationSuggestionService.recompute()`: +- berechnet den verteilbaren Restbetrag +- respektiert gesperrte Zielkonten +- verteilt den übrigen Betrag gewichtet +- gleicht Rundungsreste in der letzten Vorschlagszeile aus + +## PWA und Push + +- Manifest: [app/static/manifest.json](/home/hnzio/Projekte/saldo/app/static/manifest.json) +- Service Worker: [app/static/service-worker.js](/home/hnzio/Projekte/saldo/app/static/service-worker.js) +- Push-Subscription-Route: [app/planning/routes.py](/home/hnzio/Projekte/saldo/app/planning/routes.py:113) +- Reminder-Logik: [app/services/notification_service.py](/home/hnzio/Projekte/saldo/app/services/notification_service.py) + +## Git und Releases + +Repo-Vorbereitung in diesem Stand: + +- `.gitignore` verhindert, dass lokale SQLite-Datenbank, Uploads und Laufzeitdaten eingecheckt werden +- `.dockerignore` hält lokale Test- und Laufzeitdateien auch aus Cloudron-Builds heraus +- `VERSION` und `CHANGELOG` dokumentieren den aktuellen Release-Stand +- `LICENSE` erlaubt private Nutzung + +Empfohlene Release-Schritte: + +```bash +git init -b main +git remote add origin https://git.hnz.io/hnzio/saldo.git +git add . +git commit -m "chore: prepare saldo 0.1.0 for git and cloudron" +git push -u origin main +``` diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..2f93e68 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import logging +import secrets +from pathlib import Path + +from flask import Flask, g, redirect, request, session, url_for +from flask_login import current_user + +from .config import Config +from .extensions import db, login_manager, migrate +from .models import NotificationPreference, User +from .seed import seed_data +from .utils.users import sync_user_participants +from .services.allocation_service import AllocationSuggestionService +from .services.comparison_service import ComparisonService +from .services.month_service import MonthService +from .services.notification_service import NotificationService +from .services.push_service import PushService +from .services.share_service import ShareCalculationService +from .utils.formatting import currency + + +def create_app(config_class: type[Config] = Config) -> Flask: + app = Flask(__name__, instance_relative_config=True) + app.config.from_object(config_class) + app.config.setdefault("AVATAR_UPLOAD_DIR", Path(app.config["DATA_DIR"]) / "avatars") + app.config.setdefault("MAX_CONTENT_LENGTH", 5 * 1024 * 1024) + + Path(app.config["DATA_DIR"]).mkdir(parents=True, exist_ok=True) + Path(app.config["AVATAR_UPLOAD_DIR"]).mkdir(parents=True, exist_ok=True) + + db.init_app(app) + migrate.init_app(app, db) + login_manager.init_app(app) + + app.jinja_env.filters["currency"] = currency + + allocation_service = AllocationSuggestionService( + app.config["ALLOCATION_TARGET_RULES"], + app.config["DEFAULT_PERSONAL_SPLIT_DESI_PCT"], + ) + comparison_service = ComparisonService() + share_service = ShareCalculationService() + push_service = PushService( + app.config["VAPID_PUBLIC_KEY"], + app.config["VAPID_PRIVATE_KEY"], + app.config["VAPID_CLAIMS"], + ) + month_service = MonthService(allocation_service, comparison_service, share_service) + notification_service = NotificationService( + month_service, push_service, app.config["STRONG_INCOME_CHANGE_THRESHOLD"] + ) + + app.extensions["saldo.month_service"] = month_service + app.extensions["saldo.allocation_service"] = allocation_service + app.extensions["saldo.notification_service"] = notification_service + app.extensions["saldo.push_service"] = push_service + app.extensions["saldo.share_service"] = share_service + + from .admin.routes import admin_bp + from .auth.routes import auth_bp + from .main.routes import main_bp + from .months.routes import months_bp + from .planning.routes import planning_bp + + app.register_blueprint(auth_bp) + app.register_blueprint(main_bp) + app.register_blueprint(planning_bp) + app.register_blueprint(months_bp) + app.register_blueprint(admin_bp) + + register_cli(app) + register_context(app) + register_hooks(app) + configure_logging(app) + return app + + +def register_context(app: Flask) -> None: + @app.context_processor + def inject_globals(): + if "csrf_token" not in session: + session["csrf_token"] = secrets.token_urlsafe(32) + return { + "app_name": app.config["APP_NAME"], + "vapid_public_key": app.config["VAPID_PUBLIC_KEY"], + "csrf_token": session["csrf_token"], + } + + +def register_hooks(app: Flask) -> None: + @app.before_request + def ensure_current_month(): + allowed_endpoints = {"auth.setup", "static", "main.health"} + if User.query.count() == 0 and request.endpoint not in allowed_endpoints: + return redirect(url_for("auth.setup")) + if request.method == "POST" and app.config.get("CSRF_ENABLED") and not app.config.get("TESTING"): + sent_token = request.headers.get("X-CSRF-Token") or request.form.get("csrf_token") + if request.endpoint != "planning.subscribe_push" and sent_token != session.get("csrf_token"): + return ("CSRF validation failed", 400) + if current_user.is_authenticated: + if sync_user_participants(): + db.session.commit() + g.current_month = app.extensions["saldo.month_service"].ensure_month() + + +def register_cli(app: Flask) -> None: + import click + + from .extensions import db + from .models import NotificationPreference, User + @app.cli.command("create-admin") + @click.option("--username", required=True) + @click.option("--email", required=True) + @click.option("--password", required=True) + @click.option("--display-name", default=None) + def create_admin(username: str, email: str, password: str, display_name: str | None): + user = User.query.filter_by(username=username).first() + if user is None: + user = User( + username=username, + display_name=display_name or username, + email=email, + role="admin", + ) + db.session.add(user) + elif not user.display_name: + user.display_name = display_name or username + user.set_password(password) + user.is_active = True + pref = user.notification_preference or NotificationPreference(user=user) + db.session.add(pref) + db.session.commit() + click.echo(f"Admin {username} bereit.") + + @app.cli.command("bootstrap-admin") + def bootstrap_admin(): + username = app.config["ADMIN_BOOTSTRAP_USERNAME"] + password = app.config["ADMIN_BOOTSTRAP_PASSWORD"] + user = User.query.filter_by(username=username).first() + if user: + click.echo("Bootstrap-Admin existiert bereits.") + return + if not password: + click.echo("Kein Bootstrap-Passwort gesetzt. Bitte Setup-Seite oder create-admin nutzen.") + return + user = User( + username=username, + display_name=username, + email=app.config["ADMIN_BOOTSTRAP_EMAIL"], + role="admin", + ) + user.set_password(password) + db.session.add(user) + db.session.flush() + db.session.add(NotificationPreference(user_id=user.id)) + db.session.commit() + click.echo(f"Bootstrap-Admin {username} angelegt.") + + @app.cli.command("seed") + def seed(): + seed_data() + click.echo("Basisdaten angelegt.") + + @app.cli.command("run-reminders") + def run_reminders(): + count = app.extensions["saldo.notification_service"].run_monthly_checks() + click.echo(f"{count} Erinnerungen geprüft.") + + +def configure_logging(app: Flask) -> None: + logging.basicConfig(level=logging.INFO) + app.logger.setLevel(logging.INFO) diff --git a/app/admin/__init__.py b/app/admin/__init__.py new file mode 100644 index 0000000..77966a7 --- /dev/null +++ b/app/admin/__init__.py @@ -0,0 +1,2 @@ +from .routes import admin_bp + diff --git a/app/admin/routes.py b/app/admin/routes.py new file mode 100644 index 0000000..9cfa021 --- /dev/null +++ b/app/admin/routes.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +from flask import Blueprint, flash, redirect, render_template, request, url_for +from flask_login import login_required +from sqlalchemy import select + +from app.extensions import db +from app.models import ( + Account, + Category, + CostParticipant, + Entry, + EntryShareRule, + MonthlyEntryValue, + Month, + NotificationPreference, + User, +) +from app.seed import slugify +from app.utils.uploads import save_avatar_upload +from app.utils.decorators import admin_required + +admin_bp = Blueprint("admin", __name__, url_prefix="/admin") + + +def _resolve_avatar_url(existing: str | None = None) -> str | None: + upload = request.files.get("avatar_file") + if upload and upload.filename: + try: + return save_avatar_upload(upload) + except ValueError as exc: + flash(str(exc), "danger") + return existing + avatar_url = request.form.get("avatar_url") + if avatar_url is not None: + avatar_url = avatar_url.strip() + return avatar_url or existing + return existing + + +@admin_bp.route("/") +@login_required +@admin_required +def index(): + users = User.query.order_by(User.display_name.asc(), User.username.asc()).all() + participants = CostParticipant.query.order_by(CostParticipant.name.asc()).all() + accounts = db.session.scalars(select(Account).order_by(Account.sort_order.asc(), Account.name.asc())).all() + categories = db.session.scalars( + select(Category).order_by(Category.account_id.asc(), Category.sort_order.asc(), Category.name.asc()) + ).all() + entries = db.session.scalars( + select(Entry).order_by(Entry.category_id.asc(), Entry.sort_order.asc(), Entry.name.asc()) + ).all() + return render_template( + "admin/index.html", + users=users, + participants=participants, + accounts=accounts, + categories=categories, + entries=entries, + ) + + +@admin_bp.route("/users", methods=["POST"]) +@login_required +@admin_required +def create_user(): + user = User( + username=request.form["username"].strip(), + display_name=request.form["display_name"].strip(), + email=request.form["email"].strip(), + avatar_url=_resolve_avatar_url(), + role=request.form.get("role", "editor"), + is_active=True, + ) + user.set_password(request.form["password"]) + db.session.add(user) + db.session.flush() + db.session.add(NotificationPreference(user_id=user.id)) + db.session.commit() + flash("Benutzer angelegt.", "success") + return redirect(url_for("admin.index")) + + +@admin_bp.route("/users/", methods=["POST"]) +@login_required +@admin_required +def update_user(user_id: int): + user = User.query.get_or_404(user_id) + user.display_name = request.form["display_name"].strip() + user.email = request.form["email"].strip() + user.avatar_url = _resolve_avatar_url(user.avatar_url) + user.role = request.form.get("role", user.role) + user.is_active = request.form.get("is_active") == "on" + db.session.commit() + flash("Benutzer aktualisiert.", "success") + return redirect(url_for("admin.index")) + + +@admin_bp.route("/users//toggle", methods=["POST"]) +@login_required +@admin_required +def toggle_user(user_id: int): + user = User.query.get_or_404(user_id) + user.is_active = not user.is_active + db.session.commit() + flash("Benutzerstatus aktualisiert.", "info") + return redirect(url_for("admin.index")) + + +@admin_bp.route("/participants", methods=["POST"]) +@login_required +@admin_required +def create_participant(): + participant = CostParticipant( + name=request.form["name"].strip(), + avatar_url=_resolve_avatar_url(), + is_external=request.form.get("is_external") == "on", + is_app_user=bool(request.form.get("linked_user_id")), + linked_user_id=int(request.form["linked_user_id"]) if request.form.get("linked_user_id") else None, + is_active=True, + ) + db.session.add(participant) + db.session.commit() + flash("Beteiligte Person angelegt.", "success") + return redirect(url_for("admin.index")) + + +@admin_bp.route("/participants/", methods=["POST"]) +@login_required +@admin_required +def update_participant(participant_id: int): + participant = CostParticipant.query.get_or_404(participant_id) + participant.name = request.form["name"].strip() + participant.avatar_url = _resolve_avatar_url(participant.avatar_url) + participant.is_external = request.form.get("is_external") == "on" + participant.linked_user_id = ( + int(request.form["linked_user_id"]) if request.form.get("linked_user_id") else None + ) + participant.is_app_user = participant.linked_user_id is not None + participant.is_active = request.form.get("is_active") == "on" + db.session.commit() + flash("Beteiligte Person aktualisiert.", "success") + return redirect(url_for("admin.index")) + + +@admin_bp.route("/accounts", methods=["POST"]) +@login_required +@admin_required +def create_account(): + name = request.form["name"].strip() + account = Account( + name=name, + slug=slugify(request.form.get("slug", "") or name), + description=request.form.get("description", "").strip() or None, + sort_order=int(request.form.get("sort_order") or 0), + is_active=request.form.get("is_active") == "on", + ) + db.session.add(account) + db.session.commit() + flash("Konto angelegt.", "success") + return redirect(url_for("admin.index")) + + +@admin_bp.route("/accounts/", methods=["POST"]) +@login_required +@admin_required +def update_account(account_id: int): + account = Account.query.get_or_404(account_id) + name = request.form["name"].strip() + account.name = name + account.slug = slugify(request.form.get("slug", "") or name) + account.description = request.form.get("description", "").strip() or None + account.sort_order = int(request.form.get("sort_order") or 0) + account.is_active = request.form.get("is_active") == "on" + db.session.commit() + flash("Konto aktualisiert.", "success") + return redirect(url_for("admin.index")) + + +@admin_bp.route("/categories", methods=["POST"]) +@login_required +@admin_required +def create_category(): + name = request.form["name"].strip() + category = Category( + account_id=int(request.form["account_id"]), + name=name, + slug=slugify(request.form.get("slug", "") or name), + description=request.form.get("description", "").strip() or None, + sort_order=int(request.form.get("sort_order") or 0), + is_active=request.form.get("is_active") == "on", + ) + db.session.add(category) + db.session.commit() + flash("Kategorie angelegt.", "success") + return redirect(url_for("admin.index")) + + +@admin_bp.route("/categories/", methods=["POST"]) +@login_required +@admin_required +def update_category(category_id: int): + category = Category.query.get_or_404(category_id) + name = request.form["name"].strip() + category.account_id = int(request.form["account_id"]) + category.name = name + category.slug = slugify(request.form.get("slug", "") or name) + category.description = request.form.get("description", "").strip() or None + category.sort_order = int(request.form.get("sort_order") or 0) + category.is_active = request.form.get("is_active") == "on" + db.session.commit() + flash("Kategorie aktualisiert.", "success") + return redirect(url_for("admin.index")) + + +@admin_bp.route("/entries", methods=["POST"]) +@login_required +@admin_required +def create_entry(): + name = request.form["name"].strip() + entry = Entry( + category_id=int(request.form["category_id"]), + name=name, + slug=slugify(request.form.get("slug", "") or name), + description=request.form.get("description", "").strip() or None, + default_amount=request.form.get("default_amount", "0"), + amount_type=request.form.get("amount_type", "fixed"), + sort_order=int(request.form.get("sort_order") or 0), + is_active=request.form.get("is_active") == "on", + ) + db.session.add(entry) + db.session.flush() + for month in Month.query.order_by(Month.year.asc(), Month.month.asc()).all(): + db.session.add( + MonthlyEntryValue( + month_id=month.id, + entry_id=entry.id, + planned_amount=entry.default_amount, + ) + ) + db.session.commit() + flash("Eintrag angelegt und in vorhandene Monate übernommen.", "success") + return redirect(url_for("admin.index")) + + +@admin_bp.route("/entries/", methods=["POST"]) +@login_required +@admin_required +def update_entry(entry_id: int): + entry = Entry.query.get_or_404(entry_id) + name = request.form["name"].strip() + entry.category_id = int(request.form["category_id"]) + entry.name = name + entry.slug = slugify(request.form.get("slug", "") or name) + entry.description = request.form.get("description", "").strip() or None + entry.default_amount = request.form.get("default_amount", "0") + entry.amount_type = request.form.get("amount_type", "fixed") + entry.sort_order = int(request.form.get("sort_order") or 0) + entry.is_active = request.form.get("is_active") == "on" + db.session.commit() + flash("Eintrag aktualisiert.", "success") + return redirect(url_for("admin.index")) + + +@admin_bp.route("/entries//share-rules", methods=["POST"]) +@login_required +@admin_required +def create_share_rule(entry_id: int): + participant_id = int(request.form["participant_id"]) + rule = EntryShareRule.query.filter_by(entry_id=entry_id, participant_id=participant_id).first() + if rule is None: + rule = EntryShareRule(entry_id=entry_id, participant_id=participant_id) + db.session.add(rule) + rule.share_type = request.form.get("share_type", "equal") + share_value = request.form.get("share_value", "").strip() + rule.share_value = share_value or None + db.session.commit() + flash("Beteiligungsregel gespeichert.", "success") + return redirect(url_for("admin.index")) + + +@admin_bp.route("/share-rules//delete", methods=["POST"]) +@login_required +@admin_required +def delete_share_rule(rule_id: int): + rule = EntryShareRule.query.get_or_404(rule_id) + db.session.delete(rule) + db.session.commit() + flash("Beteiligungsregel entfernt.", "info") + return redirect(url_for("admin.index")) diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..6e7b767 --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1,2 @@ +from .routes import auth_bp + diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 0000000..edf91bb --- /dev/null +++ b/app/auth/routes.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from flask import Blueprint, flash, redirect, render_template, request, url_for +from flask_login import current_user, login_required, login_user, logout_user + +from app.extensions import db +from app.models import NotificationPreference, User +from app.seed import seed_data +auth_bp = Blueprint("auth", __name__, url_prefix="/auth") + + +@auth_bp.route("/setup", methods=["GET", "POST"]) +def setup(): + if current_user.is_authenticated: + return redirect(url_for("main.index")) + if User.query.count() > 0: + return redirect(url_for("auth.login")) + + if request.method == "POST": + username = request.form.get("username", "").strip() + display_name = request.form.get("display_name", "").strip() + email = request.form.get("email", "").strip() + password = request.form.get("password", "") + password_confirm = request.form.get("password_confirm", "") + + if not username or not display_name or not email or not password: + flash("Bitte alle Pflichtfelder ausfüllen.", "danger") + elif password != password_confirm: + flash("Die Passwörter stimmen nicht überein.", "danger") + elif User.query.filter((User.username == username) | (User.email == email)).first(): + flash("Benutzername oder E-Mail existieren bereits.", "danger") + else: + seed_data() + user = User( + username=username, + display_name=display_name, + email=email, + role="admin", + is_active=True, + ) + user.set_password(password) + db.session.add(user) + db.session.flush() + db.session.add(NotificationPreference(user_id=user.id)) + db.session.commit() + login_user(user, remember=True) + flash("Admin eingerichtet. Saldo ist startklar.", "success") + return redirect(url_for("main.index")) + + return render_template("auth/setup.html") + + +@auth_bp.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(url_for("main.index")) + if User.query.count() == 0: + return redirect(url_for("auth.setup")) + has_users = User.query.count() > 0 + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + user = User.query.filter_by(username=username, is_active=True).first() + if user and user.check_password(password): + login_user(user, remember=True) + flash("Willkommen zurück.", "success") + return redirect(request.args.get("next") or url_for("main.index")) + flash("Login fehlgeschlagen. Bitte prüfe Benutzername und Passwort.", "danger") + return render_template("auth/login.html", has_users=has_users) + + +@auth_bp.route("/logout", methods=["POST"]) +@login_required +def logout(): + logout_user() + flash("Du wurdest abgemeldet.", "info") + return redirect(url_for("auth.login")) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..626e5d1 --- /dev/null +++ b/app/config.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import json +import os +import secrets +from pathlib import Path + + +def _default_data_dir() -> Path: + return Path(os.getenv("SALDO_DATA_DIR", Path.cwd() / "instance")).resolve() + + +class Config: + APP_NAME = "Saldo" + SECRET_KEY = os.getenv("SECRET_KEY") or secrets.token_hex(32) + DATA_DIR = _default_data_dir() + AVATAR_UPLOAD_DIR = DATA_DIR / "avatars" + SQLALCHEMY_DATABASE_URI = os.getenv( + "DATABASE_URL", + f"sqlite:///{(DATA_DIR / 'saldo.db').as_posix()}", + ) + SQLALCHEMY_TRACK_MODIFICATIONS = False + REMEMBER_COOKIE_DURATION = 60 * 60 * 24 * 30 + MAX_CONTENT_LENGTH = int(os.getenv("SALDO_MAX_CONTENT_LENGTH", str(5 * 1024 * 1024))) + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = "Lax" + REMEMBER_COOKIE_HTTPONLY = True + REMEMBER_COOKIE_SAMESITE = "Lax" + SESSION_COOKIE_SECURE = os.getenv("SALDO_SESSION_COOKIE_SECURE", "0") != "0" + REMEMBER_COOKIE_SECURE = SESSION_COOKIE_SECURE + CSRF_ENABLED = os.getenv("SALDO_CSRF_ENABLED", "1") != "0" + + VAPID_PUBLIC_KEY = os.getenv("VAPID_PUBLIC_KEY", "") + VAPID_PRIVATE_KEY = os.getenv("VAPID_PRIVATE_KEY", "") + VAPID_CLAIMS = { + "sub": os.getenv("VAPID_SUBJECT", "mailto:admin@example.invalid"), + } + + ADMIN_BOOTSTRAP_USERNAME = os.getenv("SALDO_ADMIN_USERNAME", "admin") + ADMIN_BOOTSTRAP_PASSWORD = os.getenv("SALDO_ADMIN_PASSWORD", "") + ADMIN_BOOTSTRAP_EMAIL = os.getenv("SALDO_ADMIN_EMAIL", "admin@example.invalid") + + ALLOCATION_TARGET_RULES = json.loads( + os.getenv( + "SALDO_ALLOCATION_TARGET_RULES", + json.dumps( + { + "sparen": { + "recommended_pct": 0.18, + "min_pct": 0.15, + "max_pct": 0.20, + "label": "Sparen", + }, + "urlaub": { + "recommended_pct": 0.06, + "min_pct": 0.05, + "max_pct": 0.08, + "label": "Urlaub", + }, + "freizeit": { + "recommended_pct": 0.07, + "min_pct": 0.05, + "max_pct": 0.10, + "label": "Freizeit", + }, + } + ), + ) + ) + DEFAULT_PERSONAL_SPLIT_DESI_PCT = float( + os.getenv("SALDO_DEFAULT_PERSONAL_SPLIT_DESI_PCT", "50") + ) + STRONG_INCOME_CHANGE_THRESHOLD = float( + os.getenv("SALDO_STRONG_INCOME_CHANGE_THRESHOLD", "150") + ) diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..2be3a9d --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,9 @@ +from flask_login import LoginManager +from flask_migrate import Migrate +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() +migrate = Migrate() +login_manager = LoginManager() +login_manager.login_view = "auth.login" +login_manager.login_message_category = "info" diff --git a/app/main/__init__.py b/app/main/__init__.py new file mode 100644 index 0000000..3efc0a5 --- /dev/null +++ b/app/main/__init__.py @@ -0,0 +1,2 @@ +from .routes import main_bp + diff --git a/app/main/routes.py b/app/main/routes.py new file mode 100644 index 0000000..d48d669 --- /dev/null +++ b/app/main/routes.py @@ -0,0 +1,339 @@ +from __future__ import annotations + +from decimal import Decimal + +from flask import Blueprint, current_app, g, render_template, send_from_directory +from flask_login import login_required + +from app.models import ( + Account, + Category, + CommunityAccount, + CostParticipant, + InAppNotification, + Month, + to_decimal, +) +from app.utils.users import ( + active_users, + benefit_scope_label, + personal_account_names, + personal_users, + sync_user_participants, +) + +main_bp = Blueprint("main", __name__) + + +def _community_account_cards(month, previous_month): + community_accounts = CommunityAccount.query.filter_by(is_active=True).order_by( + CommunityAccount.sort_order.asc(), CommunityAccount.name.asc() + ).all() + current_entry_values = {item.entry_id: to_decimal(item.planned_amount) for item in month.entry_values} + previous_entry_values = ( + {item.entry_id: to_decimal(item.planned_amount) for item in previous_month.entry_values} + if previous_month is not None + else {} + ) + current_allocations = { + item.target_account.slug: to_decimal(item.amount) + for item in month.allocations + if item.target_account + } + previous_allocations = ( + { + item.target_account.slug: to_decimal(item.amount) + for item in previous_month.allocations + if item.target_account + } + if previous_month is not None + else {} + ) + budget_categories = Category.query.join(Account).filter( + Category.is_active.is_(True), + Account.slug == "gemeinschaftskonto", + ).all() + cards = [] + for community_account in community_accounts: + if community_account.account_type == "personal" and community_account.linked_account_slug: + current_total = current_allocations.get(community_account.linked_account_slug, Decimal("0.00")) + previous_total = previous_allocations.get(community_account.linked_account_slug, Decimal("0.00")) + assigned_budget_names = ["Persönliche Auszahlung"] + else: + assigned_categories = [ + category for category in budget_categories if category.community_account_id == community_account.id + ] + current_total = sum( + (current_entry_values.get(entry.id, Decimal("0.00")) for category in assigned_categories for entry in category.entries if entry.is_active), + Decimal("0.00"), + ) + previous_total = sum( + (previous_entry_values.get(entry.id, Decimal("0.00")) for category in assigned_categories for entry in category.entries if entry.is_active), + Decimal("0.00"), + ) + assigned_budget_names = [category.name for category in assigned_categories] + delta = current_total - previous_total + cards.append( + { + "community_account": community_account, + "current_total": current_total, + "previous_total": previous_total, + "delta": delta, + "assigned_budget_names": assigned_budget_names, + "needs_update": delta != Decimal("0.00"), + "is_read_only": community_account.account_type == "personal", + } + ) + return cards + + +@main_bp.route("/") +@login_required +def index(): + if sync_user_participants(): + current_app.logger.info("App-Nutzer wurden mit Split-Personen synchronisiert.") + from app.extensions import db + + db.session.commit() + month = g.current_month + summary = current_app.extensions["saldo.month_service"].compute_summary(month) + previous_month = current_app.extensions["saldo.month_service"].previous_month(month.year, month.month) + recent_months = Month.query.order_by(Month.year.desc(), Month.month.desc()).limit(6).all() + notifications = ( + InAppNotification.query.filter_by(is_read=False) + .order_by(InAppNotification.created_at.desc()) + .limit(5) + .all() + ) + community_account_cards = _community_account_cards(month, previous_month) + shared_account_changes = [ + item for item in community_account_cards if not item["is_read_only"] and item["needs_update"] + ] + personal_allocations = { + item.target_account.slug: to_decimal(item.amount) + for item in month.allocations + if item.target_account and item.target_account.slug in {"persoenlich-flo", "persoenlich-desi"} + } + internal_participants = { + participant.linked_user_id: participant + for participant in CostParticipant.query.filter( + CostParticipant.is_active.is_(True), + CostParticipant.is_app_user.is_(True), + ).all() + if participant.linked_user_id is not None + } + personal_label_map = personal_account_names() + personal_user_list = personal_users() + personal_user_map = { + "persoenlich-flo": personal_user_list[0] if len(personal_user_list) > 0 else None, + "persoenlich-desi": personal_user_list[1] if len(personal_user_list) > 1 else None, + } + + def _personal_payload(slug: str) -> dict: + user = personal_user_map.get(slug) + participant = internal_participants.get(user.id) if user is not None else None + return { + "amount": personal_allocations.get(slug, Decimal("0.00")), + "name": personal_label_map.get(slug, slug), + "avatar_url": getattr(participant, "avatar_url", None) or getattr(user, "avatar_url", None), + "avatar_initials": getattr(participant, "avatar_initials", None) + or getattr(user, "avatar_initials", "??"), + } + + return render_template( + "main/index.html", + month=month, + summary=summary, + recent_months=recent_months, + notifications=notifications, + community_account_cards=community_account_cards, + shared_account_changes=shared_account_changes, + personal_payouts={ + "first": _personal_payload("persoenlich-flo"), + "second": _personal_payload("persoenlich-desi"), + }, + ) + + +@main_bp.route("/analytics") +@login_required +def analytics(): + if sync_user_participants(): + from app.extensions import db + + db.session.commit() + month = g.current_month + summary = current_app.extensions["saldo.month_service"].compute_summary(month) + available_users = active_users() + + category_totals: dict[str, dict] = {} + account_totals: dict[str, Decimal] = {} + benefit_totals: dict[str, Decimal] = {} + entry_rows = [] + personal_label_map = personal_account_names() + + for value in month.entry_values: + entry = value.entry + category = entry.category if entry else None + account = category.account if category else None + if ( + entry is None + or category is None + or account is None + or not entry.is_active + or not category.is_active + or not account.is_active + ): + continue + + amount = to_decimal(value.planned_amount) + is_personal_payout = account.slug in {"persoenlich-flo", "persoenlich-desi"} + is_savings_target = account.slug in {"sparen", "urlaub", "freizeit"} + category_key = ( + "personal-payout" + if is_personal_payout + else "savings-targets" + if is_savings_target + else str(category.id) + ) + category_label = ( + "Persönliche Auszahlung" + if is_personal_payout + else "Sparkonten" + if is_savings_target + else category.name + ) + category_account = ( + "Persönliche Auszahlung" + if is_personal_payout + else "Sparen & Verteilung" + if is_savings_target + else account.name + ) + detail_entry_label = ( + personal_label_map.get(account.slug, account.name) if account.slug in {"persoenlich-flo", "persoenlich-desi"} + else category.name if is_savings_target + else entry.name + ) + category_bucket = category_totals.setdefault( + category_key, + { + "id": category_key, + "label": category_label, + "account": category_account, + "value": Decimal("0.00"), + "entries": {}, + }, + ) + category_bucket["value"] += amount + category_bucket["entries"][detail_entry_label] = ( + category_bucket["entries"].get(detail_entry_label, Decimal("0.00")) + amount + ) + + account_label = ( + "Persönliche Auszahlung" + if is_personal_payout + else account.name + ) + account_totals[account_label] = account_totals.get(account_label, Decimal("0.00")) + amount + + benefit_label = benefit_scope_label(entry.benefit_scope, available_users) + benefit_totals[benefit_label] = benefit_totals.get(benefit_label, Decimal("0.00")) + amount + + entry_rows.append( + { + "label": f"{category.name} · {entry.name}", + "value": amount, + } + ) + + sorted_categories = sorted( + category_totals.values(), + key=lambda item: (-item["value"], item["account"], item["label"]), + ) + category_entry_map = { + str(item["id"]): { + "label": item["label"], + "account": item["account"], + "labels": [label for label, _ in sorted(item["entries"].items(), key=lambda entry: (-entry[1], entry[0]))], + "values": [float(value) for _, value in sorted(item["entries"].items(), key=lambda entry: (-entry[1], entry[0]))], + } + for item in sorted_categories + } + default_category_id = str(sorted_categories[0]["id"]) if sorted_categories else "" + + sorted_accounts = sorted(account_totals.items(), key=lambda item: (-item[1], item[0])) + top_entries = sorted(entry_rows, key=lambda item: (-item["value"], item["label"]))[:10] + historical_months = Month.query.order_by(Month.year.asc(), Month.month.asc()).all() + budget_categories = ( + Category.query.join(Account) + .filter( + Category.is_active.is_(True), + Account.slug == "gemeinschaftskonto", + ) + .order_by(Category.sort_order.asc(), Category.name.asc()) + .all() + ) + budget_timeline_rows = { + category.id: { + "label": category.name, + "data": [], + } + for category in budget_categories + } + + for historical_month in historical_months: + month_totals = {category.id: Decimal("0.00") for category in budget_categories} + for value in historical_month.entry_values: + entry = value.entry + category = entry.category if entry else None + account = category.account if category else None + if ( + entry is None + or category is None + or account is None + or category.id not in month_totals + or not entry.is_active + or not category.is_active + or not account.is_active + ): + continue + month_totals[category.id] += to_decimal(value.planned_amount) + + for category in budget_categories: + budget_timeline_rows[category.id]["data"].append(float(month_totals[category.id])) + + budget_timeline_datasets = [ + dataset + for dataset in budget_timeline_rows.values() + if any(value != 0 for value in dataset["data"]) + ] + + return render_template( + "main/analytics.html", + month=month, + summary=summary, + category_labels=[item["label"] for item in sorted_categories], + category_values=[float(item["value"]) for item in sorted_categories], + category_keys=[str(item["id"]) for item in sorted_categories], + category_entry_map=category_entry_map, + default_category_id=default_category_id, + benefit_labels=[label for label, _ in sorted(benefit_totals.items(), key=lambda item: (-item[1], item[0]))], + benefit_values=[float(value) for _, value in sorted(benefit_totals.items(), key=lambda item: (-item[1], item[0]))], + account_labels=[label for label, _ in sorted_accounts], + account_values=[float(value) for _, value in sorted_accounts], + top_entry_labels=[item["label"] for item in top_entries], + top_entry_values=[float(item["value"]) for item in top_entries], + budget_timeline_labels=[item.label for item in historical_months], + budget_timeline_datasets=budget_timeline_datasets, + ) + + +@main_bp.route("/health") +def health(): + return {"status": "ok"}, 200 + + +@main_bp.route("/media/avatars/") +def uploaded_avatar(filename: str): + return send_from_directory(current_app.config["AVATAR_UPLOAD_DIR"], filename) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..e6006dc --- /dev/null +++ b/app/models.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal + +from flask_login import UserMixin +from sqlalchemy import UniqueConstraint +from werkzeug.security import check_password_hash, generate_password_hash + +from .extensions import db, login_manager + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class TimestampMixin: + created_at = db.Column(db.DateTime(timezone=True), default=utcnow, nullable=False) + updated_at = db.Column( + db.DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False + ) + + +class User(UserMixin, TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + display_name = db.Column(db.String(120), nullable=False) + email = db.Column(db.String(255), unique=True, nullable=False) + avatar_url = db.Column(db.String(255), nullable=True) + password_hash = db.Column(db.String(255), nullable=False) + role = db.Column(db.String(20), nullable=False, default="editor") + is_active = db.Column(db.Boolean, nullable=False, default=True) + + notification_preference = db.relationship( + "NotificationPreference", back_populates="user", uselist=False + ) + push_subscriptions = db.relationship("PushSubscription", back_populates="user") + + def set_password(self, password: str) -> None: + self.password_hash = generate_password_hash(password) + + def check_password(self, password: str) -> bool: + return check_password_hash(self.password_hash, password) + + def is_admin(self) -> bool: + return self.role == "admin" + + @property + def ui_name(self) -> str: + return self.display_name + + @property + def avatar_initials(self) -> str: + parts = [part for part in self.ui_name.replace("-", " ").replace("_", " ").split() if part] + if not parts: + return "?" + if len(parts) == 1: + return parts[0][:2].upper() + return f"{parts[0][0]}{parts[1][0]}".upper() + + +@login_manager.user_loader +def load_user(user_id: str) -> User | None: + return db.session.get(User, int(user_id)) + + +class Month(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + label = db.Column(db.String(7), unique=True, nullable=False) + year = db.Column(db.Integer, nullable=False) + month = db.Column(db.Integer, nullable=False) + auto_created = db.Column(db.Boolean, nullable=False, default=False) + is_locked = db.Column(db.Boolean, nullable=False, default=False) + notes = db.Column(db.Text, nullable=True) + savings_min_pct = db.Column(db.Numeric(5, 2), nullable=False, default=15) + savings_max_pct = db.Column(db.Numeric(5, 2), nullable=False, default=20) + vacation_min_pct = db.Column(db.Numeric(5, 2), nullable=False, default=5) + vacation_max_pct = db.Column(db.Numeric(5, 2), nullable=False, default=8) + leisure_min_pct = db.Column(db.Numeric(5, 2), nullable=False, default=5) + leisure_max_pct = db.Column(db.Numeric(5, 2), nullable=False, default=10) + personal_split_desi_pct = db.Column(db.Numeric(5, 2), nullable=False, default=50) + + entry_values = db.relationship( + "MonthlyEntryValue", back_populates="month", cascade="all, delete-orphan" + ) + incomes = db.relationship( + "MonthlyIncome", back_populates="month", cascade="all, delete-orphan" + ) + allocations = db.relationship( + "MonthlyAllocation", back_populates="month", cascade="all, delete-orphan" + ) + suggestions = db.relationship( + "AllocationSuggestion", back_populates="month", cascade="all, delete-orphan" + ) + + +class Account(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False, unique=True) + slug = db.Column(db.String(120), nullable=False, unique=True) + description = db.Column(db.Text, nullable=True) + sort_order = db.Column(db.Integer, nullable=False, default=0) + is_active = db.Column(db.Boolean, nullable=False, default=True) + + categories = db.relationship( + "Category", back_populates="account", cascade="all, delete-orphan" + ) + + +class Category(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + account_id = db.Column(db.Integer, db.ForeignKey("account.id"), nullable=False) + community_account_id = db.Column( + db.Integer, db.ForeignKey("community_account.id"), nullable=True + ) + name = db.Column(db.String(120), nullable=False) + slug = db.Column(db.String(120), nullable=False) + description = db.Column(db.Text, nullable=True) + sort_order = db.Column(db.Integer, nullable=False, default=0) + is_active = db.Column(db.Boolean, nullable=False, default=True) + + account = db.relationship("Account", back_populates="categories") + community_account = db.relationship("CommunityAccount", back_populates="budget_categories") + entries = db.relationship( + "Entry", back_populates="category", cascade="all, delete-orphan" + ) + + __table_args__ = (UniqueConstraint("account_id", "slug"),) + + +class Entry(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + category_id = db.Column(db.Integer, db.ForeignKey("category.id"), nullable=False) + name = db.Column(db.String(120), nullable=False) + slug = db.Column(db.String(120), nullable=False) + description = db.Column(db.Text, nullable=True) + default_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0) + amount_type = db.Column(db.String(20), nullable=False, default="fixed") + benefit_scope = db.Column(db.String(120), nullable=False, default="all-users") + is_allocation_target = db.Column(db.Boolean, nullable=False, default=False) + is_active = db.Column(db.Boolean, nullable=False, default=True) + sort_order = db.Column(db.Integer, nullable=False, default=0) + + category = db.relationship("Category", back_populates="entries") + monthly_values = db.relationship( + "MonthlyEntryValue", back_populates="entry", cascade="all, delete-orphan" + ) + share_rules = db.relationship( + "EntryShareRule", back_populates="entry", cascade="all, delete-orphan" + ) + + __table_args__ = (UniqueConstraint("category_id", "slug"),) + + +class MonthlyEntryValue(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + month_id = db.Column(db.Integer, db.ForeignKey("month.id"), nullable=False) + entry_id = db.Column(db.Integer, db.ForeignKey("entry.id"), nullable=False) + planned_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0) + note = db.Column(db.Text, nullable=True) + created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + updated_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + + month = db.relationship("Month", back_populates="entry_values") + entry = db.relationship("Entry", back_populates="monthly_values") + + __table_args__ = (UniqueConstraint("month_id", "entry_id"),) + + +class MonthlyIncome(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + month_id = db.Column(db.Integer, db.ForeignKey("month.id"), nullable=False) + label = db.Column(db.String(120), nullable=False) + amount = db.Column(db.Numeric(12, 2), nullable=False, default=0) + sort_order = db.Column(db.Integer, nullable=False, default=0) + + month = db.relationship("Month", back_populates="incomes") + + +class MonthlyAllocation(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + month_id = db.Column(db.Integer, db.ForeignKey("month.id"), nullable=False) + target_account_id = db.Column(db.Integer, db.ForeignKey("account.id"), nullable=False) + label = db.Column(db.String(120), nullable=False) + amount = db.Column(db.Numeric(12, 2), nullable=False, default=0) + source = db.Column(db.String(30), nullable=False, default="manual") + is_locked = db.Column(db.Boolean, nullable=False, default=False) + sort_order = db.Column(db.Integer, nullable=False, default=0) + + month = db.relationship("Month", back_populates="allocations") + target_account = db.relationship("Account") + + __table_args__ = (UniqueConstraint("month_id", "target_account_id"),) + + +class AllocationSuggestion(db.Model): + id = db.Column(db.Integer, primary_key=True) + month_id = db.Column(db.Integer, db.ForeignKey("month.id"), nullable=False) + target_account_id = db.Column(db.Integer, db.ForeignKey("account.id"), nullable=False) + suggested_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0) + reason = db.Column(db.Text, nullable=True) + strategy_key = db.Column(db.String(80), nullable=False) + created_at = db.Column(db.DateTime(timezone=True), default=utcnow, nullable=False) + + month = db.relationship("Month", back_populates="suggestions") + target_account = db.relationship("Account") + + +class CostParticipant(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False, unique=True) + avatar_url = db.Column(db.String(255), nullable=True) + is_app_user = db.Column(db.Boolean, nullable=False, default=False) + linked_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + is_external = db.Column(db.Boolean, nullable=False, default=False) + is_active = db.Column(db.Boolean, nullable=False, default=True) + + linked_user = db.relationship("User") + share_rules = db.relationship( + "EntryShareRule", back_populates="participant", cascade="all, delete-orphan" + ) + + @property + def display_name(self) -> str: + if self.is_app_user and self.linked_user is not None: + return self.linked_user.ui_name + return self.name + + @property + def avatar_initials(self) -> str: + parts = [part for part in self.display_name.replace("-", " ").replace("_", " ").split() if part] + if not parts: + return "?" + if len(parts) == 1: + return parts[0][:2].upper() + return f"{parts[0][0]}{parts[1][0]}".upper() + + +class EntryShareRule(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + entry_id = db.Column(db.Integer, db.ForeignKey("entry.id"), nullable=False) + participant_id = db.Column( + db.Integer, db.ForeignKey("cost_participant.id"), nullable=False + ) + share_type = db.Column(db.String(20), nullable=False, default="equal") + share_value = db.Column(db.Numeric(12, 4), nullable=True) + + entry = db.relationship("Entry", back_populates="share_rules") + participant = db.relationship("CostParticipant", back_populates="share_rules") + + __table_args__ = (UniqueConstraint("entry_id", "participant_id"),) + + +class PushSubscription(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + endpoint = db.Column(db.Text, nullable=False) + p256dh_key = db.Column(db.Text, nullable=False) + auth_key = db.Column(db.Text, nullable=False) + user_agent = db.Column(db.String(255), nullable=True) + + user = db.relationship("User", back_populates="push_subscriptions") + + +class CommunityAccount(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False, unique=True) + slug = db.Column(db.String(120), nullable=False, unique=True) + description = db.Column(db.Text, nullable=True) + account_type = db.Column(db.String(20), nullable=False, default="shared") + linked_account_slug = db.Column(db.String(120), nullable=True) + sort_order = db.Column(db.Integer, nullable=False, default=0) + is_active = db.Column(db.Boolean, nullable=False, default=True) + + budget_categories = db.relationship("Category", back_populates="community_account") + + +class NotificationPreference(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, unique=True) + notify_month_end = db.Column(db.Boolean, nullable=False, default=True) + notify_missing_distribution = db.Column(db.Boolean, nullable=False, default=True) + notify_missing_values = db.Column(db.Boolean, nullable=False, default=True) + + user = db.relationship("User", back_populates="notification_preference") + + +class InAppNotification(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + type = db.Column(db.String(50), nullable=False) + title = db.Column(db.String(150), nullable=False) + body = db.Column(db.Text, nullable=False) + action_url = db.Column(db.String(255), nullable=True) + is_read = db.Column(db.Boolean, nullable=False, default=False) + created_at = db.Column(db.DateTime(timezone=True), default=utcnow, nullable=False) + + +class AuditLog(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + action = db.Column(db.String(120), nullable=False) + entity_type = db.Column(db.String(80), nullable=False) + entity_id = db.Column(db.Integer, nullable=True) + payload_json = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime(timezone=True), default=utcnow, nullable=False) + + +def to_decimal(value: object) -> Decimal: + if value is None: + return Decimal("0.00") + if isinstance(value, Decimal): + return value.quantize(Decimal("0.01")) + return Decimal(str(value)).quantize(Decimal("0.01")) diff --git a/app/months/__init__.py b/app/months/__init__.py new file mode 100644 index 0000000..d09aafe --- /dev/null +++ b/app/months/__init__.py @@ -0,0 +1,2 @@ +from .routes import months_bp + diff --git a/app/months/routes.py b/app/months/routes.py new file mode 100644 index 0000000..1c627b2 --- /dev/null +++ b/app/months/routes.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for +from flask_login import login_required + +from app.extensions import db +from app.models import Month + +months_bp = Blueprint("months", __name__, url_prefix="/months") + + +@months_bp.route("/") +@login_required +def index(): + months = Month.query.order_by(Month.year.desc(), Month.month.desc()).all() + return render_template("months/index.html", months=months) + + +@months_bp.route("/create", methods=["POST"]) +@login_required +def create(): + label = request.form.get("label", "") + month = current_app.extensions["saldo.month_service"].get_or_create_by_label(label) + flash(f"Monat {month.label} ist bereit.", "success") + return redirect(url_for("planning.detail", label=month.label)) + + +@months_bp.route("/