From 6f5e704739c7edf9aa20d445aff8de4a296a7a66 Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Tue, 21 Apr 2026 21:17:36 +0200 Subject: [PATCH] release: publish saldo 0.1.0 --- .dockerignore | 19 + .env.example | 12 + .gitignore | 29 + ARCHITECTURE.md | 51 + CHANGELOG | 20 + CloudronManifest.json | 29 + DESCRIPTION.md | 12 + Dockerfile | 20 + LICENSE | 29 + POSTINSTALL.md | 14 + README.md | 231 +++ VERSION | 1 + app/__init__.py | 174 ++ app/admin/__init__.py | 2 + app/admin/routes.py | 291 ++++ app/auth/__init__.py | 2 + app/auth/routes.py | 77 + app/config.py | 75 + app/extensions.py | 9 + app/main/__init__.py | 2 + app/main/routes.py | 339 ++++ app/models.py | 314 ++++ app/months/__init__.py | 2 + app/months/routes.py | 49 + app/planning/__init__.py | 2 + app/planning/routes.py | 1092 ++++++++++++ app/seed.py | 462 ++++++ app/services/allocation_service.py | 228 +++ app/services/comparison_service.py | 60 + app/services/month_service.py | 429 +++++ app/services/notification_service.py | 64 + app/services/push_service.py | 32 + app/services/share_service.py | 97 ++ app/static/css/app.css | 1468 +++++++++++++++++ app/static/icons/arrow-right-to-bracket.svg | 1 + app/static/icons/bell.svg | 1 + app/static/icons/building-columns.svg | 1 + app/static/icons/calendar.svg | 1 + app/static/icons/chart-bar.svg | 1 + app/static/icons/chart-simple.svg | 1 + app/static/icons/circle-user.svg | 1 + app/static/icons/database.svg | 1 + app/static/icons/gear.svg | 1 + app/static/icons/house.svg | 1 + app/static/icons/landmark.svg | 1 + app/static/icons/list.svg | 1 + app/static/icons/money-bill-transfer.svg | 1 + app/static/icons/moon.svg | 1 + app/static/icons/pencil.svg | 1 + app/static/icons/plus.svg | 1 + app/static/icons/shield.svg | 1 + app/static/icons/sliders-h.svg | 1 + app/static/icons/sliders.svg | 1 + app/static/icons/sun.svg | 1 + app/static/icons/user-circle.svg | 1 + app/static/icons/users.svg | 1 + app/static/icons/wallet.svg | 1 + app/static/js/app.js | 432 +++++ app/static/manifest.json | 17 + app/static/service-worker.js | 35 + app/templates/_ui.html | 9 + app/templates/admin/index.html | 74 + app/templates/auth/login.html | 33 + app/templates/auth/setup.html | 46 + app/templates/base.html | 91 + app/templates/main/analytics.html | 105 ++ app/templates/main/index.html | 196 +++ app/templates/months/index.html | 34 + app/templates/planning/detail.html | 815 +++++++++ app/utils/decorators.py | 16 + app/utils/formatting.py | 9 + app/utils/uploads.py | 28 + app/utils/users.py | 171 ++ gunicorn.conf.py | 6 + migrations/README | 1 + migrations/alembic.ini | 50 + migrations/env.py | 113 ++ migrations/script.py.mako | 24 + .../46efbd09438b_add_entry_benefit_scope.py | 39 + ...87a921_add_distribution_ranges_to_month.py | 35 + .../versions/71ff8f291d18_initial_schema.py | 239 +++ ...d3f0c61bb21_add_entry_allocation_target.py | 28 + .../a91b7c4d2f10_add_community_accounts.py | 53 + ...ab4c2d1e9a10_expand_entry_benefit_scope.py | 35 + .../c1f8d92b4e31_add_avatar_fields.py | 33 + ...4a1d9b9e2f1_add_personal_split_to_month.py | 28 + .../d9f3c6a1b7f0_add_user_display_name.py | 31 + requirements.txt | 8 + start.sh | 14 + tests/conftest.py | 85 + tests/test_comparison_and_allocations.py | 171 ++ tests/test_month_service.py | 32 + tests/test_routes.py | 178 ++ tests/test_shares_notifications_auth.py | 119 ++ wsgi.py | 3 + 95 files changed, 9196 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 CHANGELOG create mode 100644 CloudronManifest.json create mode 100644 DESCRIPTION.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 POSTINSTALL.md create mode 100644 README.md create mode 100644 VERSION create mode 100644 app/__init__.py create mode 100644 app/admin/__init__.py create mode 100644 app/admin/routes.py create mode 100644 app/auth/__init__.py create mode 100644 app/auth/routes.py create mode 100644 app/config.py create mode 100644 app/extensions.py create mode 100644 app/main/__init__.py create mode 100644 app/main/routes.py create mode 100644 app/models.py create mode 100644 app/months/__init__.py create mode 100644 app/months/routes.py create mode 100644 app/planning/__init__.py create mode 100644 app/planning/routes.py create mode 100644 app/seed.py create mode 100644 app/services/allocation_service.py create mode 100644 app/services/comparison_service.py create mode 100644 app/services/month_service.py create mode 100644 app/services/notification_service.py create mode 100644 app/services/push_service.py create mode 100644 app/services/share_service.py create mode 100644 app/static/css/app.css create mode 100644 app/static/icons/arrow-right-to-bracket.svg create mode 100644 app/static/icons/bell.svg create mode 100644 app/static/icons/building-columns.svg create mode 100644 app/static/icons/calendar.svg create mode 100644 app/static/icons/chart-bar.svg create mode 100644 app/static/icons/chart-simple.svg create mode 100644 app/static/icons/circle-user.svg create mode 100644 app/static/icons/database.svg create mode 100644 app/static/icons/gear.svg create mode 100644 app/static/icons/house.svg create mode 100644 app/static/icons/landmark.svg create mode 100644 app/static/icons/list.svg create mode 100644 app/static/icons/money-bill-transfer.svg create mode 100644 app/static/icons/moon.svg create mode 100644 app/static/icons/pencil.svg create mode 100644 app/static/icons/plus.svg create mode 100644 app/static/icons/shield.svg create mode 100644 app/static/icons/sliders-h.svg create mode 100644 app/static/icons/sliders.svg create mode 100644 app/static/icons/sun.svg create mode 100644 app/static/icons/user-circle.svg create mode 100644 app/static/icons/users.svg create mode 100644 app/static/icons/wallet.svg create mode 100644 app/static/js/app.js create mode 100644 app/static/manifest.json create mode 100644 app/static/service-worker.js create mode 100644 app/templates/_ui.html create mode 100644 app/templates/admin/index.html create mode 100644 app/templates/auth/login.html create mode 100644 app/templates/auth/setup.html create mode 100644 app/templates/base.html create mode 100644 app/templates/main/analytics.html create mode 100644 app/templates/main/index.html create mode 100644 app/templates/months/index.html create mode 100644 app/templates/planning/detail.html create mode 100644 app/utils/decorators.py create mode 100644 app/utils/formatting.py create mode 100644 app/utils/uploads.py create mode 100644 app/utils/users.py create mode 100644 gunicorn.conf.py create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/46efbd09438b_add_entry_benefit_scope.py create mode 100644 migrations/versions/5f1c2e87a921_add_distribution_ranges_to_month.py create mode 100644 migrations/versions/71ff8f291d18_initial_schema.py create mode 100644 migrations/versions/8d3f0c61bb21_add_entry_allocation_target.py create mode 100644 migrations/versions/a91b7c4d2f10_add_community_accounts.py create mode 100644 migrations/versions/ab4c2d1e9a10_expand_entry_benefit_scope.py create mode 100644 migrations/versions/c1f8d92b4e31_add_avatar_fields.py create mode 100644 migrations/versions/c4a1d9b9e2f1_add_personal_split_to_month.py create mode 100644 migrations/versions/d9f3c6a1b7f0_add_user_display_name.py create mode 100644 requirements.txt create mode 100755 start.sh create mode 100644 tests/conftest.py create mode 100644 tests/test_comparison_and_allocations.py create mode 100644 tests/test_month_service.py create mode 100644 tests/test_routes.py create mode 100644 tests/test_shares_notifications_auth.py create mode 100644 wsgi.py 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("/