release: publish saldo 0.1.0
@@ -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
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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=<random>
|
||||
DATABASE_URL=sqlite:////app/data/saldo.db
|
||||
SALDO_DATA_DIR=/app/data
|
||||
SALDO_ADMIN_USERNAME=admin
|
||||
SALDO_ADMIN_PASSWORD=<random>
|
||||
SALDO_ADMIN_EMAIL=admin@example.invalid
|
||||
VAPID_PUBLIC_KEY=<public>
|
||||
VAPID_PRIVATE_KEY=<private>
|
||||
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
|
||||
```
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
from .routes import admin_bp
|
||||
|
||||
@@ -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/<int:user_id>", 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/<int:user_id>/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/<int:participant_id>", 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/<int:account_id>", 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/<int:category_id>", 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/<int:entry_id>", 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/<int:entry_id>/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/<int:rule_id>/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"))
|
||||
@@ -0,0 +1,2 @@
|
||||
from .routes import auth_bp
|
||||
|
||||
@@ -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"))
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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"
|
||||
@@ -0,0 +1,2 @@
|
||||
from .routes import main_bp
|
||||
|
||||
@@ -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/<path:filename>")
|
||||
def uploaded_avatar(filename: str):
|
||||
return send_from_directory(current_app.config["AVATAR_UPLOAD_DIR"], filename)
|
||||
@@ -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"))
|
||||
@@ -0,0 +1,2 @@
|
||||
from .routes import months_bp
|
||||
|
||||
@@ -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("/<label>/copy", methods=["POST"])
|
||||
@login_required
|
||||
def copy(label: str):
|
||||
source_month = Month.query.filter_by(label=label).first_or_404()
|
||||
target_label = request.form.get("target_label", "")
|
||||
year, month_num = [int(part) for part in target_label.split("-")]
|
||||
current_app.extensions["saldo.month_service"].copy_month(
|
||||
source_month, year, month_num, auto_created=False
|
||||
)
|
||||
db.session.commit()
|
||||
flash(f"{target_label} wurde aus {label} kopiert.", "success")
|
||||
return redirect(url_for("months.index"))
|
||||
|
||||
|
||||
@months_bp.route("/<label>/toggle-lock", methods=["POST"])
|
||||
@login_required
|
||||
def toggle_lock(label: str):
|
||||
month = Month.query.filter_by(label=label).first_or_404()
|
||||
month.is_locked = not month.is_locked
|
||||
db.session.commit()
|
||||
flash(f"Monat {label} wurde {'gesperrt' if month.is_locked else 'entsperrt'}.", "info")
|
||||
return redirect(url_for("months.index"))
|
||||
@@ -0,0 +1,2 @@
|
||||
from .routes import planning_bp
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Account,
|
||||
Category,
|
||||
CommunityAccount,
|
||||
CostParticipant,
|
||||
Entry,
|
||||
EntryShareRule,
|
||||
MonthlyIncome,
|
||||
NotificationPreference,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
ACCOUNT_TREE = {
|
||||
"gemeinschaftskonto": {
|
||||
"name": "Budgets",
|
||||
"categories": {
|
||||
"wohnen": ["Miete", "Zusatzmiete"],
|
||||
"fixkosten": [
|
||||
"Internet",
|
||||
"Mobilfunk 1",
|
||||
"Mobilfunk 2",
|
||||
"Rechtsschutz",
|
||||
"Haftpflicht",
|
||||
"Zusatzversicherung",
|
||||
"Altersvorsorge",
|
||||
"Rundfunkbeitrag",
|
||||
],
|
||||
"mitgliedschaften": [
|
||||
"Vereinsbeitrag",
|
||||
"Magazin",
|
||||
"Lernplattform",
|
||||
"Streaming 1",
|
||||
"Streaming 2",
|
||||
"Streaming 3",
|
||||
"Cloudspeicher",
|
||||
"Tabler",
|
||||
],
|
||||
"technik": ["Server", "Hosting", "Domains", "Software"],
|
||||
"energie": ["Strom", "Gas"],
|
||||
"haushalt": ["Lebensmittel", "Drogerie", "Haushaltsbedarf"],
|
||||
"mobilitaet": ["Auto", "Fahrrad"],
|
||||
"finanzen": ["Bankgebuehren", "Kreditrate 1", "Kreditrate 2"],
|
||||
},
|
||||
},
|
||||
"sparen": {"name": "Sparen", "categories": {"sparen": ["Sparziel"]}},
|
||||
"urlaub": {"name": "Urlaub", "categories": {"urlaub": ["Reisebudget"]}},
|
||||
"freizeit": {"name": "Freizeit", "categories": {"freizeit": ["Freizeitbudget"]}},
|
||||
"persoenlich-flo": {"name": "Persönlich 1", "categories": {"persoenliche-auszahlung": ["Person 1"]}},
|
||||
"persoenlich-desi": {"name": "Persönlich 2", "categories": {"persoenliche-auszahlung": ["Person 2"]}},
|
||||
}
|
||||
|
||||
COMMUNITY_ACCOUNT_DEFAULTS = [
|
||||
{
|
||||
"name": "Hauptkonto",
|
||||
"slug": "hauptkonto",
|
||||
"account_type": "shared",
|
||||
"linked_account_slug": None,
|
||||
},
|
||||
{
|
||||
"name": "Privatkonto 1",
|
||||
"slug": "privatkonto-1",
|
||||
"account_type": "personal",
|
||||
"linked_account_slug": "persoenlich-flo",
|
||||
},
|
||||
{
|
||||
"name": "Privatkonto 2",
|
||||
"slug": "privatkonto-2",
|
||||
"account_type": "personal",
|
||||
"linked_account_slug": "persoenlich-desi",
|
||||
},
|
||||
]
|
||||
|
||||
LEGACY_COMMUNITY_ACCOUNT_SLUGS = {
|
||||
"hauptkonto": "gemeinschaftskonto",
|
||||
"privatkonto-1": "flo-privat",
|
||||
"privatkonto-2": "desi-privat",
|
||||
}
|
||||
|
||||
LEGACY_CATEGORY_SLUGS = {
|
||||
("gemeinschaftskonto", "wohnen"): "miete",
|
||||
("gemeinschaftskonto", "fixkosten"): "kommunikation",
|
||||
("gemeinschaftskonto", "mitgliedschaften"): "abos",
|
||||
("gemeinschaftskonto", "technik"): "server",
|
||||
("gemeinschaftskonto", "finanzen"): "schulden",
|
||||
("persoenlich-flo", "persoenliche-auszahlung"): "flo",
|
||||
("persoenlich-desi", "persoenliche-auszahlung"): "desi",
|
||||
}
|
||||
|
||||
LEGACY_ENTRY_NAMES = {
|
||||
("persoenlich-flo", "persoenliche-auszahlung", "Person 1"): "persönliche Auszahlung",
|
||||
("persoenlich-desi", "persoenliche-auszahlung", "Person 2"): "persönliche Auszahlung",
|
||||
}
|
||||
|
||||
ENTRY_TARGET_CATEGORY = {
|
||||
"Miete": "wohnen",
|
||||
"Zusatzmiete": "wohnen",
|
||||
"Internet": "fixkosten",
|
||||
"Mobilfunk 1": "fixkosten",
|
||||
"Mobilfunk 2": "fixkosten",
|
||||
"Rechtsschutz": "fixkosten",
|
||||
"Haftpflicht": "fixkosten",
|
||||
"Zusatzversicherung": "fixkosten",
|
||||
"Altersvorsorge": "fixkosten",
|
||||
"Rundfunkbeitrag": "fixkosten",
|
||||
"Lebensmittel": "haushalt",
|
||||
"Drogerie": "haushalt",
|
||||
"Haushaltsbedarf": "haushalt",
|
||||
"Auto": "mobilitaet",
|
||||
"Fahrrad": "mobilitaet",
|
||||
"Vereinsbeitrag": "mitgliedschaften",
|
||||
"Magazin": "mitgliedschaften",
|
||||
"Lernplattform": "mitgliedschaften",
|
||||
"Streaming 1": "mitgliedschaften",
|
||||
"Streaming 2": "mitgliedschaften",
|
||||
"Streaming 3": "mitgliedschaften",
|
||||
"Cloudspeicher": "mitgliedschaften",
|
||||
"Tabler": "mitgliedschaften",
|
||||
"Server": "technik",
|
||||
"Hosting": "technik",
|
||||
"Domains": "technik",
|
||||
"Software": "technik",
|
||||
"Bankgebuehren": "finanzen",
|
||||
"Kreditrate 1": "finanzen",
|
||||
"Kreditrate 2": "finanzen",
|
||||
}
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
return (
|
||||
value.lower()
|
||||
.replace(" ", "-")
|
||||
.replace("ö", "oe")
|
||||
.replace("ü", "ue")
|
||||
.replace("ä", "ae")
|
||||
.replace("/", "-")
|
||||
.replace("+", "plus")
|
||||
)
|
||||
|
||||
|
||||
def seed_data() -> None:
|
||||
# Basisdaten nur für die fachliche Grundstruktur, ohne Demo-Benutzer,
|
||||
# Beispiel-Personen oder vorausgefüllte Monatsdaten.
|
||||
community_accounts = {}
|
||||
for sort_order, data in enumerate(COMMUNITY_ACCOUNT_DEFAULTS, start=1):
|
||||
community_account = CommunityAccount.query.filter_by(slug=data["slug"]).first()
|
||||
if community_account is None and LEGACY_COMMUNITY_ACCOUNT_SLUGS.get(data["slug"]):
|
||||
community_account = CommunityAccount.query.filter_by(
|
||||
slug=LEGACY_COMMUNITY_ACCOUNT_SLUGS[data["slug"]]
|
||||
).first()
|
||||
if community_account is None:
|
||||
community_account = CommunityAccount(
|
||||
name=data["name"],
|
||||
slug=data["slug"],
|
||||
account_type=data["account_type"],
|
||||
linked_account_slug=data["linked_account_slug"],
|
||||
sort_order=sort_order,
|
||||
is_active=True,
|
||||
)
|
||||
db.session.add(community_account)
|
||||
else:
|
||||
community_account.name = data["name"]
|
||||
community_account.slug = data["slug"]
|
||||
community_account.account_type = data["account_type"]
|
||||
community_account.linked_account_slug = data["linked_account_slug"]
|
||||
community_account.sort_order = sort_order
|
||||
community_account.is_active = True
|
||||
community_accounts[data["slug"]] = community_account
|
||||
|
||||
db.session.flush()
|
||||
|
||||
sort_order = 1
|
||||
account_categories = {}
|
||||
for account_slug, account_data in ACCOUNT_TREE.items():
|
||||
account = Account.query.filter_by(slug=account_slug).first()
|
||||
if account is None:
|
||||
account = Account(
|
||||
name=account_data["name"],
|
||||
slug=account_slug,
|
||||
sort_order=sort_order,
|
||||
is_active=True,
|
||||
)
|
||||
db.session.add(account)
|
||||
db.session.flush()
|
||||
else:
|
||||
account.name = account_data["name"]
|
||||
account.sort_order = sort_order
|
||||
account.is_active = True
|
||||
if account_slug == "freizeit":
|
||||
account.sort_order = 2
|
||||
sort_order += 1
|
||||
category_sort = 1
|
||||
account_categories[account_slug] = {}
|
||||
for category_slug, entries in account_data["categories"].items():
|
||||
category = Category.query.filter_by(account_id=account.id, slug=category_slug).first()
|
||||
legacy_slug = LEGACY_CATEGORY_SLUGS.get((account_slug, category_slug))
|
||||
if category is None and legacy_slug:
|
||||
category = Category.query.filter_by(account_id=account.id, slug=legacy_slug).first()
|
||||
if category is None:
|
||||
category = Category(
|
||||
account_id=account.id,
|
||||
community_account_id=(
|
||||
community_accounts["hauptkonto"].id
|
||||
if account_slug == "gemeinschaftskonto"
|
||||
else None
|
||||
),
|
||||
name=category_slug.replace("-", " ").title(),
|
||||
slug=category_slug,
|
||||
sort_order=category_sort,
|
||||
is_active=True,
|
||||
)
|
||||
db.session.add(category)
|
||||
db.session.flush()
|
||||
else:
|
||||
category.name = category_slug.replace("-", " ").title()
|
||||
category.slug = category_slug
|
||||
category.sort_order = category_sort
|
||||
category.is_active = True
|
||||
if account_slug == "gemeinschaftskonto" and category.community_account_id is None:
|
||||
category.community_account_id = community_accounts["hauptkonto"].id
|
||||
account_categories[account_slug][category_slug] = category
|
||||
category_sort += 1
|
||||
for index, entry_name in enumerate(entries, start=1):
|
||||
default_amount = Decimal("0.00")
|
||||
if entry_name == "Miete":
|
||||
default_amount = Decimal("920.00")
|
||||
elif entry_name == "Lebensmittel":
|
||||
default_amount = Decimal("520.00")
|
||||
elif entry_name == "Streaming 1":
|
||||
default_amount = Decimal("17.99")
|
||||
elif entry_name in {"Sparziel", "Reisebudget", "Freizeitbudget", "persönliche Auszahlung"}:
|
||||
default_amount = Decimal("0.00")
|
||||
entry_slug = slugify(entry_name)
|
||||
entry = Entry.query.filter_by(category_id=category.id, slug=entry_slug).first()
|
||||
if entry is None:
|
||||
entry = (
|
||||
Entry.query.join(Category)
|
||||
.filter(
|
||||
Category.account_id == account.id,
|
||||
Entry.slug == entry_slug,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if entry is not None:
|
||||
entry.category_id = category.id
|
||||
legacy_entry_name = LEGACY_ENTRY_NAMES.get((account_slug, category_slug, entry_name))
|
||||
if entry is None and legacy_entry_name:
|
||||
entry = Entry.query.filter_by(
|
||||
category_id=category.id,
|
||||
slug=slugify(legacy_entry_name),
|
||||
).first()
|
||||
if entry is None:
|
||||
entry = Entry(
|
||||
category_id=category.id,
|
||||
name=entry_name,
|
||||
slug=entry_slug,
|
||||
default_amount=default_amount,
|
||||
amount_type="fixed",
|
||||
benefit_scope=(
|
||||
"all-users"
|
||||
),
|
||||
is_allocation_target=entry_name in {
|
||||
"Sparziel",
|
||||
"Reisebudget",
|
||||
"Freizeitbudget",
|
||||
"Person 1",
|
||||
"Person 2",
|
||||
},
|
||||
sort_order=index,
|
||||
is_active=True,
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.flush()
|
||||
else:
|
||||
entry.name = entry_name
|
||||
entry.default_amount = default_amount
|
||||
entry.amount_type = "fixed"
|
||||
entry.benefit_scope = "all-users"
|
||||
entry.is_allocation_target = entry_name in {
|
||||
"Sparziel",
|
||||
"Reisebudget",
|
||||
"Freizeitbudget",
|
||||
"Person 1",
|
||||
"Person 2",
|
||||
}
|
||||
entry.sort_order = index
|
||||
entry.is_active = True
|
||||
|
||||
gemeinschaft = Account.query.filter_by(slug="gemeinschaftskonto").first()
|
||||
if gemeinschaft:
|
||||
target_categories = account_categories["gemeinschaftskonto"]
|
||||
with db.session.no_autoflush:
|
||||
for category in gemeinschaft.categories:
|
||||
for entry in list(category.entries):
|
||||
target_slug = ENTRY_TARGET_CATEGORY.get(entry.name)
|
||||
if not target_slug:
|
||||
continue
|
||||
target_category = target_categories.get(target_slug)
|
||||
if not target_category:
|
||||
continue
|
||||
existing_target_entry = Entry.query.filter_by(
|
||||
category_id=target_category.id,
|
||||
slug=entry.slug,
|
||||
).first()
|
||||
if existing_target_entry and existing_target_entry.id != entry.id:
|
||||
for monthly_value in entry.monthly_values:
|
||||
monthly_value.entry_id = existing_target_entry.id
|
||||
existing_rule_participants = {
|
||||
rule.participant_id for rule in existing_target_entry.share_rules
|
||||
}
|
||||
for rule in list(entry.share_rules):
|
||||
if rule.participant_id in existing_rule_participants:
|
||||
db.session.delete(rule)
|
||||
continue
|
||||
rule.entry_id = existing_target_entry.id
|
||||
db.session.delete(entry)
|
||||
continue
|
||||
entry.category_id = target_category.id
|
||||
entry.benefit_scope = "all-users"
|
||||
entry.is_allocation_target = entry.name in {
|
||||
"Sparziel",
|
||||
"Reisebudget",
|
||||
"Freizeitbudget",
|
||||
"Person 1",
|
||||
"Person 2",
|
||||
}
|
||||
for category in gemeinschaft.categories:
|
||||
if category.slug not in ACCOUNT_TREE["gemeinschaftskonto"]["categories"]:
|
||||
category.is_active = False
|
||||
elif category.community_account_id is None:
|
||||
category.community_account_id = community_accounts["hauptkonto"].id
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def seed_demo_data() -> None:
|
||||
from datetime import date
|
||||
from flask import current_app
|
||||
|
||||
seed_data()
|
||||
|
||||
admin = User.query.filter_by(username="admin").first()
|
||||
if admin is None:
|
||||
admin = User(username="admin", display_name="Admin", email="admin@example.invalid", role="admin")
|
||||
admin.set_password("testpass")
|
||||
db.session.add(admin)
|
||||
|
||||
editor_a = User.query.filter_by(username="mitglied1").first()
|
||||
if editor_a is None:
|
||||
editor_a = User(
|
||||
username="mitglied1",
|
||||
display_name="Person A",
|
||||
email="person-a@example.invalid",
|
||||
role="editor",
|
||||
)
|
||||
editor_a.set_password("testpass")
|
||||
db.session.add(editor_a)
|
||||
|
||||
editor_b = User.query.filter_by(username="mitglied2").first()
|
||||
if editor_b is None:
|
||||
editor_b = User(
|
||||
username="mitglied2",
|
||||
display_name="Person B",
|
||||
email="person-b@example.invalid",
|
||||
role="editor",
|
||||
)
|
||||
editor_b.set_password("testpass")
|
||||
db.session.add(editor_b)
|
||||
|
||||
db.session.flush()
|
||||
|
||||
for user in [admin, editor_a, editor_b]:
|
||||
if user.notification_preference is None:
|
||||
db.session.add(NotificationPreference(user_id=user.id))
|
||||
|
||||
participants = {
|
||||
"Person A": CostParticipant.query.filter_by(name="Person A").first(),
|
||||
"Person B": CostParticipant.query.filter_by(name="Person B").first(),
|
||||
"Gast": CostParticipant.query.filter_by(name="Gast").first(),
|
||||
}
|
||||
if participants["Person A"] is None:
|
||||
participants["Person A"] = CostParticipant(
|
||||
name="Person A", is_app_user=True, linked_user_id=editor_a.id, is_external=False
|
||||
)
|
||||
db.session.add(participants["Person A"])
|
||||
participants["Person A"].is_app_user = True
|
||||
participants["Person A"].linked_user_id = editor_a.id
|
||||
participants["Person A"].is_external = False
|
||||
participants["Person A"].avatar_url = editor_a.avatar_url
|
||||
|
||||
if participants["Person B"] is None:
|
||||
participants["Person B"] = CostParticipant(
|
||||
name="Person B", is_app_user=True, linked_user_id=editor_b.id, is_external=False
|
||||
)
|
||||
db.session.add(participants["Person B"])
|
||||
participants["Person B"].is_app_user = True
|
||||
participants["Person B"].linked_user_id = editor_b.id
|
||||
participants["Person B"].is_external = False
|
||||
|
||||
if participants["Gast"] is None:
|
||||
participants["Gast"] = CostParticipant(name="Gast", is_app_user=False, is_external=True)
|
||||
db.session.add(participants["Gast"])
|
||||
participants["Gast"].is_app_user = False
|
||||
participants["Gast"].is_external = True
|
||||
|
||||
db.session.flush()
|
||||
|
||||
shared_entry = Entry.query.join(Category).filter(Entry.name == "Streaming 1").first()
|
||||
if shared_entry is not None:
|
||||
for person in participants.values():
|
||||
rule = EntryShareRule.query.filter_by(
|
||||
entry_id=shared_entry.id, participant_id=person.id
|
||||
).first()
|
||||
if rule is None:
|
||||
db.session.add(
|
||||
EntryShareRule(
|
||||
entry_id=shared_entry.id,
|
||||
participant_id=person.id,
|
||||
share_type="equal",
|
||||
)
|
||||
)
|
||||
|
||||
month_service = current_app.extensions["saldo.month_service"]
|
||||
month = month_service.ensure_month(date(2026, 4, 1))
|
||||
|
||||
while len(month.incomes) < 2:
|
||||
db.session.add(
|
||||
MonthlyIncome(
|
||||
month_id=month.id,
|
||||
label=f"Einkommen {len(month.incomes) + 1}",
|
||||
amount=Decimal("0.00"),
|
||||
sort_order=len(month.incomes) + 1,
|
||||
)
|
||||
)
|
||||
db.session.flush()
|
||||
|
||||
if not month.incomes:
|
||||
db.session.flush()
|
||||
for income in month.incomes:
|
||||
if income.sort_order == 1:
|
||||
income.label = "Einkommen 1"
|
||||
income.amount = Decimal("3100.00")
|
||||
else:
|
||||
income.label = "Einkommen 2"
|
||||
income.amount = Decimal("2450.00")
|
||||
for allocation in month.allocations:
|
||||
if allocation.target_account.slug == "sparen":
|
||||
allocation.amount = Decimal("350.00")
|
||||
elif allocation.target_account.slug == "urlaub":
|
||||
allocation.amount = Decimal("180.00")
|
||||
elif allocation.target_account.slug == "freizeit":
|
||||
allocation.amount = Decimal("120.00")
|
||||
else:
|
||||
allocation.amount = Decimal("220.00")
|
||||
|
||||
month_service.refresh_suggestions(month, "Demo-Daten initialisiert")
|
||||
db.session.commit()
|
||||
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Account, AllocationSuggestion, MonthlyAllocation, to_decimal
|
||||
from app.utils.users import personal_account_names
|
||||
|
||||
|
||||
class AllocationSuggestionService:
|
||||
TARGET_SLUGS = ["sparen", "urlaub", "freizeit", "persoenlich-flo", "persoenlich-desi"]
|
||||
PRIORITY_TARGETS = ["sparen", "urlaub", "freizeit"]
|
||||
PERSONAL_TARGETS = ["persoenlich-flo", "persoenlich-desi"]
|
||||
|
||||
def __init__(self, target_rules: dict[str, dict], default_personal_split_desi_pct: float):
|
||||
self.target_rules = target_rules
|
||||
self.default_personal_split_desi_pct = default_personal_split_desi_pct
|
||||
|
||||
FIELD_MAP = {
|
||||
"sparen": ("savings_min_pct", "savings_max_pct", "Sparen"),
|
||||
"urlaub": ("vacation_min_pct", "vacation_max_pct", "Urlaub"),
|
||||
"freizeit": ("leisure_min_pct", "leisure_max_pct", "Freizeit"),
|
||||
}
|
||||
|
||||
def recompute(
|
||||
self,
|
||||
month,
|
||||
distributable_total: Decimal,
|
||||
total_income: Decimal,
|
||||
reason: str = "",
|
||||
) -> list[AllocationSuggestion]:
|
||||
distributable_total = max(Decimal("0.00"), to_decimal(distributable_total))
|
||||
total_income = max(Decimal("0.00"), to_decimal(total_income))
|
||||
targets = self._target_accounts()
|
||||
allocations = {item.target_account.slug: item for item in month.allocations if item.target_account}
|
||||
suggested_values = {slug: Decimal("0.00") for slug in self.TARGET_SLUGS}
|
||||
|
||||
for slug in self.PRIORITY_TARGETS:
|
||||
allocation = allocations.get(slug)
|
||||
current_amount = to_decimal(allocation.amount) if allocation is not None else Decimal("0.00")
|
||||
target_amount = self._minimum_target_amount(total_income, month, slug)
|
||||
suggested_values[slug] = max(current_amount, target_amount) if current_amount > target_amount else target_amount
|
||||
|
||||
for slug in self.PERSONAL_TARGETS:
|
||||
allocation = allocations.get(slug)
|
||||
if allocation is not None:
|
||||
suggested_values[slug] = to_decimal(allocation.amount)
|
||||
|
||||
AllocationSuggestion.query.filter_by(month_id=month.id).delete()
|
||||
suggestions = []
|
||||
for account in targets:
|
||||
rule = self.month_target_rule(month, account.slug)
|
||||
reason_text = reason or self._reason_for(account.slug, rule, month.personal_split_desi_pct)
|
||||
suggestion = AllocationSuggestion(
|
||||
month_id=month.id,
|
||||
target_account_id=account.id,
|
||||
suggested_amount=max(Decimal("0.00"), to_decimal(suggested_values.get(account.slug, 0))),
|
||||
reason=reason_text,
|
||||
strategy_key="income-targets-then-personal-split",
|
||||
)
|
||||
db.session.add(suggestion)
|
||||
suggestions.append(suggestion)
|
||||
db.session.flush()
|
||||
return suggestions
|
||||
|
||||
def accept_all(self, month) -> None:
|
||||
suggestions = {
|
||||
item.target_account_id: item
|
||||
for item in AllocationSuggestion.query.filter_by(month_id=month.id).all()
|
||||
}
|
||||
allocations = {
|
||||
item.target_account_id: item for item in MonthlyAllocation.query.filter_by(month_id=month.id).all()
|
||||
}
|
||||
for target_account in self._target_accounts():
|
||||
suggestion = suggestions.get(target_account.id)
|
||||
allocation = allocations.get(target_account.id)
|
||||
if not suggestion:
|
||||
continue
|
||||
if allocation is None:
|
||||
allocation = MonthlyAllocation(
|
||||
month_id=month.id,
|
||||
target_account_id=target_account.id,
|
||||
label=target_account.name,
|
||||
sort_order=target_account.sort_order,
|
||||
source="accepted_suggestion",
|
||||
amount=suggestion.suggested_amount,
|
||||
)
|
||||
db.session.add(allocation)
|
||||
continue
|
||||
if allocation.is_locked:
|
||||
continue
|
||||
allocation.amount = suggestion.suggested_amount
|
||||
allocation.source = "accepted_suggestion"
|
||||
|
||||
def accept_single(self, month, account_id: int) -> None:
|
||||
suggestion = AllocationSuggestion.query.filter_by(
|
||||
month_id=month.id, target_account_id=account_id
|
||||
).first()
|
||||
if not suggestion:
|
||||
return
|
||||
allocation = MonthlyAllocation.query.filter_by(
|
||||
month_id=month.id, target_account_id=account_id
|
||||
).first()
|
||||
if allocation is None:
|
||||
account = db.session.get(Account, account_id)
|
||||
allocation = MonthlyAllocation(
|
||||
month_id=month.id,
|
||||
target_account_id=account_id,
|
||||
label=account.name,
|
||||
amount=suggestion.suggested_amount,
|
||||
source="accepted_suggestion",
|
||||
sort_order=account.sort_order,
|
||||
)
|
||||
db.session.add(allocation)
|
||||
return
|
||||
if not allocation.is_locked:
|
||||
allocation.amount = suggestion.suggested_amount
|
||||
allocation.source = "accepted_suggestion"
|
||||
|
||||
def strategy_hint(self, month, slug: str) -> dict[str, str | Decimal] | None:
|
||||
rule = self.month_target_rule(month, slug)
|
||||
if rule is None:
|
||||
return None
|
||||
return {
|
||||
"label": rule.get("label", slug.title()),
|
||||
"range_label": f"{int(rule['min_pct'])} bis {int(rule['max_pct'])} %",
|
||||
"min_pct": to_decimal(rule["min_pct"]),
|
||||
"max_pct": to_decimal(rule["max_pct"]),
|
||||
}
|
||||
|
||||
def month_target_rule(self, month, slug: str) -> dict | None:
|
||||
field_map = self.FIELD_MAP.get(slug)
|
||||
base_rule = self.target_rules.get(slug)
|
||||
if field_map is None or base_rule is None:
|
||||
return base_rule
|
||||
min_field, max_field, label = field_map
|
||||
min_pct = to_decimal(getattr(month, min_field, Decimal(str(base_rule["min_pct"] * 100))))
|
||||
max_pct = to_decimal(getattr(month, max_field, Decimal(str(base_rule["max_pct"] * 100))))
|
||||
return {
|
||||
"label": label,
|
||||
"min_pct": min_pct,
|
||||
"max_pct": max_pct,
|
||||
"recommended_pct": ((min_pct + max_pct) / Decimal("2.00")).quantize(Decimal("0.01")),
|
||||
}
|
||||
|
||||
def _target_accounts(self) -> list[Account]:
|
||||
stmt = (
|
||||
select(Account)
|
||||
.where(Account.slug.in_(self.TARGET_SLUGS), Account.is_active.is_(True))
|
||||
.order_by(Account.sort_order.asc(), Account.id.asc())
|
||||
)
|
||||
return list(db.session.scalars(stmt))
|
||||
|
||||
def _target_amount(self, total_income: Decimal, month, slug: str) -> Decimal:
|
||||
rule = self.month_target_rule(month, slug)
|
||||
if rule is None:
|
||||
return Decimal("0.00")
|
||||
recommended_pct = to_decimal(rule["recommended_pct"]) / Decimal("100.00")
|
||||
return (total_income * recommended_pct).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
|
||||
def _minimum_target_amount(self, total_income: Decimal, month, slug: str) -> Decimal:
|
||||
rule = self.month_target_rule(month, slug)
|
||||
if rule is None:
|
||||
return Decimal("0.00")
|
||||
min_pct = to_decimal(rule["min_pct"]) / Decimal("100.00")
|
||||
return (total_income * min_pct).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
|
||||
def _personal_split_values(
|
||||
self,
|
||||
remaining_pool: Decimal,
|
||||
desi_pct: Decimal,
|
||||
unlocked_targets: list[str],
|
||||
) -> dict[str, Decimal]:
|
||||
if remaining_pool <= Decimal("0.00") or not unlocked_targets:
|
||||
return {slug: Decimal("0.00") for slug in self.PERSONAL_TARGETS}
|
||||
|
||||
desi_pct = min(Decimal("100.00"), max(Decimal("0.00"), to_decimal(desi_pct)))
|
||||
flo_pct = Decimal("100.00") - desi_pct
|
||||
weights = {
|
||||
"persoenlich-flo": flo_pct,
|
||||
"persoenlich-desi": desi_pct,
|
||||
}
|
||||
unlocked_weight_total = sum((weights[slug] for slug in unlocked_targets), Decimal("0.00"))
|
||||
if unlocked_weight_total <= Decimal("0.00"):
|
||||
even_share = (remaining_pool / Decimal(len(unlocked_targets))).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
result = {slug: Decimal("0.00") for slug in self.PERSONAL_TARGETS}
|
||||
assigned = Decimal("0.00")
|
||||
for index, slug in enumerate(unlocked_targets):
|
||||
if index == len(unlocked_targets) - 1:
|
||||
result[slug] = remaining_pool - assigned
|
||||
else:
|
||||
result[slug] = even_share
|
||||
assigned += even_share
|
||||
return result
|
||||
|
||||
result = {slug: Decimal("0.00") for slug in self.PERSONAL_TARGETS}
|
||||
assigned = Decimal("0.00")
|
||||
for index, slug in enumerate(unlocked_targets):
|
||||
if index == len(unlocked_targets) - 1:
|
||||
amount = remaining_pool - assigned
|
||||
else:
|
||||
ratio = weights[slug] / unlocked_weight_total
|
||||
amount = (remaining_pool * ratio).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
assigned += amount
|
||||
result[slug] = max(Decimal("0.00"), amount)
|
||||
return result
|
||||
|
||||
def _reason_for(self, slug: str, rule: dict | None, desi_pct: Decimal) -> str:
|
||||
if slug in self.PRIORITY_TARGETS and rule is not None:
|
||||
return (
|
||||
f"Zielbereich {to_decimal(rule['min_pct'])} bis {to_decimal(rule['max_pct'])} % "
|
||||
f"vom Einkommen."
|
||||
)
|
||||
flo_pct = Decimal("100.00") - to_decimal(desi_pct)
|
||||
personal_labels = personal_account_names()
|
||||
return (
|
||||
"Restbetrag nach Sparen, Urlaub und Freizeit mit Split "
|
||||
f"{personal_labels['persoenlich-flo']} {flo_pct} % / "
|
||||
f"{personal_labels['persoenlich-desi']} {to_decimal(desi_pct)} %."
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models import MonthlyAllocation, MonthlyEntryValue, MonthlyIncome, to_decimal
|
||||
|
||||
|
||||
class ComparisonService:
|
||||
def month_delta(self, current_month, previous_month) -> dict:
|
||||
if previous_month is None:
|
||||
zero = Decimal("0.00")
|
||||
return {
|
||||
"income_delta": zero,
|
||||
"cost_delta": zero,
|
||||
"remainder_delta": zero,
|
||||
"allocation_delta": zero,
|
||||
}
|
||||
return {
|
||||
"income_delta": self._total_income(current_month) - self._total_income(previous_month),
|
||||
"cost_delta": self._total_costs(current_month) - self._total_costs(previous_month),
|
||||
"remainder_delta": self._remainder(current_month) - self._remainder(previous_month),
|
||||
"allocation_delta": self._total_allocations(current_month)
|
||||
- self._total_allocations(previous_month),
|
||||
}
|
||||
|
||||
def top_entry_changes(self, current_month, previous_month, limit: int = 6) -> list[dict]:
|
||||
if previous_month is None:
|
||||
return []
|
||||
previous_values = {
|
||||
item.entry_id: to_decimal(item.planned_amount) for item in previous_month.entry_values
|
||||
}
|
||||
changes = []
|
||||
for item in current_month.entry_values:
|
||||
previous_amount = previous_values.get(item.entry_id, Decimal("0.00"))
|
||||
current_amount = to_decimal(item.planned_amount)
|
||||
delta = current_amount - previous_amount
|
||||
if delta:
|
||||
changes.append(
|
||||
{
|
||||
"entry_name": item.entry.name,
|
||||
"category_name": item.entry.category.name,
|
||||
"delta": delta,
|
||||
"current_amount": current_amount,
|
||||
"previous_amount": previous_amount,
|
||||
}
|
||||
)
|
||||
changes.sort(key=lambda item: abs(item["delta"]), reverse=True)
|
||||
return changes[:limit]
|
||||
|
||||
def _total_income(self, month) -> Decimal:
|
||||
return sum((to_decimal(item.amount) for item in month.incomes), Decimal("0.00"))
|
||||
|
||||
def _total_costs(self, month) -> Decimal:
|
||||
return sum((to_decimal(item.planned_amount) for item in month.entry_values), Decimal("0.00"))
|
||||
|
||||
def _total_allocations(self, month) -> Decimal:
|
||||
return sum((to_decimal(item.amount) for item in month.allocations), Decimal("0.00"))
|
||||
|
||||
def _remainder(self, month) -> Decimal:
|
||||
return self._total_income(month) - self._total_costs(month)
|
||||
@@ -0,0 +1,429 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Account,
|
||||
Category,
|
||||
Entry,
|
||||
EntryShareRule,
|
||||
Month,
|
||||
MonthlyAllocation,
|
||||
MonthlyEntryValue,
|
||||
MonthlyIncome,
|
||||
to_decimal,
|
||||
)
|
||||
from app.services.allocation_service import AllocationSuggestionService
|
||||
from app.services.comparison_service import ComparisonService
|
||||
from app.services.share_service import ShareCalculationService
|
||||
from app.utils.users import personal_account_names
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonthSummary:
|
||||
total_income: Decimal
|
||||
fixed_costs: Decimal
|
||||
distribution_pool: Decimal
|
||||
total_costs: Decimal
|
||||
remainder: Decimal
|
||||
allocation_total: Decimal
|
||||
suggestion_total: Decimal
|
||||
current_allocations: list
|
||||
suggestions: list
|
||||
deltas: dict
|
||||
top_changes: list
|
||||
external_totals: list
|
||||
|
||||
|
||||
class MonthService:
|
||||
DISTRIBUTION_ENTRY_PREFERENCES = {
|
||||
"sparen": "Sparziel",
|
||||
"urlaub": "Reisebudget",
|
||||
"freizeit": "Freizeitbudget",
|
||||
"persoenlich-flo": "Person 1",
|
||||
"persoenlich-desi": "Person 2",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
allocation_service: AllocationSuggestionService,
|
||||
comparison_service: ComparisonService,
|
||||
share_service: ShareCalculationService,
|
||||
):
|
||||
self.allocation_service = allocation_service
|
||||
self.comparison_service = comparison_service
|
||||
self.share_service = share_service
|
||||
|
||||
def ensure_month(self, target: date | None = None) -> Month:
|
||||
target = target or date.today()
|
||||
label = f"{target.year:04d}-{target.month:02d}"
|
||||
month = Month.query.filter_by(label=label).first()
|
||||
if month:
|
||||
return month
|
||||
latest = Month.query.order_by(Month.year.desc(), Month.month.desc()).first()
|
||||
if latest:
|
||||
# Neue Monate erben den letzten gepflegten Stand, damit der Alltag
|
||||
# mit einem realistischen Ausgangspunkt startet statt mit leeren Formularen.
|
||||
month = self.copy_month(latest, target.year, target.month, auto_created=True)
|
||||
else:
|
||||
# Beim allerersten Start legen wir einen nutzbaren Seed-Monat mit
|
||||
# Standardkonten, Einkommenszeilen und Defaultwerten an.
|
||||
month = self.seed_initial_month(target.year, target.month)
|
||||
db.session.commit()
|
||||
return month
|
||||
|
||||
def seed_initial_month(self, year: int, month_num: int) -> Month:
|
||||
month = Month(
|
||||
label=f"{year:04d}-{month_num:02d}",
|
||||
year=year,
|
||||
month=month_num,
|
||||
auto_created=True,
|
||||
savings_min_pct=Decimal("15.00"),
|
||||
savings_max_pct=Decimal("20.00"),
|
||||
vacation_min_pct=Decimal("5.00"),
|
||||
vacation_max_pct=Decimal("8.00"),
|
||||
leisure_min_pct=Decimal("5.00"),
|
||||
leisure_max_pct=Decimal("10.00"),
|
||||
personal_split_desi_pct=Decimal(str(self.allocation_service.default_personal_split_desi_pct)),
|
||||
)
|
||||
db.session.add(month)
|
||||
db.session.flush()
|
||||
entries = db.session.scalars(select(Entry).where(Entry.is_active.is_(True))).all()
|
||||
for entry in entries:
|
||||
db.session.add(
|
||||
MonthlyEntryValue(
|
||||
month_id=month.id,
|
||||
entry_id=entry.id,
|
||||
planned_amount=to_decimal(entry.default_amount),
|
||||
)
|
||||
)
|
||||
self._create_default_income_lines(month)
|
||||
self._ensure_allocations(month)
|
||||
return month
|
||||
|
||||
def copy_month(
|
||||
self, source_month: Month, year: int, month_num: int, auto_created: bool = False
|
||||
) -> Month:
|
||||
label = f"{year:04d}-{month_num:02d}"
|
||||
month = Month(
|
||||
label=label,
|
||||
year=year,
|
||||
month=month_num,
|
||||
auto_created=auto_created,
|
||||
savings_min_pct=source_month.savings_min_pct,
|
||||
savings_max_pct=source_month.savings_max_pct,
|
||||
vacation_min_pct=source_month.vacation_min_pct,
|
||||
vacation_max_pct=source_month.vacation_max_pct,
|
||||
leisure_min_pct=source_month.leisure_min_pct,
|
||||
leisure_max_pct=source_month.leisure_max_pct,
|
||||
personal_split_desi_pct=source_month.personal_split_desi_pct,
|
||||
)
|
||||
db.session.add(month)
|
||||
db.session.flush()
|
||||
|
||||
for value in source_month.entry_values:
|
||||
db.session.add(
|
||||
MonthlyEntryValue(
|
||||
month_id=month.id,
|
||||
entry_id=value.entry_id,
|
||||
planned_amount=value.planned_amount,
|
||||
note=value.note,
|
||||
created_by=value.created_by,
|
||||
updated_by=value.updated_by,
|
||||
)
|
||||
)
|
||||
for income in source_month.incomes:
|
||||
db.session.add(
|
||||
MonthlyIncome(
|
||||
month_id=month.id,
|
||||
label=income.label,
|
||||
amount=income.amount,
|
||||
sort_order=income.sort_order,
|
||||
)
|
||||
)
|
||||
for allocation in source_month.allocations:
|
||||
db.session.add(
|
||||
MonthlyAllocation(
|
||||
month_id=month.id,
|
||||
target_account_id=allocation.target_account_id,
|
||||
label=allocation.label,
|
||||
amount=allocation.amount,
|
||||
source=allocation.source,
|
||||
is_locked=allocation.is_locked,
|
||||
sort_order=allocation.sort_order,
|
||||
)
|
||||
)
|
||||
self._ensure_allocations(month)
|
||||
return month
|
||||
|
||||
def get_or_create_by_label(self, label: str) -> Month:
|
||||
year, month_num = [int(part) for part in label.split("-")]
|
||||
month = Month.query.filter_by(label=label).first()
|
||||
if month:
|
||||
return month
|
||||
previous = self.previous_month(year, month_num)
|
||||
if previous:
|
||||
month = self.copy_month(previous, year, month_num, auto_created=True)
|
||||
else:
|
||||
month = self.seed_initial_month(year, month_num)
|
||||
db.session.commit()
|
||||
return month
|
||||
|
||||
def previous_month(self, year: int, month_num: int) -> Month | None:
|
||||
stmt = (
|
||||
select(Month)
|
||||
.where((Month.year < year) | ((Month.year == year) & (Month.month < month_num)))
|
||||
.order_by(Month.year.desc(), Month.month.desc())
|
||||
)
|
||||
return db.session.scalars(stmt).first()
|
||||
|
||||
def compute_summary(self, month: Month) -> MonthSummary:
|
||||
total_income = sum((to_decimal(item.amount) for item in month.incomes), Decimal("0.00"))
|
||||
distribution_entry_values = self._distribution_entry_values(month)
|
||||
distribution_entry_ids = {value.entry_id for value in distribution_entry_values.values()}
|
||||
fixed_costs = sum(
|
||||
(
|
||||
self.share_service.calculate_entry_shares(item)["internal_total"]
|
||||
for item in month.entry_values
|
||||
if item.entry_id not in distribution_entry_ids
|
||||
),
|
||||
Decimal("0.00"),
|
||||
)
|
||||
allocation_total = sum((to_decimal(item.amount) for item in month.allocations), Decimal("0.00"))
|
||||
total_costs = fixed_costs + allocation_total
|
||||
distribution_pool = total_income - fixed_costs
|
||||
remainder = distribution_pool - allocation_total
|
||||
suggestions = month.suggestions
|
||||
allocation_amounts = {
|
||||
item.target_account_id: to_decimal(item.amount)
|
||||
for item in month.allocations
|
||||
if item.target_account_id is not None
|
||||
}
|
||||
suggestion_total = sum(
|
||||
(
|
||||
max(
|
||||
Decimal("0.00"),
|
||||
to_decimal(item.suggested_amount) - allocation_amounts.get(item.target_account_id, Decimal("0.00")),
|
||||
)
|
||||
for item in suggestions
|
||||
),
|
||||
Decimal("0.00"),
|
||||
)
|
||||
previous = self.previous_month(month.year, month.month)
|
||||
deltas = self.comparison_service.month_delta(month, previous)
|
||||
top_changes = self.comparison_service.top_entry_changes(month, previous)
|
||||
external_totals = self.share_service.calculate_external_month_totals(month)
|
||||
return MonthSummary(
|
||||
total_income=total_income,
|
||||
fixed_costs=fixed_costs,
|
||||
distribution_pool=distribution_pool,
|
||||
total_costs=total_costs,
|
||||
remainder=remainder,
|
||||
allocation_total=allocation_total,
|
||||
suggestion_total=suggestion_total,
|
||||
current_allocations=sorted(month.allocations, key=lambda item: item.sort_order),
|
||||
suggestions=sorted(suggestions, key=lambda item: item.target_account.sort_order),
|
||||
deltas=deltas,
|
||||
top_changes=top_changes,
|
||||
external_totals=external_totals,
|
||||
)
|
||||
|
||||
def refresh_suggestions(self, month: Month, reason: str = "") -> list:
|
||||
summary = self.compute_summary(month)
|
||||
self.apply_personal_remainder(month, summary.distribution_pool)
|
||||
db.session.flush()
|
||||
summary = self.compute_summary(month)
|
||||
# Vorschläge basieren auf dem gesamten Verteilungstopf nach Fixkosten.
|
||||
# So bleiben die Richtwerte stabil, auch wenn bereits manuelle Werte in
|
||||
# Sparen, Urlaub, Freizeit oder persönlicher Auszahlung stehen.
|
||||
suggestions = self.allocation_service.recompute(
|
||||
month,
|
||||
summary.distribution_pool,
|
||||
summary.total_income,
|
||||
reason=reason or "Aktueller Restbetrag wurde neu berechnet",
|
||||
)
|
||||
db.session.flush()
|
||||
return suggestions
|
||||
|
||||
def apply_personal_remainder(self, month: Month, distribution_pool: Decimal) -> bool:
|
||||
allocations_by_slug = {
|
||||
item.target_account.slug: item
|
||||
for item in month.allocations
|
||||
if item.target_account and item.target_account.slug in self.allocation_service.TARGET_SLUGS
|
||||
}
|
||||
committed_priority_total = sum(
|
||||
(
|
||||
to_decimal(allocations_by_slug.get(slug).amount)
|
||||
for slug in ("sparen", "urlaub", "freizeit")
|
||||
if allocations_by_slug.get(slug) is not None
|
||||
),
|
||||
Decimal("0.00"),
|
||||
)
|
||||
remaining_pool = max(Decimal("0.00"), to_decimal(distribution_pool) - committed_priority_total)
|
||||
desi_pct = min(Decimal("100.00"), max(Decimal("0.00"), to_decimal(month.personal_split_desi_pct)))
|
||||
flo_pct = Decimal("100.00") - desi_pct
|
||||
flo_amount = (remaining_pool * flo_pct / Decimal("100.00")).quantize(Decimal("0.01"))
|
||||
desi_amount = remaining_pool - flo_amount
|
||||
|
||||
changed = False
|
||||
target_values = {
|
||||
"persoenlich-flo": flo_amount,
|
||||
"persoenlich-desi": desi_amount,
|
||||
}
|
||||
for slug, target_amount in target_values.items():
|
||||
allocation = allocations_by_slug.get(slug)
|
||||
if allocation is None:
|
||||
continue
|
||||
if to_decimal(allocation.amount) != to_decimal(target_amount):
|
||||
allocation.amount = to_decimal(target_amount)
|
||||
allocation.source = "remainder_auto"
|
||||
changed = True
|
||||
if changed:
|
||||
self.sync_distribution_entries_from_allocations(month)
|
||||
return changed
|
||||
|
||||
def sync_distribution_entries_from_allocations(self, month: Month) -> bool:
|
||||
changed = False
|
||||
distribution_values = self._distribution_entry_values(month)
|
||||
allocations_by_slug = {
|
||||
item.target_account.slug: item
|
||||
for item in month.allocations
|
||||
if item.target_account and item.target_account.slug in self.allocation_service.TARGET_SLUGS
|
||||
}
|
||||
for account_slug, allocation in allocations_by_slug.items():
|
||||
value = distribution_values.get(account_slug)
|
||||
if value is None:
|
||||
continue
|
||||
allocation_amount = to_decimal(allocation.amount)
|
||||
if to_decimal(value.planned_amount) != allocation_amount:
|
||||
value.planned_amount = allocation_amount
|
||||
changed = True
|
||||
display_label = self._distribution_label(account_slug, value.entry.name)
|
||||
if allocation.label != display_label:
|
||||
allocation.label = display_label
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
def sync_distribution_allocation_from_entry(
|
||||
self, month: Month, entry: Entry, mark_manual: bool = False
|
||||
) -> bool:
|
||||
account = entry.category.account if entry.category else None
|
||||
if account is None or account.slug not in self.allocation_service.TARGET_SLUGS:
|
||||
return False
|
||||
value = next((item for item in month.entry_values if item.entry_id == entry.id), None)
|
||||
if value is None:
|
||||
return False
|
||||
allocation = next(
|
||||
(
|
||||
item
|
||||
for item in month.allocations
|
||||
if item.target_account and item.target_account.slug == account.slug
|
||||
),
|
||||
None,
|
||||
)
|
||||
if allocation is None:
|
||||
return False
|
||||
changed = False
|
||||
entry_amount = to_decimal(value.planned_amount)
|
||||
if to_decimal(allocation.amount) != entry_amount:
|
||||
allocation.amount = entry_amount
|
||||
changed = True
|
||||
display_label = self._distribution_label(account.slug, entry.name)
|
||||
if allocation.label != display_label:
|
||||
allocation.label = display_label
|
||||
changed = True
|
||||
if mark_manual and changed:
|
||||
allocation.source = "manual"
|
||||
return changed
|
||||
|
||||
def _create_default_income_lines(self, month: Month) -> None:
|
||||
labels = ["Einkommen 1", "Einkommen 2"]
|
||||
for sort_order, label in enumerate(labels, start=1):
|
||||
db.session.add(
|
||||
MonthlyIncome(month_id=month.id, label=label, amount=Decimal("0.00"), sort_order=sort_order)
|
||||
)
|
||||
|
||||
def _ensure_allocations(self, month: Month) -> None:
|
||||
target_accounts = self.allocation_service._target_accounts()
|
||||
existing = {item.target_account_id for item in month.allocations}
|
||||
for account in target_accounts:
|
||||
if account.id in existing:
|
||||
continue
|
||||
db.session.add(
|
||||
MonthlyAllocation(
|
||||
month_id=month.id,
|
||||
target_account_id=account.id,
|
||||
label=self._distribution_label(account.slug, account.name),
|
||||
amount=Decimal("0.00"),
|
||||
source="manual",
|
||||
sort_order=account.sort_order,
|
||||
)
|
||||
)
|
||||
|
||||
def _distribution_entry_values(self, month: Month) -> dict[str, MonthlyEntryValue]:
|
||||
grouped: dict[str, list[MonthlyEntryValue]] = {}
|
||||
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
|
||||
or account.slug not in self.allocation_service.TARGET_SLUGS
|
||||
or not entry.is_allocation_target
|
||||
):
|
||||
continue
|
||||
grouped.setdefault(account.slug, []).append(value)
|
||||
|
||||
preferred = {}
|
||||
for account_slug, values in grouped.items():
|
||||
preferred_name = self.DISTRIBUTION_ENTRY_PREFERENCES.get(account_slug)
|
||||
values.sort(
|
||||
key=lambda item: (
|
||||
item.entry.name != preferred_name,
|
||||
item.entry.sort_order,
|
||||
item.entry.id,
|
||||
)
|
||||
)
|
||||
preferred[account_slug] = values[0]
|
||||
return preferred
|
||||
|
||||
def _distribution_label(self, account_slug: str, fallback: str) -> str:
|
||||
personal_labels = personal_account_names()
|
||||
if account_slug in personal_labels:
|
||||
return personal_labels[account_slug]
|
||||
return fallback
|
||||
|
||||
def normalize_personal_split(self, month: Month, flo_pct: object, desi_pct: object) -> None:
|
||||
flo_value = min(Decimal("100.00"), max(Decimal("0.00"), to_decimal(flo_pct)))
|
||||
desi_value = min(Decimal("100.00"), max(Decimal("0.00"), to_decimal(desi_pct)))
|
||||
total = flo_value + desi_value
|
||||
if total == Decimal("100.00"):
|
||||
month.personal_split_desi_pct = desi_value
|
||||
return
|
||||
month.personal_split_desi_pct = Decimal("100.00") - flo_value
|
||||
|
||||
def update_target_range(self, month: Month, slug: str, min_pct: object, max_pct: object) -> None:
|
||||
field_map = {
|
||||
"sparen": ("savings_min_pct", "savings_max_pct"),
|
||||
"urlaub": ("vacation_min_pct", "vacation_max_pct"),
|
||||
"freizeit": ("leisure_min_pct", "leisure_max_pct"),
|
||||
}
|
||||
target_fields = field_map.get(slug)
|
||||
if target_fields is None:
|
||||
return
|
||||
min_value = min(Decimal("100.00"), max(Decimal("0.00"), to_decimal(min_pct)))
|
||||
max_value = min(Decimal("100.00"), max(Decimal("0.00"), to_decimal(max_pct)))
|
||||
if min_value > max_value:
|
||||
min_value, max_value = max_value, min_value
|
||||
setattr(month, target_fields[0], min_value)
|
||||
setattr(month, target_fields[1], max_value)
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import InAppNotification, Month, NotificationPreference, User
|
||||
|
||||
|
||||
class NotificationService:
|
||||
def __init__(self, month_service, push_service, threshold: float):
|
||||
self.month_service = month_service
|
||||
self.push_service = push_service
|
||||
self.threshold = threshold
|
||||
|
||||
def run_monthly_checks(self, today: date | None = None) -> int:
|
||||
today = today or date.today()
|
||||
current_month = self.month_service.ensure_month(today)
|
||||
next_month_date = (today.replace(day=28) + timedelta(days=4)).replace(day=1)
|
||||
next_month = Month.query.filter_by(
|
||||
label=f"{next_month_date.year:04d}-{next_month_date.month:02d}"
|
||||
).first()
|
||||
summary = self.month_service.compute_summary(current_month)
|
||||
count = 0
|
||||
for user in User.query.filter_by(is_active=True).all():
|
||||
pref = user.notification_preference or NotificationPreference(user_id=user.id)
|
||||
if pref.id is None:
|
||||
db.session.add(pref)
|
||||
db.session.flush()
|
||||
if pref.notify_month_end and next_month is None and today.day >= 25:
|
||||
self._notify(
|
||||
user,
|
||||
"month_end",
|
||||
"Folgemonat fehlt",
|
||||
"Der nächste Monat ist noch nicht vorbereitet. Öffne Saldo und prüfe die Planung.",
|
||||
"/months/",
|
||||
)
|
||||
count += 1
|
||||
if pref.notify_missing_distribution and summary.remainder != summary.allocation_total:
|
||||
self._notify(
|
||||
user,
|
||||
"missing_distribution",
|
||||
"Restverteilung unvollständig",
|
||||
"Die geplante Verteilung deckt den aktuellen Restbetrag noch nicht vollständig ab.",
|
||||
f"/planning/{current_month.label}",
|
||||
)
|
||||
count += 1
|
||||
if pref.notify_missing_values and abs(float(summary.deltas["income_delta"])) >= self.threshold:
|
||||
self._notify(
|
||||
user,
|
||||
"income_change",
|
||||
"Einkommen hat sich deutlich verändert",
|
||||
"Durch eine Einkommensänderung sollten die Vorschläge und Verteilungen geprüft werden.",
|
||||
f"/planning/{current_month.label}",
|
||||
)
|
||||
count += 1
|
||||
db.session.commit()
|
||||
return count
|
||||
|
||||
def _notify(self, user, kind: str, title: str, body: str, action_url: str) -> None:
|
||||
notification = InAppNotification(
|
||||
user_id=user.id, type=kind, title=title, body=body, action_url=action_url
|
||||
)
|
||||
db.session.add(notification)
|
||||
self.push_service.send_to_user(user, title=title, body=body, url=action_url)
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pywebpush import WebPushException, webpush
|
||||
|
||||
from app.models import PushSubscription
|
||||
|
||||
|
||||
class PushService:
|
||||
def __init__(self, public_key: str, private_key: str, claims: dict):
|
||||
self.public_key = public_key
|
||||
self.private_key = private_key
|
||||
self.claims = claims
|
||||
|
||||
def send_to_user(self, user, title: str, body: str, url: str = "/") -> int:
|
||||
if not self.public_key or not self.private_key:
|
||||
return 0
|
||||
sent = 0
|
||||
for sub in user.push_subscriptions:
|
||||
try:
|
||||
webpush(
|
||||
subscription_info={
|
||||
"endpoint": sub.endpoint,
|
||||
"keys": {"p256dh": sub.p256dh_key, "auth": sub.auth_key},
|
||||
},
|
||||
data=f'{{"title":"{title}","body":"{body}","url":"{url}"}}',
|
||||
vapid_private_key=self.private_key,
|
||||
vapid_claims=self.claims,
|
||||
)
|
||||
sent += 1
|
||||
except WebPushException:
|
||||
continue
|
||||
return sent
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from app.models import CostParticipant, EntryShareRule, to_decimal
|
||||
|
||||
|
||||
class ShareCalculationService:
|
||||
def calculate_entry_shares(self, monthly_entry_value) -> dict:
|
||||
amount = to_decimal(monthly_entry_value.planned_amount)
|
||||
rules = monthly_entry_value.entry.share_rules
|
||||
if not rules:
|
||||
return {
|
||||
"total": amount,
|
||||
"internal_total": amount,
|
||||
"external_total": Decimal("0.00"),
|
||||
"shares": [],
|
||||
}
|
||||
|
||||
shares = []
|
||||
total_assigned = Decimal("0.00")
|
||||
equal_rules = [rule for rule in rules if rule.share_type == "equal"]
|
||||
non_equal_rules = [rule for rule in rules if rule.share_type != "equal"]
|
||||
|
||||
for rule in non_equal_rules:
|
||||
share_amount = self._amount_for_rule(amount, rule)
|
||||
total_assigned += share_amount
|
||||
shares.append(self._serialize_share(rule.participant, share_amount))
|
||||
|
||||
if equal_rules:
|
||||
remaining = amount - total_assigned
|
||||
unit = (remaining / Decimal(len(equal_rules))).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
for index, rule in enumerate(equal_rules):
|
||||
share_amount = unit
|
||||
if index == len(equal_rules) - 1:
|
||||
share_amount = amount - total_assigned - (unit * (len(equal_rules) - 1))
|
||||
shares.append(self._serialize_share(rule.participant, share_amount))
|
||||
|
||||
internal_total = sum(
|
||||
(item["amount"] for item in shares if not item["is_external"]), Decimal("0.00")
|
||||
)
|
||||
external_total = sum(
|
||||
(item["amount"] for item in shares if item["is_external"]), Decimal("0.00")
|
||||
)
|
||||
return {
|
||||
"total": amount,
|
||||
"internal_total": internal_total,
|
||||
"external_total": external_total,
|
||||
"shares": shares,
|
||||
}
|
||||
|
||||
def calculate_external_month_totals(self, month) -> list[dict]:
|
||||
participant_totals: dict[int, dict] = defaultdict(
|
||||
lambda: {
|
||||
"participant_id": None,
|
||||
"participant_name": "",
|
||||
"participant_avatar_url": None,
|
||||
"participant_avatar_initials": "",
|
||||
"total": Decimal("0.00"),
|
||||
"items": [],
|
||||
}
|
||||
)
|
||||
for value in month.entry_values:
|
||||
result = self.calculate_entry_shares(value)
|
||||
for share in result["shares"]:
|
||||
if not share["is_external"]:
|
||||
continue
|
||||
bucket = participant_totals[share["participant_id"]]
|
||||
bucket["participant_id"] = share["participant_id"]
|
||||
bucket["participant_name"] = share["participant_name"]
|
||||
bucket["participant_avatar_url"] = share["participant_avatar_url"]
|
||||
bucket["participant_avatar_initials"] = share["participant_avatar_initials"]
|
||||
bucket["total"] += share["amount"]
|
||||
bucket["items"].append({"entry_name": value.entry.name, "amount": share["amount"]})
|
||||
return sorted(participant_totals.values(), key=lambda item: item["participant_name"])
|
||||
|
||||
def _amount_for_rule(self, total_amount: Decimal, rule: EntryShareRule) -> Decimal:
|
||||
if rule.share_type == "percentage":
|
||||
return (total_amount * to_decimal(rule.share_value) / Decimal("100")).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
if rule.share_type == "fixed":
|
||||
return to_decimal(rule.share_value)
|
||||
return Decimal("0.00")
|
||||
|
||||
def _serialize_share(self, participant: CostParticipant, amount: Decimal) -> dict:
|
||||
return {
|
||||
"participant_id": participant.id,
|
||||
"participant_name": participant.display_name,
|
||||
"participant_avatar_url": participant.avatar_url,
|
||||
"participant_avatar_initials": participant.avatar_initials,
|
||||
"amount": amount.quantize(Decimal("0.01")),
|
||||
"is_external": participant.is_external,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M456 0L408 0L408 96L456 96C482.5 96 504 117.5 504 144L504 432C504 458.5 482.5 480 456 480L408 480L408 576L456 576C509 576 552 533 552 480L552 96C552 43 509 0 456 0zM456 276L456 267.4L450.5 260.7L336.5 122.7L319.7 102.4C292.4 129.7 264.3 157.8 259.3 162.8C259.9 163.4 295.8 196.1 366.8 260.9L49.1 246L24 244.8L24 331.2L49.1 330L366.7 315.1C295.7 379.9 259.9 412.6 259.2 413.2C284.7 438.7 314.2 468.2 319.6 473.6L336.4 453.3L450.4 315.3L455.9 308.6L455.9 276z"/></svg>
|
||||
|
After Width: | Height: | Size: 725 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M320 64C302.3 64 288 78.3 288 96L288 99.2C215 114 160 178.6 160 256L160 277.7C160 325.8 143.6 372.5 113.6 410.1L103.8 422.3C98.7 428.6 96 436.4 96 444.5C96 464.1 111.9 480 131.5 480L508.4 480C528 480 543.9 464.1 543.9 444.5C543.9 436.4 541.2 428.6 536.1 422.3L526.3 410.1C496.4 372.5 480 325.8 480 277.7L480 256C480 178.6 425 114 352 99.2L352 96C352 78.3 337.7 64 320 64zM258 528C265.1 555.6 290.2 576 320 576C349.8 576 374.9 555.6 382 528L258 528z"/></svg>
|
||||
|
After Width: | Height: | Size: 716 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M335.9 84.2C326.1 78.6 314 78.6 304.1 84.2L80.1 212.2C67.5 219.4 61.3 234.2 65 248.2C68.7 262.2 81.5 272 96 272L128 272L128 480L128 480L76.8 518.4C68.7 524.4 64 533.9 64 544C64 561.7 78.3 576 96 576L544 576C561.7 576 576 561.7 576 544C576 533.9 571.3 524.4 563.2 518.4L512 480L512 272L544 272C558.5 272 571.2 262.2 574.9 248.2C578.6 234.2 572.4 219.4 559.8 212.2L335.8 84.2zM464 272L464 480L400 480L400 272L464 272zM352 272L352 480L288 480L288 272L352 272zM240 272L240 480L176 480L176 272L240 272zM320 160C337.7 160 352 174.3 352 192C352 209.7 337.7 224 320 224C302.3 224 288 209.7 288 192C288 174.3 302.3 160 320 160z"/></svg>
|
||||
|
After Width: | Height: | Size: 886 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M224 64C206.3 64 192 78.3 192 96L192 128L160 128C124.7 128 96 156.7 96 192L96 240L544 240L544 192C544 156.7 515.3 128 480 128L448 128L448 96C448 78.3 433.7 64 416 64C398.3 64 384 78.3 384 96L384 128L256 128L256 96C256 78.3 241.7 64 224 64zM96 288L96 480C96 515.3 124.7 544 160 544L480 544C515.3 544 544 515.3 544 480L544 288L96 288z"/></svg>
|
||||
|
After Width: | Height: | Size: 600 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M408 528L408 24L552 24L552 456C552 509 509 552 456 552L408 552L408 528zM504 408L504 72L456 72L456 456C482.5 456 504 434.5 504 408zM216 528L216 144L360 144L360 552L216 552L216 528zM312 456L312 192L264 192L264 456L312 456zM168 528L168 552L120 552C67 552 24 509 24 456L24 264L168 264L168 528zM120 456L120 312L72 312L72 408C72 434.5 93.5 456 120 456z"/></svg>
|
||||
|
After Width: | Height: | Size: 614 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M256 144C256 117.5 277.5 96 304 96L336 96C362.5 96 384 117.5 384 144L384 496C384 522.5 362.5 544 336 544L304 544C277.5 544 256 522.5 256 496L256 144zM64 336C64 309.5 85.5 288 112 288L144 288C170.5 288 192 309.5 192 336L192 496C192 522.5 170.5 544 144 544L112 544C85.5 544 64 522.5 64 496L64 336zM496 160L528 160C554.5 160 576 181.5 576 208L576 496C576 522.5 554.5 544 528 544L496 544C469.5 544 448 522.5 448 496L448 208C448 181.5 469.5 160 496 160z"/></svg>
|
||||
|
After Width: | Height: | Size: 716 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M463 448.2C440.9 409.8 399.4 384 352 384L288 384C240.6 384 199.1 409.8 177 448.2C212.2 487.4 263.2 512 320 512C376.8 512 427.8 487.3 463 448.2zM64 320C64 178.6 178.6 64 320 64C461.4 64 576 178.6 576 320C576 461.4 461.4 576 320 576C178.6 576 64 461.4 64 320zM320 336C359.8 336 392 303.8 392 264C392 224.2 359.8 192 320 192C280.2 192 248 224.2 248 264C248 303.8 280.2 336 320 336z"/></svg>
|
||||
|
After Width: | Height: | Size: 646 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M288 24C353.9 24 416.8 25.8 463.5 37.4C486.9 43.2 508.7 51.9 525 66.1C540.8 79.9 550.4 98 551.9 120L552.1 120L552.1 450C552.1 474.7 542.2 494.9 525 509.9C508.7 524.1 486.9 532.9 463.5 538.6C416.8 550.1 354 552 288 552C222 552 159.2 550.2 112.5 538.6C89.1 532.8 67.3 524.1 51.1 509.9C33.9 494.9 24 474.7 24 450L24 120L24.2 120C25.7 98 35.3 79.9 51.1 66.1C67.3 51.9 89.1 43.2 112.5 37.4C159.2 25.8 222.1 24 288 24zM287.7 396L288.3 396C341.6 396 386.2 394 417.2 385.2C432.5 380.8 442.2 375.4 447.9 369.7C452.9 364.7 456 358.3 456 348L456 325.2C410.4 334.4 350.9 336 288.3 336L287.7 336C225.1 336 165.6 334.4 120 325.2L120 348C120 358.2 123.1 364.6 128.1 369.7C133.8 375.4 143.5 380.9 158.8 385.2C189.8 394 234.4 396 287.7 396zM120 433.2L120 450C120 462.2 123.5 469.8 128.8 475.6C134.6 481.9 144.2 487.7 159.3 492.4C190.1 501.9 234.6 504 288 504C341.4 504 385.9 501.9 416.7 492.4C431.8 487.7 441.4 481.9 447.2 475.6C452.5 469.9 456 462.2 456 450L456 433.2C410.6 442.4 351.3 444 289 444L287 444C224.7 444 165.4 442.4 120 433.2zM288 288L288.3 288C341.6 288 386.2 286 417.2 277.2C432.5 272.8 442.2 267.4 447.9 261.7C452.9 256.7 456 250.3 456 240L456 216.4C410.2 226.4 350.5 228 288 228C225.5 228 165.8 226.3 120 216.4L120 240C120 250.2 123.1 256.6 128.1 261.7C133.8 267.4 143.5 272.9 158.8 277.2C189.9 286 234.6 288 288 288zM447.2 100.4C441.4 94.1 431.8 88.3 416.7 83.6C385.9 74.1 341.4 72 288 72C234.6 72 190.1 74.1 159.3 83.6C144.2 88.3 134.6 94.1 128.8 100.4C123.5 106.1 120 113.8 120 126C120 138.2 123.5 145.8 128.8 151.6C134.6 157.9 144.2 163.7 159.3 168.4C190.1 177.9 234.6 180 288 180C341.4 180 385.9 177.9 416.7 168.4C431.8 163.7 441.4 157.9 447.2 151.6C452.5 145.9 456 138.2 456 126C456 113.8 452.5 106.2 447.2 100.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M259.1 73.5C262.1 58.7 275.2 48 290.4 48L350.2 48C365.4 48 378.5 58.7 381.5 73.5L396 143.5C410.1 149.5 423.3 157.2 435.3 166.3L503.1 143.8C517.5 139 533.3 145 540.9 158.2L570.8 210C578.4 223.2 575.7 239.8 564.3 249.9L511 297.3C511.9 304.7 512.3 312.3 512.3 320C512.3 327.7 511.8 335.3 511 342.7L564.4 390.2C575.8 400.3 578.4 417 570.9 430.1L541 481.9C533.4 495 517.6 501.1 503.2 496.3L435.4 473.8C423.3 482.9 410.1 490.5 396.1 496.6L381.7 566.5C378.6 581.4 365.5 592 350.4 592L290.6 592C275.4 592 262.3 581.3 259.3 566.5L244.9 496.6C230.8 490.6 217.7 482.9 205.6 473.8L137.5 496.3C123.1 501.1 107.3 495.1 99.7 481.9L69.8 430.1C62.2 416.9 64.9 400.3 76.3 390.2L129.7 342.7C128.8 335.3 128.4 327.7 128.4 320C128.4 312.3 128.9 304.7 129.7 297.3L76.3 249.8C64.9 239.7 62.3 223 69.8 209.9L99.7 158.1C107.3 144.9 123.1 138.9 137.5 143.7L205.3 166.2C217.4 157.1 230.6 149.5 244.6 143.4L259.1 73.5zM320.3 400C364.5 399.8 400.2 363.9 400 319.7C399.8 275.5 363.9 239.8 319.7 240C275.5 240.2 239.8 276.1 240 320.3C240.2 364.5 276.1 400.2 320.3 400z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M341.8 72.6C329.5 61.2 310.5 61.2 298.3 72.6L74.3 280.6C64.7 289.6 61.5 303.5 66.3 315.7C71.1 327.9 82.8 336 96 336L112 336L112 512C112 547.3 140.7 576 176 576L464 576C499.3 576 528 547.3 528 512L528 336L544 336C557.2 336 569 327.9 573.8 315.7C578.6 303.5 575.4 289.5 565.8 280.6L341.8 72.6zM304 384L336 384C362.5 384 384 405.5 384 432L384 528L256 528L256 432C256 405.5 277.5 384 304 384z"/></svg>
|
||||
|
After Width: | Height: | Size: 656 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M312 48.8L312 0L264 0L264 48.8C136.6 57.3 61.9 133.2 49.8 216L0 216L0 264L48 264L48 528L0 528L0 576L576 576L576 528L528 528L528 264L576 264L576 216L526.2 216C514.1 133.2 439.4 57.3 312 48.8zM177.8 137.2C199.6 112.9 234.6 96 288 96C341.4 96 376.4 112.9 398.2 137.2C416.1 157.2 427.1 184.1 430.6 216L145.2 216C148.8 184.2 159.7 157.2 177.6 137.2zM432 264L432 528L366 528L366 264L432 264zM318 264L318 528L258 528L258 264L318 264zM144 528L144 264L210 264L210 528L144 528z"/></svg>
|
||||
|
After Width: | Height: | Size: 735 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M552 120L552 145.9L526.2 143.9L214.2 119.9L214.2 72L526.2 48L552 46L552 119.9zM96 144C62.4 144 48 122.5 48 96C48 69.5 62.4 48 96 48C129.6 48 144 69.5 144 96C144 122.5 129.6 144 96 144zM96 336C62.4 336 48 314.5 48 288C48 261.5 62.4 240 96 240C129.6 240 144 261.5 144 288C144 314.5 129.6 336 96 336zM144 480C144 506.5 129.6 528 96 528C62.4 528 48 506.5 48 480C48 453.5 62.4 432 96 432C129.6 432 144 453.5 144 480zM552 337.9L526.2 335.9L214.2 311.9L214.2 264L526.2 240L552 238L552 337.8zM552 504L552 529.9L526.2 527.9L214.2 503.9L214.2 456L526.2 432L552 430L552 503.9z"/></svg>
|
||||
|
After Width: | Height: | Size: 833 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M31 169C21.6 159.6 21.6 144.4 31 135.1L103 63C112.4 53.6 127.6 53.6 136.9 63C146.2 72.4 146.3 87.6 136.9 96.9L105.9 127.9L173.6 127.9L173.6 127.9L511.9 127.9C547.2 127.9 575.9 156.6 575.9 191.9L575.9 370.1L570.8 365C542.7 336.9 497.1 336.9 469 365C441.8 392.2 440.9 435.6 466.2 463.9L533.9 463.9L502.9 432.9C493.5 423.5 493.5 408.3 502.9 399C512.3 389.7 527.5 389.6 536.8 399L608.8 471C618.2 480.4 618.2 495.6 608.8 504.9L536.8 576.9C527.4 586.3 512.2 586.3 502.9 576.9C493.6 567.5 493.5 552.3 502.9 543L533.9 512L127.8 512C92.5 512 63.8 483.3 63.8 448L63.8 269.8L68.9 274.9C97 303 142.6 303 170.7 274.9C197.9 247.7 198.8 204.3 173.5 176L105.8 176L136.8 207C146.2 216.4 146.2 231.6 136.8 240.9C127.4 250.2 112.2 250.3 102.9 240.9L31 169zM416 320C416 267 373 224 320 224C267 224 224 267 224 320C224 373 267 416 320 416C373 416 416 373 416 320zM504 255.5C508.4 256 512 252.4 512 248L512 200C512 195.6 508.4 192 504 192L456 192C451.6 192 447.9 195.6 448.5 200C452.1 229 475.1 251.9 504 255.5zM136 384.5C131.6 384 128 387.6 128 392L128 440C128 444.4 131.6 448 136 448L184 448C188.4 448 192.1 444.4 191.5 440C187.9 411 164.9 388.1 136 384.5z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M320 64C178.6 64 64 178.6 64 320C64 461.4 178.6 576 320 576C388.8 576 451.3 548.8 497.3 504.6C504.6 497.6 506.7 486.7 502.6 477.5C498.5 468.3 488.9 462.6 478.8 463.4C473.9 463.8 469 464 464 464C362.4 464 280 381.6 280 280C280 207.9 321.5 145.4 382.1 115.2C391.2 110.7 396.4 100.9 395.2 90.8C394 80.7 386.6 72.5 376.7 70.3C358.4 66.2 339.4 64 320 64z"/></svg>
|
||||
|
After Width: | Height: | Size: 617 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M353.3 113L346.3 120L456.4 230.1L463.4 223.1C493.8 192.7 493.8 143.4 463.4 113C433 82.6 383.7 82.6 353.3 113zM130.3 336L240.3 446.1L422.4 264L312.3 153.9L130.3 336zM96.3 369.9L94 372.2L71.7 461.3L114.9 504.5L204 482.2L206.3 479.9L96.2 369.8zM319.4 79L361.4 37C410.5-12.1 490.2-12.1 539.3 37C588.4 86.1 588.4 165.8 539.3 214.9L233.3 520.9L228.6 525.6L222.2 527.2L30.2 575.2L1.1 546.1L49.1 354.1L50.7 347.6L55.4 342.9L319.4 78.9L319.4 78.9z"/></svg>
|
||||
|
After Width: | Height: | Size: 706 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M506.1 264.1L327.2 248.8L311.9 69.9L264.1 70.2L250.9 250.8L70.3 264L70 311.9L248.9 327.2L264.2 506.1L312 505.8L325.2 325.2L505.8 312L506.1 264.1z"/></svg>
|
||||
|
After Width: | Height: | Size: 413 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M275.2 9.7L275.2 9.7L275.2 9.7L275.1 9.7L274.5 10.1C273.9 10.5 273 11 271.7 11.7C269.1 13.2 265.2 15.4 259.9 18C249.4 23.3 233.8 30.5 213.8 37.7C173.8 52.2 116.6 66.7 48 66.7L24 66.8L24 259C24 358.5 80 449.6 168.8 494.6L276.3 549L288 556.1L299.7 549L407.2 494.6C496 449.6 552 358.6 552 259L552 66.8L528 66.8C459.4 66.8 402.2 52.3 362.2 37.8C342.2 30.6 326.6 23.4 316.1 18.1C310.9 15.4 306.9 13.3 304.3 11.8C303 11.1 302.1 10.5 301.5 10.2L300.9 9.8L300.8 9.8L300.8 9.8L300.8 9.8L300.8 9.8L300.8 9.8L288 1.7L275.2 9.8L275.2 9.8L275.2 9.8zM456 109.9L456 276.3C456 351.7 416.7 421.7 352.2 460.9L288 500L223.8 460.9C159.3 421.7 120 351.7 120 276.3L120 109.9C163.7 104 201.1 93.5 230.2 82.9C252.2 74.9 269.6 66.9 281.6 60.8C283.9 59.6 286.1 58.5 288 57.5C289.9 58.5 292.1 59.6 294.4 60.8C306.4 66.9 323.8 74.9 345.8 82.9C374.9 93.4 412.2 104 456 109.9z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M120 266.9C137.1 271.2 152.8 280.5 165.3 295.4C183.1 316.7 192 346.8 192 384C192 421.2 183.1 451.3 165.3 472.6C152.8 487.5 137 496.8 120 501.1L120 552L72 552L72 501.1C54.9 496.8 39.2 487.5 26.7 472.6C8.9 451.3 0 421.1 0 384C0 346.9 8.9 316.7 26.7 295.4C39.2 280.5 55 271.2 72 266.9L72 24L120 24L120 266.9zM312 74.9C329.1 79.2 344.8 88.5 357.3 103.4C375.1 124.7 384 154.8 384 192C384 229.2 375.1 259.3 357.3 280.6C344.8 295.5 329 304.8 312 309.1L312 552L264 552L264 309.1C246.9 304.8 231.2 295.5 218.7 280.6C200.9 259.3 192 229.2 192 192C192 154.8 200.9 124.7 218.7 103.4C231.2 88.5 247 79.2 264 74.9L264 24L312 24L312 74.9zM384 384C384 346.9 392.9 316.7 410.7 295.4C423.2 280.5 439 271.2 456 266.9L456 24L504 24L504 266.9C521.1 271.2 536.8 280.5 549.3 295.4C567.1 316.7 576 346.8 576 384C576 421.2 567.1 451.3 549.3 472.6C536.8 487.5 521 496.8 504 501.1L504 552L456 552L456 501.1C438.9 496.8 423.2 487.5 410.7 472.6C392.9 451.3 384 421.2 384 384zM322.9 155C315.5 148.3 303.8 144 288 144C272.2 144 260.6 148.3 253.1 155C246.1 161.3 240 172.2 240 192C240 211.8 246.1 222.8 253.1 229C260.5 235.7 272.2 240 288 240C303.8 240 315.4 235.7 322.9 229C329.9 222.7 336 211.8 336 192C336 172.2 329.9 161.2 322.9 155zM514.9 347C507.5 340.3 495.8 336 480 336C464.2 336 452.6 340.3 445.1 347C438.1 353.3 432 364.2 432 384C432 403.8 438.1 414.8 445.1 421C452.5 427.7 464.2 432 480 432C495.8 432 507.4 427.7 514.9 421C521.9 414.7 528 403.8 528 384C528 364.2 521.9 353.2 514.9 347zM61.1 421C68.5 427.7 80.2 432 96 432C111.8 432 123.4 427.7 130.9 421C137.9 414.7 144 403.8 144 384C144 364.2 137.9 353.2 130.9 347C123.5 340.3 111.8 336 96 336C80.2 336 68.6 340.3 61.1 347C54.1 353.3 48 364.2 48 384C48 403.8 54.1 414.8 61.1 421z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M96 128C78.3 128 64 142.3 64 160C64 177.7 78.3 192 96 192L182.7 192C195 220.3 223.2 240 256 240C288.8 240 317 220.3 329.3 192L544 192C561.7 192 576 177.7 576 160C576 142.3 561.7 128 544 128L329.3 128C317 99.7 288.8 80 256 80C223.2 80 195 99.7 182.7 128L96 128zM96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L342.7 352C355 380.3 383.2 400 416 400C448.8 400 477 380.3 489.3 352L544 352C561.7 352 576 337.7 576 320C576 302.3 561.7 288 544 288L489.3 288C477 259.7 448.8 240 416 240C383.2 240 355 259.7 342.7 288L96 288zM96 448C78.3 448 64 462.3 64 480C64 497.7 78.3 512 96 512L150.7 512C163 540.3 191.2 560 224 560C256.8 560 285 540.3 297.3 512L544 512C561.7 512 576 497.7 576 480C576 462.3 561.7 448 544 448L297.3 448C285 419.7 256.8 400 224 400C191.2 400 163 419.7 150.7 448L96 448z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M210.2 53.9C217.6 50.8 226 51.7 232.7 56.1L320.5 114.3L408.3 56.1C415 51.7 423.4 50.9 430.8 53.9C438.2 56.9 443.4 63.5 445 71.3L465.9 174.5L569.1 195.4C576.9 197 583.5 202.4 586.5 209.7C589.5 217 588.7 225.5 584.3 232.2L526.1 320L584.3 407.8C588.7 414.5 589.5 422.9 586.5 430.3C583.5 437.7 576.9 443.1 569.1 444.6L465.8 465.4L445 568.7C443.4 576.5 438 583.1 430.7 586.1C423.4 589.1 414.9 588.3 408.2 583.9L320.4 525.7L232.6 583.9C225.9 588.3 217.5 589.1 210.1 586.1C202.7 583.1 197.3 576.5 195.8 568.7L175 465.4L71.7 444.5C63.9 442.9 57.3 437.5 54.3 430.2C51.3 422.9 52.1 414.4 56.5 407.7L114.7 320L56.5 232.2C52.1 225.5 51.3 217.1 54.3 209.7C57.3 202.3 63.9 196.9 71.7 195.4L175 174.6L195.9 71.3C197.5 63.5 202.9 56.9 210.2 53.9zM239.6 320C239.6 275.6 275.6 239.6 320 239.6C364.4 239.6 400.4 275.6 400.4 320C400.4 364.4 364.4 400.4 320 400.4C275.6 400.4 239.6 364.4 239.6 320zM448.4 320C448.4 249.1 390.9 191.6 320 191.6C249.1 191.6 191.6 249.1 191.6 320C191.6 390.9 249.1 448.4 320 448.4C390.9 448.4 448.4 390.9 448.4 320z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M288 48C356.5 48 403.1 74.3 433.4 115.5C464.5 157.8 480 218.1 480 288C480 340.3 471.3 387.3 453.8 425.4C447.8 392 429.9 365.1 404.1 346C395 339.2 385 333.5 374.3 328.8C396.2 307.8 408 277.6 408 240C408 201 395.3 170 371.9 148.9C348.9 128.3 318.6 120 288 120C257.4 120 227.1 128.3 204.1 148.9C180.7 169.9 168 201 168 240C168 277.6 179.8 307.9 201.7 328.8C191 333.5 181 339.2 171.9 346C146.1 365.1 128.2 392 122.2 425.4C104.8 387.3 96 340.3 96 288C96 218.2 111.6 157.8 142.6 115.5C172.9 74.3 219.5 48 288 48zM288 360C316.5 360 340.4 368.6 356.9 383.2C373 397.5 384 419.4 384 450.5L384 504.9C358.3 519.6 326.6 528 288 528C249.4 528 217.7 519.6 192 504.9L192 450.5C192 419.4 203 397.5 219.1 383.2C235.6 368.5 259.5 360 288 360zM288.1 312L288 312C274.3 312 263.4 307.2 255.5 297.8C247.2 287.9 240 270 240 240C240 210 247.2 192.1 255.5 182.2C263.4 172.8 274.2 168 288 168C301.8 168 312.6 172.8 320.5 182.2C328.8 192.1 336 210 336 240C336 270 328.8 287.9 320.5 297.8C312.6 307.2 301.8 312 288.1 312zM288 0C190.2 0 117.2 31.5 68.8 85.3C20.9 138.7 0 211 0 288C0 365 20.9 437.3 68.8 490.7C117.2 544.5 190.2 576 288 576C385.8 576 458.8 544.5 507.2 490.7C555.1 437.3 576 365 576 288C576 211 555.1 138.7 507.2 85.3C458.8 31.5 385.8 0 288 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M320 80C377.4 80 424 126.6 424 184C424 241.4 377.4 288 320 288C262.6 288 216 241.4 216 184C216 126.6 262.6 80 320 80zM96 152C135.8 152 168 184.2 168 224C168 263.8 135.8 296 96 296C56.2 296 24 263.8 24 224C24 184.2 56.2 152 96 152zM0 480C0 409.3 57.3 352 128 352C140.8 352 153.2 353.9 164.9 357.4C132 394.2 112 442.8 112 496L112 512C112 523.4 114.4 534.2 118.7 544L32 544C14.3 544 0 529.7 0 512L0 480zM521.3 544C525.6 534.2 528 523.4 528 512L528 496C528 442.8 508 394.2 475.1 357.4C486.8 353.9 499.2 352 512 352C582.7 352 640 409.3 640 480L640 512C640 529.7 625.7 544 608 544L521.3 544zM472 224C472 184.2 504.2 152 544 152C583.8 152 616 184.2 616 224C616 263.8 583.8 296 544 296C504.2 296 472 263.8 472 224zM160 496C160 407.6 231.6 336 320 336C408.4 336 480 407.6 480 496L480 512C480 529.7 465.7 544 448 544L192 544C174.3 544 160 529.7 160 512L160 496z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M128 96C92.7 96 64 124.7 64 160L64 448C64 483.3 92.7 512 128 512L512 512C547.3 512 576 483.3 576 448L576 256C576 220.7 547.3 192 512 192L136 192C122.7 192 112 181.3 112 168C112 154.7 122.7 144 136 144L520 144C533.3 144 544 133.3 544 120C544 106.7 533.3 96 520 96L128 96zM480 320C497.7 320 512 334.3 512 352C512 369.7 497.7 384 480 384C462.3 384 448 369.7 448 352C448 334.3 462.3 320 480 320z"/></svg>
|
||||
|
After Width: | Height: | Size: 659 B |
@@ -0,0 +1,432 @@
|
||||
const urlBase64ToUint8Array = (base64String) => {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const rawData = atob(base64);
|
||||
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
|
||||
};
|
||||
|
||||
async function registerServiceWorker() {
|
||||
if (!("serviceWorker" in navigator)) return null;
|
||||
return navigator.serviceWorker.register("/static/service-worker.js");
|
||||
}
|
||||
|
||||
async function enablePushNotifications() {
|
||||
const vapidPublicKey = document.body.dataset.vapidPublicKey;
|
||||
const csrfToken = document.body.dataset.csrfToken;
|
||||
if (!vapidPublicKey || !("PushManager" in window)) return;
|
||||
const registration = await registerServiceWorker();
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") return;
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
});
|
||||
await fetch("/planning/push/subscribe", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
body: JSON.stringify(subscription),
|
||||
});
|
||||
}
|
||||
|
||||
function injectCsrfTokens() {
|
||||
const csrfToken = document.body.dataset.csrfToken;
|
||||
if (!csrfToken) return;
|
||||
document.querySelectorAll('form[method="post"]').forEach((form) => {
|
||||
let input = form.querySelector('input[name="csrf_token"]');
|
||||
if (!input) {
|
||||
input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = "csrf_token";
|
||||
form.appendChild(input);
|
||||
}
|
||||
input.value = csrfToken;
|
||||
});
|
||||
}
|
||||
|
||||
function mountCharts() {
|
||||
const palette = ["#146a63", "#f2b35d", "#d97757", "#6f8677", "#7d6b91", "#6f8fc2", "#c58d5a", "#3f7f9b", "#9a6f5f", "#5f8c6d"];
|
||||
const styles = getComputedStyle(document.body);
|
||||
const chartTextColor = styles.getPropertyValue("--text").trim() || "#1d2a33";
|
||||
const chartGridColor = styles.getPropertyValue("--line").trim() || "rgba(29, 42, 51, 0.08)";
|
||||
const buildDatasets = (node) => {
|
||||
const datasetPayload = JSON.parse(node.dataset.datasets || "null");
|
||||
if (Array.isArray(datasetPayload) && datasetPayload.length) {
|
||||
return datasetPayload.map((dataset, index) => {
|
||||
const color = palette[index % palette.length];
|
||||
return {
|
||||
label: dataset.label,
|
||||
data: (dataset.data || []).map((value) => Number(value)),
|
||||
backgroundColor: `${color}22`,
|
||||
borderColor: color,
|
||||
pointBackgroundColor: color,
|
||||
pointBorderColor: color,
|
||||
borderWidth: 3,
|
||||
borderRadius: 0,
|
||||
tension: 0.32,
|
||||
fill: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const values = JSON.parse(node.dataset.values || "[]").map((value) => Number(value));
|
||||
const secondaryValues = JSON.parse(node.dataset.secondaryValues || "[]").map((value) => Number(value));
|
||||
const datasets = [{
|
||||
label: "Werte",
|
||||
data: values,
|
||||
backgroundColor: palette,
|
||||
borderColor: "#146a63",
|
||||
borderRadius: 12,
|
||||
tension: 0.3,
|
||||
}];
|
||||
if (secondaryValues.length) {
|
||||
datasets[0].label = "Einkommen";
|
||||
datasets[0].fill = false;
|
||||
datasets.push({
|
||||
label: "Kosten",
|
||||
data: secondaryValues,
|
||||
backgroundColor: "#f2b35d",
|
||||
borderColor: "#f2b35d",
|
||||
tension: 0.3,
|
||||
});
|
||||
}
|
||||
if ((node.dataset.chartType || "bar") === "line") {
|
||||
datasets[0].backgroundColor = "#146a6322";
|
||||
datasets[0].borderWidth = 3;
|
||||
datasets[0].borderRadius = 0;
|
||||
datasets[0].pointBackgroundColor = "#146a63";
|
||||
datasets[0].pointBorderColor = "#146a63";
|
||||
datasets[0].fill = false;
|
||||
}
|
||||
return datasets;
|
||||
};
|
||||
|
||||
document.querySelectorAll(".chart").forEach((node) => {
|
||||
if (node.dataset.drilldownSource === "true" || node.dataset.drilldownMounted === "true") {
|
||||
return;
|
||||
}
|
||||
const labels = JSON.parse(node.dataset.labels || "[]");
|
||||
const indexAxis = node.dataset.indexAxis || "x";
|
||||
const chartType = node.dataset.chartType || "bar";
|
||||
const datasets = buildDatasets(node);
|
||||
new Chart(node, {
|
||||
type: chartType,
|
||||
data: {
|
||||
labels,
|
||||
datasets,
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis,
|
||||
scales: chartType === "pie" || chartType === "doughnut"
|
||||
? {}
|
||||
: {
|
||||
x: {
|
||||
ticks: { color: chartTextColor },
|
||||
grid: { color: chartGridColor },
|
||||
},
|
||||
y: {
|
||||
ticks: { color: chartTextColor },
|
||||
grid: { color: chartGridColor },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: chartType === "pie" || chartType === "doughnut" || chartType === "line",
|
||||
position: "bottom",
|
||||
labels: {
|
||||
color: chartTextColor,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
titleColor: chartTextColor,
|
||||
bodyColor: chartTextColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-drilldown-source="true"]').forEach((sourceNode) => {
|
||||
if (sourceNode.dataset.drilldownMounted === "true") {
|
||||
return;
|
||||
}
|
||||
sourceNode.dataset.drilldownMounted = "true";
|
||||
const rootLabels = JSON.parse(sourceNode.dataset.labels || "[]");
|
||||
const rootValues = JSON.parse(sourceNode.dataset.values || "[]").map((value) => Number(value));
|
||||
const detailKeys = JSON.parse(sourceNode.dataset.detailKeys || "[]");
|
||||
const detailMap = JSON.parse(sourceNode.dataset.detailMap || "{}");
|
||||
const titleTarget = document.getElementById(sourceNode.dataset.detailTitleTarget);
|
||||
const subtitleTarget = document.getElementById(sourceNode.dataset.detailSubtitleTarget);
|
||||
const backButton = document.getElementById(sourceNode.dataset.detailBackTarget);
|
||||
const rootTitlePayload = document.getElementById(`${sourceNode.id}-root-title`);
|
||||
const rootTitle = rootTitlePayload ? JSON.parse(rootTitlePayload.textContent) : {
|
||||
title: titleTarget ? titleTarget.textContent : "",
|
||||
subtitle: subtitleTarget ? subtitleTarget.textContent : "",
|
||||
};
|
||||
|
||||
let isInDetail = false;
|
||||
const chart = new Chart(sourceNode, {
|
||||
type: sourceNode.dataset.chartType || "pie",
|
||||
data: {
|
||||
labels: rootLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: "Kategorien",
|
||||
data: rootValues,
|
||||
backgroundColor: palette,
|
||||
borderWidth: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: "bottom",
|
||||
labels: {
|
||||
color: chartTextColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
onClick: (_event, elements) => {
|
||||
if (!elements.length || isInDetail) {
|
||||
return;
|
||||
}
|
||||
const clickedIndex = elements[0].index;
|
||||
const detailKey = detailKeys[clickedIndex];
|
||||
if (detailKey) {
|
||||
renderDetailChart(detailKey);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderRootChart = () => {
|
||||
chart.config.type = sourceNode.dataset.chartType || "pie";
|
||||
chart.data.labels = rootLabels;
|
||||
chart.data.datasets = [
|
||||
{
|
||||
label: "Kategorien",
|
||||
data: rootValues,
|
||||
backgroundColor: palette,
|
||||
borderWidth: 0,
|
||||
},
|
||||
];
|
||||
chart.options.plugins.legend.display = true;
|
||||
chart.update();
|
||||
isInDetail = false;
|
||||
if (titleTarget) {
|
||||
titleTarget.textContent = rootTitle.title;
|
||||
}
|
||||
if (subtitleTarget) {
|
||||
subtitleTarget.textContent = rootTitle.subtitle;
|
||||
}
|
||||
if (backButton) {
|
||||
backButton.hidden = true;
|
||||
}
|
||||
};
|
||||
|
||||
const renderDetailChart = (detailKey) => {
|
||||
const detail = detailMap[detailKey];
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
if (titleTarget) {
|
||||
titleTarget.textContent = detail.label;
|
||||
}
|
||||
if (subtitleTarget) {
|
||||
subtitleTarget.textContent = `${detail.account} · ${detail.labels.length} Einträge`;
|
||||
}
|
||||
chart.config.type = "doughnut";
|
||||
chart.data.labels = detail.labels;
|
||||
chart.data.datasets = [
|
||||
{
|
||||
label: detail.label,
|
||||
data: detail.values,
|
||||
backgroundColor: palette,
|
||||
borderWidth: 0,
|
||||
},
|
||||
];
|
||||
chart.options.plugins.legend.display = true;
|
||||
chart.update();
|
||||
isInDetail = true;
|
||||
if (backButton) {
|
||||
backButton.hidden = false;
|
||||
}
|
||||
};
|
||||
|
||||
if (backButton) {
|
||||
backButton.addEventListener("click", () => {
|
||||
renderRootChart();
|
||||
});
|
||||
}
|
||||
|
||||
renderRootChart();
|
||||
});
|
||||
}
|
||||
|
||||
function mountDialogs() {
|
||||
const openDialogById = (dialogId, node = null) => {
|
||||
const dialog = document.getElementById(dialogId);
|
||||
if (!dialog) return;
|
||||
document.querySelectorAll("dialog[open]").forEach((openDialog) => {
|
||||
if (openDialog !== dialog) {
|
||||
openDialog.close();
|
||||
}
|
||||
});
|
||||
const accountIdInput = dialog.querySelector("[data-dialog-account-id]");
|
||||
const areaInput = dialog.querySelector("[data-dialog-area]");
|
||||
const categoryNameInput = dialog.querySelector("[data-dialog-category-name]");
|
||||
const returnDialogInput = dialog.querySelector("[data-dialog-return-dialog]");
|
||||
const placeholderInput = dialog.querySelector("[data-dialog-name-placeholder]");
|
||||
const communityAccountField = dialog.querySelector("[data-community-account-field]");
|
||||
if (accountIdInput) {
|
||||
accountIdInput.value = node?.dataset.accountId || "";
|
||||
}
|
||||
if (areaInput) {
|
||||
areaInput.value = node?.dataset.area || areaInput.defaultValue || areaInput.value;
|
||||
}
|
||||
if (categoryNameInput) {
|
||||
categoryNameInput.value = node?.dataset.categoryName || "";
|
||||
}
|
||||
if (placeholderInput) {
|
||||
if (!placeholderInput.dataset.defaultPlaceholder) {
|
||||
placeholderInput.dataset.defaultPlaceholder = placeholderInput.placeholder;
|
||||
}
|
||||
placeholderInput.placeholder = node?.dataset.placeholder || placeholderInput.dataset.defaultPlaceholder;
|
||||
}
|
||||
if (returnDialogInput) {
|
||||
returnDialogInput.value = node?.dataset.returnDialog || "";
|
||||
}
|
||||
if (communityAccountField && areaInput) {
|
||||
communityAccountField.hidden = areaInput.value !== "budget";
|
||||
}
|
||||
dialog.querySelectorAll("[data-annual-visibility]").forEach((element) => {
|
||||
element.hidden = areaInput ? areaInput.value !== "budget" : false;
|
||||
});
|
||||
dialog.showModal();
|
||||
};
|
||||
|
||||
document.querySelectorAll("[data-open-dialog]").forEach((node) => {
|
||||
node.addEventListener("click", () => {
|
||||
openDialogById(node.dataset.openDialog, node);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("dialog").forEach((dialog) => {
|
||||
dialog.addEventListener("click", (event) => {
|
||||
const rect = dialog.getBoundingClientRect();
|
||||
const inside =
|
||||
rect.top <= event.clientY &&
|
||||
event.clientY <= rect.bottom &&
|
||||
rect.left <= event.clientX &&
|
||||
event.clientX <= rect.right;
|
||||
if (!inside) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const initialDialogId = new URLSearchParams(window.location.search).get("dialog");
|
||||
if (initialDialogId) {
|
||||
openDialogById(initialDialogId);
|
||||
const nextUrl = new URL(window.location.href);
|
||||
nextUrl.searchParams.delete("dialog");
|
||||
window.history.replaceState({}, "", nextUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function mountPersonalSplitSync() {
|
||||
const floInputs = document.querySelectorAll('[data-personal-split="flo"]');
|
||||
const desiInputs = document.querySelectorAll('[data-personal-split="desi"]');
|
||||
const syncPair = (source, target) => {
|
||||
source.addEventListener("input", () => {
|
||||
const sourceValue = Number(source.value || 0);
|
||||
const boundedValue = Math.max(0, Math.min(100, sourceValue));
|
||||
target.value = String(Math.max(0, Math.min(100, 100 - boundedValue)));
|
||||
});
|
||||
};
|
||||
floInputs.forEach((floInput, index) => {
|
||||
const desiInput = desiInputs[index];
|
||||
if (!desiInput) return;
|
||||
syncPair(floInput, desiInput);
|
||||
syncPair(desiInput, floInput);
|
||||
});
|
||||
}
|
||||
|
||||
function mountAnnualAmountSync() {
|
||||
document.querySelectorAll("[data-annual-sync-wrapper]").forEach((wrapper) => {
|
||||
const monthlyInput = wrapper.querySelector('[data-annual-sync="monthly"]');
|
||||
const yearlyInput = wrapper.querySelector('[data-annual-sync="yearly"]');
|
||||
if (!monthlyInput || !yearlyInput) return;
|
||||
let syncing = false;
|
||||
const formatValue = (value) => (Number.isFinite(value) ? value.toFixed(2) : "");
|
||||
|
||||
monthlyInput.addEventListener("input", () => {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
const monthlyValue = Number(monthlyInput.value || 0);
|
||||
yearlyInput.value = formatValue(monthlyValue * 12);
|
||||
syncing = false;
|
||||
});
|
||||
|
||||
yearlyInput.addEventListener("input", () => {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
const yearlyValue = Number(yearlyInput.value || 0);
|
||||
monthlyInput.value = formatValue(yearlyValue / 12);
|
||||
syncing = false;
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('form').forEach((form) => {
|
||||
const annualVisibility = form.querySelector("[data-annual-visibility]");
|
||||
const areaInput = form.querySelector("[data-dialog-area]");
|
||||
if (!annualVisibility || !areaInput) return;
|
||||
annualVisibility.hidden = areaInput.value !== "budget";
|
||||
});
|
||||
}
|
||||
|
||||
function mountThemeToggle() {
|
||||
const storageKey = "saldo-theme";
|
||||
const body = document.body;
|
||||
if (!body) return;
|
||||
|
||||
const applyTheme = (theme) => {
|
||||
body.dataset.theme = theme;
|
||||
};
|
||||
|
||||
const storedTheme = window.localStorage.getItem(storageKey);
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
applyTheme(storedTheme || (prefersDark ? "dark" : "light"));
|
||||
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach((node) => {
|
||||
node.addEventListener("click", () => {
|
||||
const nextTheme = body.dataset.theme === "dark" ? "light" : "dark";
|
||||
applyTheme(nextTheme);
|
||||
window.localStorage.setItem(storageKey, nextTheme);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
await registerServiceWorker();
|
||||
injectCsrfTokens();
|
||||
mountThemeToggle();
|
||||
mountCharts();
|
||||
mountDialogs();
|
||||
mountPersonalSplitSync();
|
||||
mountAnnualAmountSync();
|
||||
document.querySelectorAll("[data-enable-push]").forEach((node) => {
|
||||
node.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
await enablePushNotifications();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Saldo",
|
||||
"short_name": "Saldo",
|
||||
"description": "Mehrbenutzer-Finanzplanung für Haushaltsmonate und Überweisungsverteilungen.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f4efe6",
|
||||
"theme_color": "#146a63",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/landmark.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
const CACHE_NAME = "saldo-shell-v1";
|
||||
const APP_SHELL = [
|
||||
"/",
|
||||
"/static/css/app.css",
|
||||
"/static/js/app.js",
|
||||
"/static/manifest.json",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)));
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.method !== "GET") return;
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((response) => response || fetch(event.request))
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
const data = event.data ? event.data.json() : { title: "Saldo", body: "Neue Erinnerung" };
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: "/static/icons/landmark.svg",
|
||||
badge: "/static/icons/bell.svg",
|
||||
data: { url: data.url || "/" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
event.waitUntil(clients.openWindow(event.notification.data.url || "/"));
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{% macro avatar(name, avatar_url=None, initials=None, size='md', class_name='') -%}
|
||||
<span class="avatar avatar-{{ size }} {{ class_name }}" aria-label="{{ name }}">
|
||||
{% if avatar_url %}
|
||||
<img src="{{ avatar_url }}" alt="{{ name }}">
|
||||
{% else %}
|
||||
<span>{{ initials or (name[:2] if name else '?') }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,74 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Optionen | {{ app_name }}{% endblock %}
|
||||
{% block content %}
|
||||
{% from "_ui.html" import avatar %}
|
||||
<section class="page-hero">
|
||||
<div>
|
||||
<div class="eyebrow">Optionen</div>
|
||||
<h1>Benutzerverwaltung</h1>
|
||||
<p class="muted">Kategorien, Einträge und Split-Personen werden jetzt direkt in der Planung gepflegt. Hier bleiben nur App-Zugänge und Rollen.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h2>Benutzer</h2></div>
|
||||
<form method="post" action="{{ url_for('admin.create_user') }}" class="stack-form" enctype="multipart/form-data">
|
||||
<input name="username" placeholder="Benutzername" required>
|
||||
<input name="display_name" placeholder="Anzeigename" required>
|
||||
<input name="email" type="email" placeholder="E-Mail" required>
|
||||
<label>
|
||||
<span>Avatar hochladen</span>
|
||||
<input name="avatar_file" type="file" accept="image/*">
|
||||
</label>
|
||||
<input name="avatar_url" placeholder="Avatar-URL optional">
|
||||
<input name="password" type="password" placeholder="Passwort" required>
|
||||
<select name="role">
|
||||
<option value="editor">editor</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button class="primary-button" type="submit">Benutzer anlegen</button>
|
||||
</form>
|
||||
{% for user in users %}
|
||||
<div class="month-row">
|
||||
<div class="list-row-main">
|
||||
{{ avatar(user.ui_name, user.avatar_url, user.avatar_initials, "sm") }}
|
||||
<div>
|
||||
<strong>{{ user.ui_name }}</strong>
|
||||
<small>{{ user.role }} · {{ "aktiv" if user.is_active else "deaktiviert" }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button class="ghost-button" type="button" data-open-dialog="user-dialog-{{ user.id }}">Bearbeiten</button>
|
||||
<form method="post" action="{{ url_for('admin.toggle_user', user_id=user.id) }}">
|
||||
<button class="ghost-button" type="submit">Status ändern</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
{% for user in users %}
|
||||
<dialog id="user-dialog-{{ user.id }}" class="app-dialog">
|
||||
<form method="dialog" class="dialog-close-row">
|
||||
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('admin.update_user', user_id=user.id) }}" class="stack-form" enctype="multipart/form-data">
|
||||
<h3>{{ user.ui_name }} bearbeiten</h3>
|
||||
<input value="{{ user.username }}" disabled>
|
||||
<input name="display_name" value="{{ user.ui_name }}" required>
|
||||
<input name="email" type="email" value="{{ user.email }}" required>
|
||||
<label>
|
||||
<span>Avatar hochladen</span>
|
||||
<input name="avatar_file" type="file" accept="image/*">
|
||||
</label>
|
||||
<input name="avatar_url" value="{{ user.avatar_url or '' }}" placeholder="Avatar-URL optional">
|
||||
<select name="role">
|
||||
<option value="editor" {% if user.role == "editor" %}selected{% endif %}>editor</option>
|
||||
<option value="admin" {% if user.role == "admin" %}selected{% endif %}>admin</option>
|
||||
</select>
|
||||
<label class="check-label"><input type="checkbox" name="is_active" {% if user.is_active %}checked{% endif %}> Aktiv</label>
|
||||
<button class="primary-button" type="submit">Speichern</button>
|
||||
</form>
|
||||
</dialog>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Login | Saldo</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<section class="login-card">
|
||||
<div class="eyebrow">Saldo</div>
|
||||
<h1>Monate planen, ohne Tabellenfrust.</h1>
|
||||
<p class="muted">Mehrbenutzer-Haushaltsplanung für wiederkehrende Überweisungen, variable Einkommen und faire Beteiligungen.</p>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash flash-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<form method="post" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Benutzername
|
||||
<input name="username" required autocomplete="username">
|
||||
</label>
|
||||
<label>Passwort
|
||||
<input type="password" name="password" required autocomplete="current-password">
|
||||
</label>
|
||||
<button type="submit" class="primary-button">Einloggen</button>
|
||||
</form>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Setup | Saldo</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<section class="login-card">
|
||||
<div class="eyebrow">Erststart</div>
|
||||
<h1>Saldo einrichten</h1>
|
||||
<p class="muted">Lege jetzt den ersten Admin an. Danach landest du direkt in der App und kannst Budgets, Personen und Monate selbst pflegen.</p>
|
||||
<div class="setup-card subtle">
|
||||
<strong>Was bereits vorhanden ist</strong>
|
||||
<p>Die Grundstruktur mit Budgets, Kategorien, Einträgen und Gemeinschaftskonten ist vorbereitet. Es wurden keine Beispielzugänge und keine Beispielpersonen angelegt.</p>
|
||||
</div>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash flash-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<form method="post" class="stack-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Benutzername
|
||||
<input name="username" required autocomplete="username">
|
||||
</label>
|
||||
<label>Anzeigename
|
||||
<input name="display_name" required autocomplete="name">
|
||||
</label>
|
||||
<label>E-Mail
|
||||
<input name="email" type="email" required autocomplete="email">
|
||||
</label>
|
||||
<label>Passwort
|
||||
<input name="password" type="password" required autocomplete="new-password">
|
||||
</label>
|
||||
<label>Passwort wiederholen
|
||||
<input name="password_confirm" type="password" required autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit" class="primary-button">Admin anlegen</button>
|
||||
</form>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#f4efe6">
|
||||
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||
<script defer src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
{% from "_ui.html" import avatar %}
|
||||
<body data-vapid-public-key="{{ vapid_public_key }}" data-theme="light" data-csrf-token="{{ csrf_token }}">
|
||||
<div class="app-shell">
|
||||
{% if current_user.is_authenticated %}
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="{{ url_for('main.index') }}">
|
||||
<span class="brand-mark">S</span>
|
||||
<span>
|
||||
<strong>Saldo</strong>
|
||||
<small>Haushalt gemeinsam planen</small>
|
||||
</span>
|
||||
</a>
|
||||
<nav class="nav-group">
|
||||
<a href="{{ url_for('main.index') }}" class="nav-link"><img src="{{ url_for('static', filename='icons/house.svg') }}" alt="" class="ui-icon">Übersicht</a>
|
||||
<a href="{{ url_for('planning.current') }}" class="nav-link"><img src="{{ url_for('static', filename='icons/wallet.svg') }}" alt="" class="ui-icon">Planung</a>
|
||||
<a href="{{ url_for('months.index') }}" class="nav-link"><img src="{{ url_for('static', filename='icons/calendar.svg') }}" alt="" class="ui-icon">Monate</a>
|
||||
<a href="{{ url_for('main.analytics') }}" class="nav-link"><img src="{{ url_for('static', filename='icons/chart-simple.svg') }}" alt="" class="ui-icon">Auswertungen</a>
|
||||
{% if current_user.is_admin() %}
|
||||
<a href="{{ url_for('admin.index') }}" class="nav-link"><img src="{{ url_for('static', filename='icons/sliders.svg') }}" alt="" class="ui-icon">Optionen</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-user">
|
||||
{{ avatar(current_user.ui_name, current_user.avatar_url, current_user.avatar_initials, 'md') }}
|
||||
<div>
|
||||
<strong>{{ current_user.ui_name }}</strong>
|
||||
<small>{{ "Admin" if current_user.is_admin() else "Mitglied" }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ghost-button theme-toggle" type="button" data-theme-toggle>
|
||||
<img src="{{ url_for('static', filename='icons/moon.svg') }}" alt="" class="ui-icon theme-icon-dark">
|
||||
<img src="{{ url_for('static', filename='icons/sun.svg') }}" alt="" class="ui-icon theme-icon-light">
|
||||
Design
|
||||
</button>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||
<button class="ghost-button" type="submit"><img src="{{ url_for('static', filename='icons/arrow-right-to-bracket.svg') }}" alt="" class="ui-icon">Abmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
{% endif %}
|
||||
<main class="content">
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="content-toolbar">
|
||||
<button class="ghost-button theme-toggle mobile-theme-toggle" type="button" data-theme-toggle>
|
||||
<img src="{{ url_for('static', filename='icons/moon.svg') }}" alt="" class="ui-icon theme-icon-dark">
|
||||
<img src="{{ url_for('static', filename='icons/sun.svg') }}" alt="" class="ui-icon theme-icon-light">
|
||||
Dark Mode
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-stack">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash flash-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
{% if current_user.is_authenticated %}
|
||||
<nav class="bottom-nav">
|
||||
<a href="{{ url_for('main.index') }}"><img src="{{ url_for('static', filename='icons/house.svg') }}" alt="" class="ui-icon">Übersicht</a>
|
||||
<a href="{{ url_for('planning.current') }}"><img src="{{ url_for('static', filename='icons/wallet.svg') }}" alt="" class="ui-icon">Planung</a>
|
||||
<a href="{{ url_for('months.index') }}"><img src="{{ url_for('static', filename='icons/calendar.svg') }}" alt="" class="ui-icon">Monate</a>
|
||||
<a href="{{ url_for('main.analytics') }}"><img src="{{ url_for('static', filename='icons/chart-simple.svg') }}" alt="" class="ui-icon">Auswertungen</a>
|
||||
{% if current_user.is_admin() %}
|
||||
<a href="{{ url_for('admin.index') }}"><img src="{{ url_for('static', filename='icons/sliders.svg') }}" alt="" class="ui-icon">Optionen</a>
|
||||
{% else %}
|
||||
<a href="#" data-enable-push><img src="{{ url_for('static', filename='icons/bell.svg') }}" alt="" class="ui-icon">Push</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,105 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Auswertungen | {{ app_name }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="page-hero">
|
||||
<div>
|
||||
<div class="eyebrow">Auswertungen</div>
|
||||
<h1>Kosten, Kategorien und Zuordnung</h1>
|
||||
<p class="muted">Alle Diagramme beziehen sich auf den aktuellen Monat {{ month.label }}.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chart-grid analytics-grid">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2 id="category-chart-title">Kategorien im Monat</h2>
|
||||
<small id="category-chart-subtitle">Tippe auf einen Bereich, um die Untereinträge im selben Diagramm zu sehen.</small>
|
||||
</div>
|
||||
<button id="category-chart-back" class="ghost-button small-button" type="button" hidden>Zurück</button>
|
||||
</div>
|
||||
<div class="chart-shell">
|
||||
<canvas
|
||||
id="category-chart"
|
||||
class="chart"
|
||||
data-chart-type="pie"
|
||||
data-drilldown-source="true"
|
||||
data-labels='{{ category_labels|tojson }}'
|
||||
data-values='{{ category_values|tojson }}'
|
||||
data-detail-keys='{{ category_keys|tojson }}'
|
||||
data-detail-map='{{ category_entry_map|tojson }}'
|
||||
data-detail-title-target="category-chart-title"
|
||||
data-detail-subtitle-target="category-chart-subtitle"
|
||||
data-detail-back-target="category-chart-back"
|
||||
data-default-detail-key='{{ default_category_id }}'></canvas>
|
||||
</div>
|
||||
<script type="application/json" id="category-chart-root-title">{"title":"Kategorien im Monat","subtitle":"Tippe auf einen Bereich, um die Untereinträge im selben Diagramm zu sehen."}</script>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Kosten nach Zuordnung</h2>
|
||||
<small>Welche Ausgaben welchen registrierten Nutzern zugeordnet sind.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-shell">
|
||||
<canvas
|
||||
class="chart"
|
||||
data-chart-type="bar"
|
||||
data-labels='{{ benefit_labels|tojson }}'
|
||||
data-values='{{ benefit_values|tojson }}'></canvas>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Kosten nach Hauptbereichen</h2>
|
||||
<small>Hilft beim schnellen Blick auf Gemeinschaft, Sparen, Freizeit und persönliche Auszahlung.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-shell">
|
||||
<canvas
|
||||
class="chart"
|
||||
data-chart-type="bar"
|
||||
data-index-axis="y"
|
||||
data-labels='{{ account_labels|tojson }}'
|
||||
data-values='{{ account_values|tojson }}'></canvas>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel analytics-wide-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Budgets im Monatsverlauf</h2>
|
||||
<small>Zeigt, wie sich die einzelnen Budgetbereiche von Monat zu Monat entwickeln.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-shell chart-shell-tall">
|
||||
<canvas
|
||||
class="chart"
|
||||
data-chart-type="line"
|
||||
data-labels='{{ budget_timeline_labels|tojson }}'
|
||||
data-datasets='{{ budget_timeline_datasets|tojson }}'></canvas>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel analytics-wide-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Größte Einträge im Monat</h2>
|
||||
<small>Die teuersten Positionen über alle Kategorien hinweg.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-shell chart-shell-tall">
|
||||
<canvas
|
||||
class="chart"
|
||||
data-chart-type="bar"
|
||||
data-index-axis="y"
|
||||
data-labels='{{ top_entry_labels|tojson }}'
|
||||
data-values='{{ top_entry_values|tojson }}'></canvas>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,196 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Übersicht | {{ app_name }}{% endblock %}
|
||||
{% block content %}
|
||||
{% from "_ui.html" import avatar %}
|
||||
<section class="page-hero">
|
||||
<div>
|
||||
<div class="eyebrow">Aktueller Monat</div>
|
||||
<h1>{{ month.label }}</h1>
|
||||
<p class="muted">
|
||||
{% if month.auto_created %}
|
||||
Monat wurde automatisch vorbereitet und kann jetzt angepasst werden.
|
||||
{% else %}
|
||||
Planung, Vorschläge und externe Beteiligungen auf einen Blick.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ url_for('planning.detail', label=month.label) }}" class="primary-button">Planung öffnen</a>
|
||||
</section>
|
||||
|
||||
<section class="cards-grid">
|
||||
<article class="metric-card">
|
||||
<span>Einkommen</span>
|
||||
<strong>{{ summary.total_income|currency }}</strong>
|
||||
<small>Delta zum Vormonat {{ summary.deltas.income_delta|currency }}</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>Kosten</span>
|
||||
<strong>{{ summary.total_costs|currency }}</strong>
|
||||
<small>Delta zum Vormonat {{ summary.deltas.cost_delta|currency }}</small>
|
||||
</article>
|
||||
<article class="metric-card highlight">
|
||||
<span>Restbetrag</span>
|
||||
<strong>{{ summary.remainder|currency }}</strong>
|
||||
<small>Delta zum Vormonat {{ summary.deltas.remainder_delta|currency }}</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>Vorgeschlagene Verteilung</span>
|
||||
<strong>{{ summary.suggestion_total|currency }}</strong>
|
||||
<small>{{ summary.suggestions|length }} Zielkonten vorbereitet</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="spotlight-grid split-layout">
|
||||
<article class="panel featured-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Daueraufträge prüfen</h2>
|
||||
<small>Welche Unterkonten sich gegenüber dem Vormonat geändert haben.</small>
|
||||
</div>
|
||||
</div>
|
||||
{% if shared_account_changes %}
|
||||
{% for account in shared_account_changes %}
|
||||
<div class="list-row">
|
||||
<div>
|
||||
<strong>{{ account.community_account.name }}</strong>
|
||||
<small>{{ account.current_total|currency }} geplant · bisher {{ account.previous_total|currency }}</small>
|
||||
</div>
|
||||
<span class="badge badge-warn">{{ account.delta|currency }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">Alle Unterkonten passen zum Vormonat. Daueraufträge müssen gerade nicht angepasst werden.</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Persönliche Auszahlung</h2>
|
||||
<small>Automatisch aus dem aktuellen Restbetrag verteilt.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="participant-card-grid">
|
||||
<div class="participant-manage-card">
|
||||
<div class="list-row-main">
|
||||
{{ avatar(personal_payouts.first.name, personal_payouts.first.avatar_url, personal_payouts.first.avatar_initials, "md") }}
|
||||
<strong>{{ personal_payouts.first.name }}</strong>
|
||||
</div>
|
||||
<span>{{ personal_payouts.first.amount|currency }}</span>
|
||||
</div>
|
||||
<div class="participant-manage-card">
|
||||
<div class="list-row-main">
|
||||
{{ avatar(personal_payouts.second.name, personal_payouts.second.avatar_url, personal_payouts.second.avatar_initials, "md") }}
|
||||
<strong>{{ personal_payouts.second.name }}</strong>
|
||||
</div>
|
||||
<span>{{ personal_payouts.second.amount|currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="split-layout">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Extern mitzuteilen</h2>
|
||||
</div>
|
||||
{% if summary.external_totals %}
|
||||
{% for person in summary.external_totals %}
|
||||
<button type="button" class="list-row clickable-list-row" data-open-dialog="external-person-{{ person.participant_id }}">
|
||||
<div class="list-row-main">
|
||||
{{ avatar(person.participant_name, person.participant_avatar_url, person.participant_avatar_initials, "sm") }}
|
||||
<div>
|
||||
<strong>{{ person.participant_name }}</strong>
|
||||
<small>{{ person["items"]|length }} Positionen</small>
|
||||
</div>
|
||||
</div>
|
||||
<strong>{{ person.total|currency }}</strong>
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">Für diesen Monat gibt es keine externen Anteile.</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Offene Hinweise</h2>
|
||||
</div>
|
||||
{% if notifications %}
|
||||
{% for note in notifications %}
|
||||
<div class="note-card">
|
||||
<strong>{{ note.title }}</strong>
|
||||
<p>{{ note.body }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">Keine offenen Hinweise. Das sieht gut aus.</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="split-layout">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Größte Änderungen</h2>
|
||||
</div>
|
||||
{% if summary.top_changes %}
|
||||
{% for item in summary.top_changes %}
|
||||
<div class="list-row">
|
||||
<div>
|
||||
<strong>{{ item.entry_name }}</strong>
|
||||
<small>{{ item.category_name }}</small>
|
||||
</div>
|
||||
<strong>{{ item.delta|currency }}</strong>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">Noch keine Vergleichsdaten zum Vormonat vorhanden.</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Letzte 6 Monate</h2>
|
||||
</div>
|
||||
{% for item in recent_months %}
|
||||
<a class="list-row link-row" href="{{ url_for('planning.detail', label=item.label) }}">
|
||||
<div>
|
||||
<strong>{{ item.label }}</strong>
|
||||
<small>{% if item.auto_created %}automatisch erstellt{% else %}manuell gepflegt{% endif %}</small>
|
||||
</div>
|
||||
<span>Öffnen</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{% for person in summary.external_totals %}
|
||||
<dialog id="external-person-{{ person.participant_id }}" class="app-dialog">
|
||||
<form method="dialog" class="dialog-close-row">
|
||||
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
|
||||
</form>
|
||||
<div class="stack-form">
|
||||
<div class="dialog-section-head">
|
||||
<div class="list-row-main">
|
||||
{{ avatar(person.participant_name, person.participant_avatar_url, person.participant_avatar_initials, "md") }}
|
||||
<div>
|
||||
<h3>{{ person.participant_name }}</h3>
|
||||
<small>{{ person.total|currency }} gesamt</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-entry-list">
|
||||
{% for item in person["items"] %}
|
||||
<div class="dialog-entry-row static-entry-row">
|
||||
<div>
|
||||
<strong>{{ item.entry_name }}</strong>
|
||||
<small>Extern mitzuteilen</small>
|
||||
</div>
|
||||
<span>{{ item.amount|currency }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Monate | {{ app_name }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="page-hero">
|
||||
<div>
|
||||
<div class="eyebrow">Monatsverwaltung</div>
|
||||
<h1>Monate vorbereiten und sperren</h1>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('months.create') }}" class="inline-form">
|
||||
<input type="month" name="label" required>
|
||||
<button class="primary-button" type="submit">Monat anlegen</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
{% for month in months %}
|
||||
<div class="month-row">
|
||||
<div>
|
||||
<strong>{{ month.label }}</strong>
|
||||
<small>
|
||||
{% if month.auto_created %}automatisch erstellt{% else %}manuell erstellt{% endif %}
|
||||
{% if month.is_locked %} · gesperrt{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<a class="ghost-button" href="{{ url_for('planning.detail', label=month.label) }}">Öffnen</a>
|
||||
<form method="post" action="{{ url_for('months.toggle_lock', label=month.label) }}">
|
||||
<button class="ghost-button" type="submit">{{ "Entsperren" if month.is_locked else "Sperren" }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,815 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Planung {{ month.label }} | {{ app_name }}{% endblock %}
|
||||
{% block content %}
|
||||
{% from "_ui.html" import avatar %}
|
||||
<section class="planning-hero planning-hero-strong">
|
||||
<div class="planning-title">
|
||||
<div class="eyebrow">Planung</div>
|
||||
<h1>{{ planning_heading }}</h1>
|
||||
<p class="muted">Monat direkt auf dieser Seite pflegen: Einkommen, Kategorien, Einträge, Verteilung und Split-Personen.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cards-grid cards-grid-four">
|
||||
<article class="metric-card">
|
||||
<span>Gesamteinkommen</span>
|
||||
<strong>{{ summary.total_income|currency }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>Gesamtkosten</span>
|
||||
<strong>{{ summary.total_costs|currency }}</strong>
|
||||
</article>
|
||||
<article class="metric-card highlight">
|
||||
<span>Restbetrag</span>
|
||||
<strong>{{ summary.remainder|currency }}</strong>
|
||||
<small>Aktuelle Verteilung {{ summary.allocation_total|currency }}</small>
|
||||
</article>
|
||||
<article class="metric-card soft-accent">
|
||||
<span>Vorschläge</span>
|
||||
<strong>{{ summary.suggestion_total|currency }}</strong>
|
||||
<small>Bis Mindestziel offen</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="planning-hub-grid">
|
||||
<button type="button" class="summary-category-card utility-summary-card" data-open-dialog="income-dialog">
|
||||
<div class="summary-card-head">
|
||||
<strong>Einkommen</strong>
|
||||
<img src="{{ url_for('static', filename='icons/pencil.svg') }}" alt="" class="ui-icon small-ui-icon">
|
||||
</div>
|
||||
<div class="summary-card-meta">
|
||||
<span>{{ summary.total_income|currency }}</span>
|
||||
<small>{{ month.incomes|length }} Zeilen</small>
|
||||
</div>
|
||||
</button>
|
||||
<button type="button" class="summary-category-card utility-summary-card" data-open-dialog="split-people-dialog">
|
||||
<div class="summary-card-head">
|
||||
<strong>Personen für Splits</strong>
|
||||
<img src="{{ url_for('static', filename='icons/pencil.svg') }}" alt="" class="ui-icon small-ui-icon">
|
||||
</div>
|
||||
<div class="summary-card-meta">
|
||||
<span>{{ participants|length }}</span>
|
||||
<small>{{ participant_summary.internal_count }} Nutzer, {{ participant_summary.external_count }} {{ "Gast" if participant_summary.external_count == 1 else "Gäste" }}</small>
|
||||
</div>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="account-board">
|
||||
<article class="panel account-panel premium-panel">
|
||||
<div class="panel-head account-head">
|
||||
<div>
|
||||
<h2>Gemeinschaftskonten</h2>
|
||||
<small>{{ community_account_summary.shared_count }} Konten · {{ community_account_summary.personal_count }} privat</small>
|
||||
</div>
|
||||
<button class="ghost-button icon-button" type="button" data-open-dialog="community-account-create-dialog">
|
||||
<img src="{{ url_for('static', filename='icons/plus.svg') }}" alt="" class="ui-icon small-ui-icon">
|
||||
Konto
|
||||
</button>
|
||||
</div>
|
||||
<div class="category-summary-grid">
|
||||
{% for card in community_account_cards %}
|
||||
{% if card.is_read_only %}
|
||||
<div class="summary-category-card summary-static-card community-account-card">
|
||||
<div class="summary-card-head">
|
||||
<strong>{{ card.community_account.name }}</strong>
|
||||
<span class="icon-label muted-label">Nur Anzeige</span>
|
||||
</div>
|
||||
<div class="summary-card-meta">
|
||||
<span>{{ card.current_total|currency }}</span>
|
||||
<small>Persönliche Auszahlung</small>
|
||||
</div>
|
||||
{% if card.delta %}
|
||||
<div class="card-inline-note">
|
||||
<span class="badge {% if card.delta > 0 %}badge-warn{% else %}badge-good{% endif %}">
|
||||
{{ card.delta|currency }} zum Vormonat
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<button type="button" class="summary-category-card community-account-card" data-open-dialog="community-account-item-{{ card.community_account.id }}">
|
||||
<div class="summary-card-head">
|
||||
<strong>{{ card.community_account.name }}</strong>
|
||||
<img src="{{ url_for('static', filename='icons/pencil.svg') }}" alt="" class="ui-icon small-ui-icon">
|
||||
</div>
|
||||
<div class="summary-card-meta">
|
||||
<span>{{ card.current_total|currency }}</span>
|
||||
<small>{{ card.assigned_budget_names|length }} Budgets</small>
|
||||
</div>
|
||||
{% if card.delta %}
|
||||
<div class="card-inline-note">
|
||||
<span class="badge {% if card.delta > 0 %}badge-warn{% else %}badge-good{% endif %}">
|
||||
{{ card.delta|currency }} zum Vormonat
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if card.assigned_budget_names %}
|
||||
<div class="card-inline-note">
|
||||
<small>{{ card.assigned_budget_names|join(", ") }}</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="account-board">
|
||||
{% for account_data in planning_accounts %}
|
||||
{% if account_data.categories %}
|
||||
<article class="panel account-panel premium-panel">
|
||||
<div class="panel-head account-head">
|
||||
<div>
|
||||
<h2>{{ account_data.account.name }}</h2>
|
||||
<small>
|
||||
Gesamtkosten {{ account_data.total|currency }}
|
||||
{% if account_data.account.slug == "gemeinschaftskonto" %}
|
||||
· Jährlich {{ (account_data.total * 12)|currency }}
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
{% if account_data.account.slug == "sparen-und-verteilung" %}
|
||||
<button class="ghost-button icon-button" type="button" data-open-dialog="dialog-add-category" data-area="distribution" data-placeholder="Name Sparkonto" data-dialog-label="Sparkonto">
|
||||
<img src="{{ url_for('static', filename='icons/plus.svg') }}" alt="" class="ui-icon small-ui-icon">
|
||||
Sparkonto
|
||||
</button>
|
||||
{% elif account_data.account.slug == "gemeinschaftskonto" %}
|
||||
<button class="ghost-button icon-button" type="button" data-open-dialog="dialog-add-category" data-area="budget" data-placeholder="Name Budget">
|
||||
<img src="{{ url_for('static', filename='icons/plus.svg') }}" alt="" class="ui-icon small-ui-icon">
|
||||
Kategorie
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="ghost-button icon-button" type="button" data-open-dialog="dialog-add-category" data-account-id="{{ account_data.account.id }}">
|
||||
<img src="{{ url_for('static', filename='icons/plus.svg') }}" alt="" class="ui-icon small-ui-icon">
|
||||
Kategorie
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="category-summary-grid">
|
||||
{% for category_data in account_data.categories %}
|
||||
<button type="button" class="summary-category-card {% if category_data.distribution_hint and category_data.distribution_hint.status %}range-status-{{ category_data.distribution_hint.status }}{% endif %}" data-open-dialog="{{ category_data.dialog_id }}">
|
||||
<div class="summary-card-head">
|
||||
<div>
|
||||
<strong>{{ category_data.category.name }}</strong>
|
||||
{% if category_data.category.description %}
|
||||
<small>{{ category_data.category.description }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<img src="{{ url_for('static', filename='icons/pencil.svg') }}" alt="" class="ui-icon small-ui-icon">
|
||||
</div>
|
||||
<div class="summary-card-meta">
|
||||
<span>{{ category_data.total|currency }}</span>
|
||||
<small>{{ category_data.entry_count }} Einträge</small>
|
||||
</div>
|
||||
{% if category_data.distribution_hint %}
|
||||
<div class="card-inline-note">
|
||||
<small>{{ category_data.distribution_hint.range_label if category_data.distribution_hint.range_label else "Rest nach Zielkonten" }}</small>
|
||||
</div>
|
||||
<div class="card-inline-note">
|
||||
<small>Aktuell {{ category_data.distribution_hint.current_pct }} % vom Einkommen</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if category_data.distribution_kind == "personal" and category_data.distribution_suggestion_total is not none %}
|
||||
<div class="card-inline-note">
|
||||
{% if category_data.distribution_suggestion_total > 0 %}
|
||||
<span class="badge">Automatisch +{{ category_data.distribution_suggestion_total|currency }}</span>
|
||||
{% elif category_data.distribution_suggestion_total < 0 %}
|
||||
<span class="badge">Fehlen {{ (-category_data.distribution_suggestion_total)|currency }}</span>
|
||||
{% else %}
|
||||
<span class="badge">Automatisch ausgeglichen</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<datalist id="category-suggestions">
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.name }}">{{ category.account.name }}</option>
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
|
||||
<dialog id="income-dialog" class="app-dialog">
|
||||
<form method="dialog" class="dialog-close-row">
|
||||
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
|
||||
</form>
|
||||
<div class="stack-form">
|
||||
<div class="dialog-section-head">
|
||||
<div>
|
||||
<h3>Einkommen</h3>
|
||||
<small>{{ summary.total_income|currency }} · {{ month.incomes|length }} Zeilen</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sheet-card-grid">
|
||||
{% for income in month.incomes|sort(attribute='sort_order') %}
|
||||
<form method="post" action="{{ url_for('planning.update_income', label=month.label) }}" class="sheet-card">
|
||||
<input type="hidden" name="income_id" value="{{ income.id }}">
|
||||
<input type="hidden" name="return_dialog" value="income-dialog">
|
||||
<input name="income_label" value="{{ income.label }}" placeholder="Name der Einkommenszeile" required>
|
||||
<input name="amount" type="number" step="0.01" inputmode="decimal" value="{{ income.amount }}">
|
||||
<div class="dialog-action-row dialog-action-spread">
|
||||
<button class="ghost-button small-button" type="submit">Speichern</button>
|
||||
{% if month.incomes|length > 1 %}
|
||||
<button class="ghost-button danger-button small-button" type="submit" formaction="{{ url_for('planning.delete_income', label=month.label, income_id=income.id) }}">Löschen</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('planning.create_income', label=month.label) }}" class="soft-form-section stack-form">
|
||||
<div class="dialog-section-head">
|
||||
<div>
|
||||
<strong>Neue Einkommenszeile</strong>
|
||||
<small>Zum Beispiel Gehalt, Bonus oder Nebenjob.</small>
|
||||
</div>
|
||||
</div>
|
||||
<input name="income_label" placeholder="Name der Einkommenszeile" required>
|
||||
<input name="amount" type="number" step="0.01" inputmode="decimal" placeholder="Betrag">
|
||||
<button class="primary-button" type="submit">Einkommen anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="split-people-dialog" class="app-dialog category-dialog">
|
||||
<form method="dialog" class="dialog-close-row">
|
||||
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
|
||||
</form>
|
||||
<div class="stack-form">
|
||||
<div class="dialog-section-head">
|
||||
<div>
|
||||
<h3>Personen für Splits</h3>
|
||||
<small>{{ participant_summary.internal_count }} Nutzer, {{ participant_summary.external_count }} {{ "Gast" if participant_summary.external_count == 1 else "Gäste" }}</small>
|
||||
</div>
|
||||
<button class="ghost-button icon-button" type="button" data-open-dialog="dialog-add-participant" data-return-dialog="split-people-dialog">
|
||||
<img src="{{ url_for('static', filename='icons/plus.svg') }}" alt="" class="ui-icon small-ui-icon">
|
||||
Person
|
||||
</button>
|
||||
</div>
|
||||
<div class="participant-card-grid">
|
||||
{% for participant in participants %}
|
||||
{% if participant.is_app_user %}
|
||||
<div class="participant-manage-card summary-static-card">
|
||||
<span class="list-row-main">
|
||||
{{ avatar(participant.display_name, participant.avatar_url, participant.avatar_initials, "sm") }}
|
||||
<strong>{{ participant.display_name }}</strong>
|
||||
</span>
|
||||
<small>Nutzer · automatisch aus Benutzerkonto</small>
|
||||
<span class="icon-label muted-label">Automatisch</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<button class="participant-manage-card" type="button" data-open-dialog="participant-dialog-{{ participant.id }}">
|
||||
<span class="list-row-main">
|
||||
{{ avatar(participant.display_name, participant.avatar_url, participant.avatar_initials, "sm") }}
|
||||
<strong>{{ participant.display_name }}</strong>
|
||||
</span>
|
||||
<small>Gast · {{ "aktiv" if participant.is_active else "inaktiv" }}</small>
|
||||
<span class="icon-label"><img src="{{ url_for('static', filename='icons/pencil.svg') }}" alt="" class="ui-icon small-ui-icon">Details</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="community-account-create-dialog" class="app-dialog">
|
||||
<form method="dialog" class="dialog-close-row">
|
||||
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('planning.create_community_account', label=month.label) }}" class="stack-form">
|
||||
<div class="dialog-section-head">
|
||||
<div>
|
||||
<h3>Neues Gemeinschaftskonto</h3>
|
||||
<small>Zum Beispiel separates Fixkosten- oder Reisekonto.</small>
|
||||
</div>
|
||||
</div>
|
||||
<input name="name" placeholder="Kontoname" required>
|
||||
<textarea name="description" rows="3" placeholder="Beschreibung optional"></textarea>
|
||||
<button class="primary-button" type="submit">Konto anlegen</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
{% for card in community_account_cards if not card.is_read_only %}
|
||||
<dialog id="community-account-item-{{ card.community_account.id }}" class="app-dialog">
|
||||
<form method="dialog" class="dialog-close-row">
|
||||
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('planning.update_community_account', label=month.label, community_account_id=card.community_account.id) }}" class="stack-form">
|
||||
<input type="hidden" name="return_dialog" value="community-account-item-{{ card.community_account.id }}">
|
||||
<h3>{{ card.community_account.name }}</h3>
|
||||
<small>{{ card.current_total|currency }} aktuell{% if card.delta %} · {{ card.delta|currency }} zum Vormonat{% endif %}</small>
|
||||
<input name="name" value="{{ card.community_account.name }}" required>
|
||||
<textarea name="description" rows="3" placeholder="Beschreibung optional">{{ card.community_account.description or '' }}</textarea>
|
||||
<div class="distribution-note-card">
|
||||
<div>
|
||||
<strong>Budgets zuweisen</strong>
|
||||
<small>Diese Budget-Kategorien laufen über dieses Gemeinschaftskonto. Bereits anders zugewiesene Budgets sind hier nicht auswählbar.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="participant-chip-grid">
|
||||
{% for category in categories if category.account.slug == "gemeinschaftskonto" %}
|
||||
{% if category.community_account_id in [none, card.community_account.id] %}
|
||||
<label class="participant-chip budget-assignment-chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="category_ids"
|
||||
value="{{ category.id }}"
|
||||
{% if category.community_account_id == card.community_account.id %}checked{% endif %}>
|
||||
{{ category.name }}
|
||||
</label>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if card.assigned_budget_names %}
|
||||
<small>Aktuell zugewiesen: {{ card.assigned_budget_names|join(", ") }}</small>
|
||||
{% endif %}
|
||||
<div class="dialog-action-row dialog-action-spread">
|
||||
<button class="primary-button" type="submit">Konto speichern</button>
|
||||
<button class="ghost-button danger-button" type="button" data-open-dialog="confirm-delete-community-account-{{ card.community_account.id }}">Konto löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="confirm-delete-community-account-{{ card.community_account.id }}" class="app-dialog confirm-dialog">
|
||||
<form method="dialog" class="dialog-close-row">
|
||||
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
|
||||
</form>
|
||||
<div class="stack-form">
|
||||
<h3>Konto wirklich löschen?</h3>
|
||||
<p class="muted">`{{ card.community_account.name }}` wird ausgeblendet und seine Budget-Zuordnungen werden gelöst.</p>
|
||||
<form method="post" action="{{ url_for('planning.delete_community_account', label=month.label, community_account_id=card.community_account.id) }}" class="dialog-action-row dialog-action-spread">
|
||||
<button class="ghost-button" type="button" data-open-dialog="community-account-item-{{ card.community_account.id }}">Zurück</button>
|
||||
<button class="primary-button danger-fill-button" type="submit">Jetzt löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endfor %}
|
||||
|
||||
<dialog id="dialog-add-category" class="app-dialog">
|
||||
<form method="dialog" class="dialog-close-row">
|
||||
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('planning.create_category', label=month.label) }}" class="stack-form">
|
||||
<input type="hidden" name="area" value="budget" data-dialog-area>
|
||||
<input type="hidden" name="account_id" value="" data-dialog-account-id>
|
||||
<h3>Neue Kategorie</h3>
|
||||
<select name="community_account_id" data-community-account-field>
|
||||
<option value="">Gemeinschaftskonto zuweisen</option>
|
||||
{% for community_account in community_accounts if community_account.account_type == "shared" %}
|
||||
<option value="{{ community_account.id }}">{{ community_account.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input name="name" list="category-suggestions" placeholder="Name Budget" data-dialog-name-placeholder required>
|
||||
<button class="primary-button" type="submit">Kategorie anlegen</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="dialog-add-entry" class="app-dialog">
|
||||
<form method="dialog" class="dialog-close-row">
|
||||
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('planning.create_entry', label=month.label) }}" class="stack-form">
|
||||
<h3>Neuen Eintrag anlegen</h3>
|
||||
<input type="hidden" name="area" value="budget" data-dialog-area>
|
||||
<input type="hidden" name="account_id" value="" data-dialog-account-id>
|
||||
<input type="hidden" name="return_dialog" value="" data-dialog-return-dialog>
|
||||
<input name="category_name" list="category-suggestions" data-dialog-category-name placeholder="Kategorie" required>
|
||||
<input name="name" placeholder="Eintragsname" required>
|
||||
<div class="sheet-card-grid" data-annual-sync-wrapper>
|
||||
<label>
|
||||
Monatlich
|
||||
<input name="planned_amount" type="number" step="0.01" inputmode="decimal" placeholder="Monatlicher Betrag" data-annual-sync="monthly">
|
||||
</label>
|
||||
<label data-annual-visibility>
|
||||
Jährlich
|
||||
<input name="annual_amount" type="number" step="0.01" inputmode="decimal" placeholder="Jährlicher Betrag" data-annual-sync="yearly">
|
||||
</label>
|
||||
</div>
|
||||
<select name="benefit_scope">
|
||||
{% for option in benefit_options %}
|
||||
<option value="{{ option.value }}">Betrifft {{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label class="check-label">
|
||||
<input type="checkbox" name="is_allocation_target">
|
||||
Sparkonto
|
||||
<small>Zeigt Zielbereich und direkte Budgetpflege über die Karte in Planung.</small>
|
||||
</label>
|
||||
<textarea name="note" rows="3" placeholder="Notiz optional"></textarea>
|
||||
<details class="split-picker">
|
||||
<summary class="ghost-button">Mit anderen Personen teilen</summary>
|
||||
<div class="participant-chip-grid split-panel">
|
||||
{% for participant in participants %}
|
||||
<label class="participant-chip"><input type="checkbox" name="participant_ids" value="{{ participant.id }}"> {{ participant.display_name }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
<button class="primary-button" type="submit">Eintrag anlegen</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="dialog-add-participant" class="app-dialog">
|
||||
<form method="dialog" class="dialog-close-row">
|
||||
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('planning.create_participant', label=month.label) }}" class="stack-form" enctype="multipart/form-data">
|
||||
<input type="hidden" name="return_dialog" value="" data-dialog-return-dialog>
|
||||
<h3>Neue Person</h3>
|
||||
<input name="name" placeholder="Name" required>
|
||||
<label>
|
||||
<span>Avatar hochladen</span>
|
||||
<input name="avatar_file" type="file" accept="image/*">
|
||||
</label>
|
||||
<input name="avatar_url" placeholder="Avatar-URL optional">
|
||||
<label class="check-label"><input type="checkbox" name="is_external" checked> Extern ohne App-Zugang</label>
|
||||
<button class="primary-button" type="submit">Person anlegen</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
{% for account_data in planning_accounts %}
|
||||
{% for category_data in account_data.categories %}
|
||||
{% if not category_data.is_personal_split %}
|
||||
<dialog id="{{ category_data.dialog_id }}" class="app-dialog category-dialog">
|
||||
<form method="dialog" class="dialog-close-row">
|
||||
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
|
||||
</form>
|
||||
<div class="stack-form">
|
||||
<div class="dialog-section-head">
|
||||
<div>
|
||||
<h3>{{ category_data.category.name }}</h3>
|
||||
<small>{{ category_data.total|currency }} · {{ category_data.entry_count }} Einträge</small>
|
||||
</div>
|
||||
{% if category_data.allow_new_entries %}
|
||||
<button class="ghost-button icon-button" type="button" data-open-dialog="dialog-add-entry" data-account-id="{{ category_data.category.account_id }}" data-category-name="{{ category_data.category.name }}" data-return-dialog="{{ category_data.dialog_id }}" data-area="{{ 'budget' if category_data.category.account.slug == 'gemeinschaftskonto' else 'distribution' }}">
|
||||
<img src="{{ url_for('static', filename='icons/plus.svg') }}" alt="" class="ui-icon small-ui-icon">
|
||||
Eintrag
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if category_data.distribution_kind == "single" %}
|
||||
{% set distribution_entry = category_data.entries|first %}
|
||||
{% set distribution_suggestion = distribution_entry.distribution_suggestion if distribution_entry else none %}
|
||||
{% if category_data.direct_entry %}
|
||||
<form method="post" action="{{ url_for('planning.update_entry', label=month.label) }}" class="soft-form-section stack-form">
|
||||
<input type="hidden" name="value_id" value="{{ category_data.direct_entry.value.id }}">
|
||||
<input type="hidden" name="return_dialog" value="{{ category_data.dialog_id }}">
|
||||
<input type="hidden" name="entry_name" value="{{ category_data.direct_entry.entry.name }}">
|
||||
<input type="hidden" name="category_id" value="{{ category_data.direct_entry.entry.category_id }}">
|
||||
<input type="hidden" name="benefit_scope" value="{{ category_data.direct_entry.entry.benefit_scope }}">
|
||||
<div class="dialog-section-head">
|
||||
<div>
|
||||
<strong>Budget direkt anpassen</strong>
|
||||
<small>Wenn `Sparkonto` aktiv ist, steuert dieser Eintrag das Budget direkt auf der Karte.</small>
|
||||
</div>
|
||||
</div>
|
||||
<label class="check-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_allocation_target"
|
||||
{% if category_data.direct_entry.entry.is_allocation_target %}checked{% endif %}>
|
||||
Sparkonto
|
||||
<small>Aktiviert Zielbereich und direkte Budgetpflege über diese Karte.</small>
|
||||
</label>
|
||||
<label>
|
||||
Monatliches Budget
|
||||
<input
|
||||
name="planned_amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
inputmode="decimal"
|
||||
value="{{ category_data.direct_entry.value.planned_amount }}">
|
||||
</label>
|
||||
<label class="check-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="allocation_is_locked"
|
||||
{% if category_data.direct_entry.distribution_allocation and category_data.direct_entry.distribution_allocation.is_locked %}checked{% endif %}>
|
||||
Budget fixieren
|
||||
</label>
|
||||
<button class="primary-button" type="submit">Budget speichern</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if category_data.direct_entry and category_data.direct_entry.entry.is_allocation_target %}
|
||||
<div class="distribution-note-card">
|
||||
<div>
|
||||
<strong>Verteilung</strong>
|
||||
<small>
|
||||
Der Betrag in dieser Kategorie steuert direkt die monatliche Verteilung.
|
||||
{% if category_data.distribution_hint %}
|
||||
Zielbereich {{ category_data.distribution_hint.range_label }} vom Einkommen.
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
{% if distribution_suggestion %}
|
||||
<div class="row-actions">
|
||||
<span class="badge">Noch offen {{ category_data.distribution_suggestion_total|currency }}</span>
|
||||
<form method="post" action="{{ url_for('planning.accept_single_suggestion', label=month.label, account_id=distribution_suggestion.target_account_id) }}">
|
||||
<input type="hidden" name="return_dialog" value="{{ category_data.dialog_id }}">
|
||||
<button class="ghost-button small-button" type="submit">Übernehmen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if category_data.distribution_hint %}
|
||||
<form method="post" action="{{ url_for('planning.update_distribution_settings', label=month.label, slug=category_data.distribution_account_slug) }}" class="soft-form-section stack-form">
|
||||
<input type="hidden" name="return_dialog" value="{{ category_data.dialog_id }}">
|
||||
<div class="dialog-section-head">
|
||||
<div>
|
||||
<strong>Zielbereich anpassen</strong>
|
||||
<small>Der Vorschlag arbeitet innerhalb dieses Prozentbereichs.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sheet-card-grid">
|
||||
<label>
|
||||
Von %
|
||||
<input type="number" name="min_pct" min="0" max="100" step="1" value="{{ category_data.distribution_hint.min_pct }}">
|
||||
</label>
|
||||
<label>
|
||||
Bis %
|
||||
<input type="number" name="max_pct" min="0" max="100" step="1" value="{{ category_data.distribution_hint.max_pct }}">
|
||||
</label>
|
||||
</div>
|
||||
<button class="ghost-button small-button" type="submit">Bereich speichern</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if category_data.distribution_kind != "single" %}
|
||||
<form method="post" action="{{ url_for('planning.update_category', label=month.label, category_id=category_data.category.id) }}" class="stack-form soft-form-section">
|
||||
<input name="name" value="{{ category_data.category.name }}" required>
|
||||
{% if category_data.category.account.slug == "gemeinschaftskonto" %}
|
||||
<select name="community_account_id">
|
||||
<option value="">Kein Gemeinschaftskonto</option>
|
||||
{% for community_account in community_accounts if community_account.account_type == "shared" %}
|
||||
<option value="{{ community_account.id }}" {% if category_data.category.community_account_id == community_account.id %}selected{% endif %}>{{ community_account.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
<textarea name="description" rows="3" placeholder="Beschreibung optional">{{ category_data.category.description or '' }}</textarea>
|
||||
<div class="dialog-action-row">
|
||||
<button class="primary-button" type="submit">Kategorie speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="dialog-entry-list">
|
||||
{% for item in category_data.entries %}
|
||||
{% if not (category_data.direct_entry and category_data.direct_entry.value.id == item.value.id) %}
|
||||
<button type="button" class="dialog-entry-row" data-open-dialog="{{ item.dialog_id }}">
|
||||
<div>
|
||||
<strong>{{ item.entry.name }}</strong>
|
||||
<small>
|
||||
{{ item.benefit_label }}
|
||||
{% if item.share_names %} · Split: {{ item.share_names }}{% endif %}
|
||||
{% if item.value.note %} · {{ item.value.note }}{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
<div class="entry-row-trailing">
|
||||
{% if item.entry.category and item.entry.category.account and item.entry.category.account.slug == "gemeinschaftskonto" and item.benefit_users %}
|
||||
<div class="stacked-avatars">
|
||||
{% for user in item.benefit_users %}
|
||||
{{ avatar(user.ui_name, user.avatar_url, user.avatar_initials, "sm", "stacked-avatar") }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<span>{{ item.amount|currency }}</span>
|
||||
</div>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('planning.delete_category', label=month.label, category_id=category_data.category.id) }}">
|
||||
<button class="ghost-button danger-button" type="submit">Kategorie löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
{% for account_data in planning_accounts %}
|
||||
{% for category_data in account_data.categories %}
|
||||
{% if category_data.is_personal_split %}
|
||||
<dialog id="{{ category_data.dialog_id }}" class="app-dialog category-dialog">
|
||||
<form method="dialog" class="dialog-close-row">
|
||||
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
|
||||
</form>
|
||||
<div class="stack-form">
|
||||
<div class="dialog-section-head">
|
||||
<div>
|
||||
<h3>Persönliche Auszahlung</h3>
|
||||
<small>{{ category_data.total|currency }} · {{ category_data.entry_count }} Einträge</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="distribution-note-card">
|
||||
<div>
|
||||
<strong>Split</strong>
|
||||
<small>Erst werden Sparen, Urlaub und Freizeit bedient. Der verbleibende Rest wird danach automatisch auf die persönliche Auszahlung verteilt.</small>
|
||||
</div>
|
||||
{% if category_data.distribution_suggestion_total > 0 %}
|
||||
<span class="badge">Automatisch +{{ category_data.distribution_suggestion_total|currency }}</span>
|
||||
{% elif category_data.distribution_suggestion_total < 0 %}
|
||||
<span class="badge">Fehlen {{ (-category_data.distribution_suggestion_total)|currency }}</span>
|
||||
{% else %}
|
||||
<span class="badge">Automatisch ausgeglichen</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('planning.update_personal_split', label=month.label) }}" class="soft-form-section stack-form">
|
||||
<input type="hidden" name="return_dialog" value="{{ category_data.dialog_id }}">
|
||||
<div class="dialog-section-head">
|
||||
<div>
|
||||
<strong>Aufteilung anpassen</strong>
|
||||
<small>Wenn du einen Wert aenderst, wird der andere automatisch auf 100 % ergaenzt.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sheet-card-grid">
|
||||
<label>
|
||||
{{ category_data.distribution_items[0].label if category_data.distribution_items|length > 0 else 'Person 1' }} in %
|
||||
<input
|
||||
type="number"
|
||||
name="flo_pct"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value="{{ personal_split.flo_pct }}"
|
||||
data-personal-split="flo">
|
||||
</label>
|
||||
<label>
|
||||
{{ category_data.distribution_items[1].label if category_data.distribution_items|length > 1 else 'Person 2' }} in %
|
||||
<input
|
||||
type="number"
|
||||
name="desi_pct"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value="{{ personal_split.desi_pct }}"
|
||||
data-personal-split="desi">
|
||||
</label>
|
||||
</div>
|
||||
<button class="ghost-button small-button" type="submit">Split speichern</button>
|
||||
</form>
|
||||
|
||||
<div class="personal-split-grid">
|
||||
{% for distribution_item in category_data.distribution_items %}
|
||||
<div class="sheet-card compact-sheet-card">
|
||||
<strong>{{ distribution_item.label }}</strong>
|
||||
<span>{{ distribution_item.auto_amount|currency }}</span>
|
||||
<small>Automatisch aus Restbetrag berechnet</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="dialog-entry-list">
|
||||
{% for item in category_data.entries %}
|
||||
<div class="dialog-entry-row static-entry-row">
|
||||
<div>
|
||||
<strong>{{ item.entry.name }}</strong>
|
||||
<small>
|
||||
{{ item.benefit_label }}
|
||||
{% if item.value.note %} · {{ item.value.note }}{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
<span>{{ item.amount|currency }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
{% for account_data in planning_accounts %}
|
||||
{% for category_data in account_data.categories %}
|
||||
{% for item in category_data.entries %}
|
||||
<dialog id="{{ item.dialog_id }}" class="app-dialog entry-dialog">
|
||||
<form method="dialog" class="dialog-close-row">
|
||||
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('planning.update_entry', label=month.label) }}" class="stack-form">
|
||||
<input type="hidden" name="value_id" value="{{ item.value.id }}">
|
||||
<input type="hidden" name="return_dialog" value="{{ category_data.dialog_id }}">
|
||||
<div class="dialog-section-head">
|
||||
<h3>{{ item.entry.name }}</h3>
|
||||
</div>
|
||||
<input name="entry_name" value="{{ item.entry.name }}" required>
|
||||
<select name="category_id">
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}" {% if item.entry.category_id == category.id %}selected{% endif %}>{{ category.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if item.entry.category.account.slug == "gemeinschaftskonto" %}
|
||||
<div class="sheet-card-grid" data-annual-sync-wrapper>
|
||||
<label>
|
||||
Monatlich
|
||||
<input name="planned_amount" type="number" step="0.01" inputmode="decimal" value="{{ item.value.planned_amount }}" data-annual-sync="monthly">
|
||||
</label>
|
||||
<label>
|
||||
Jährlich
|
||||
<input name="annual_amount" type="number" step="0.01" inputmode="decimal" value="{{ '%.2f'|format(item.value.planned_amount * 12) }}" data-annual-sync="yearly">
|
||||
</label>
|
||||
</div>
|
||||
{% else %}
|
||||
<input name="planned_amount" type="number" step="0.01" inputmode="decimal" value="{{ item.value.planned_amount }}">
|
||||
{% endif %}
|
||||
<select name="benefit_scope">
|
||||
{% for option in benefit_options %}
|
||||
<option value="{{ option.value }}" {% if item.entry.benefit_scope == option.value %}selected{% endif %}>Betrifft {{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label class="check-label">
|
||||
<input type="checkbox" name="is_allocation_target" {% if item.entry.is_allocation_target %}checked{% endif %}>
|
||||
Sparkonto
|
||||
<small>Zeigt Zielbereich und direkte Budgetpflege über die Karte in Planung.</small>
|
||||
</label>
|
||||
<textarea name="note" rows="4" placeholder="Notiz">{{ item.value.note or '' }}</textarea>
|
||||
{% if item.is_distribution_entry and item.distribution_allocation %}
|
||||
<div class="distribution-note-card">
|
||||
<div>
|
||||
<strong>Verteilung für {{ item.entry.name }}</strong>
|
||||
<small>
|
||||
Dieser Eintrag steuert den Zielbetrag in der Verteilung.
|
||||
{% if item.distribution_hint %}
|
||||
Zielbereich {{ item.distribution_hint.range_label }} vom Einkommen.
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
{% if item.distribution_suggestion %}
|
||||
<span class="badge">Noch offen {{ item.distribution_hint.remaining_amount if item.distribution_hint else item.distribution_suggestion.suggested_amount|currency }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<label class="check-label"><input type="checkbox" name="allocation_is_locked" {% if item.distribution_allocation.is_locked %}checked{% endif %}> Verteilung fixieren</label>
|
||||
{% endif %}
|
||||
<details class="split-picker" {% if item.entry.share_rules %}open{% endif %}>
|
||||
<summary class="ghost-button">Mit anderen Personen teilen</summary>
|
||||
<div class="participant-chip-grid split-panel">
|
||||
{% for participant in participants %}
|
||||
<label class="participant-chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="participant_ids"
|
||||
value="{{ participant.id }}"
|
||||
{% if item.entry.share_rules|selectattr('participant_id', 'equalto', participant.id)|list %}checked{% endif %}>
|
||||
{{ participant.display_name }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
<div class="dialog-action-row dialog-action-spread">
|
||||
<button class="primary-button" type="submit">Speichern</button>
|
||||
<button class="ghost-button danger-button" type="button" data-open-dialog="confirm-delete-entry-{{ item.value.id }}">Eintrag löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="confirm-delete-entry-{{ item.value.id }}" class="app-dialog confirm-dialog">
|
||||
<form method="dialog" class="dialog-close-row">
|
||||
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
|
||||
</form>
|
||||
<div class="stack-form">
|
||||
<h3>Eintrag wirklich löschen?</h3>
|
||||
<p class="muted">`{{ item.entry.name }}` wird ausgeblendet und erscheint nicht mehr in der Planung.</p>
|
||||
<form method="post" action="{{ url_for('planning.delete_entry', label=month.label, entry_id=item.entry.id) }}" class="dialog-action-row dialog-action-spread">
|
||||
<input type="hidden" name="return_dialog" value="{{ category_data.dialog_id }}">
|
||||
<button class="ghost-button" type="button" data-open-dialog="{{ item.dialog_id }}">Zurück</button>
|
||||
<button class="primary-button danger-fill-button" type="submit">Jetzt löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
{% for participant in participants if not participant.is_app_user %}
|
||||
<dialog id="participant-dialog-{{ participant.id }}" class="app-dialog">
|
||||
<form method="dialog" class="dialog-close-row">
|
||||
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('planning.update_participant', label=month.label, participant_id=participant.id) }}" class="stack-form" enctype="multipart/form-data">
|
||||
<input type="hidden" name="return_dialog" value="split-people-dialog">
|
||||
<h3>Person bearbeiten</h3>
|
||||
<input name="name" value="{{ participant.display_name }}" required>
|
||||
<label>
|
||||
<span>Avatar hochladen</span>
|
||||
<input name="avatar_file" type="file" accept="image/*">
|
||||
</label>
|
||||
<input name="avatar_url" value="{{ participant.avatar_url or '' }}" placeholder="Avatar-URL optional">
|
||||
<label class="check-label"><input type="checkbox" name="is_external" {% if participant.is_external %}checked{% endif %}> Extern ohne App-Zugang</label>
|
||||
<label class="check-label"><input type="checkbox" name="is_active" {% if participant.is_active %}checked{% endif %}> Aktiv</label>
|
||||
<button class="primary-button" type="submit">Speichern</button>
|
||||
</form>
|
||||
</dialog>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from flask import abort
|
||||
from flask_login import current_user
|
||||
|
||||
|
||||
def admin_required(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not current_user.is_authenticated or not current_user.is_admin():
|
||||
abort(403)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
def currency(value: Decimal | float | int) -> str:
|
||||
amount = Decimal(str(value or 0)).quantize(Decimal("0.01"))
|
||||
formatted = f"{amount:,.2f}".replace(",", "_").replace(".", ",").replace("_", ".")
|
||||
return f"{formatted} €"
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from flask import current_app, url_for
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
|
||||
ALLOWED_AVATAR_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
|
||||
|
||||
def save_avatar_upload(file_storage: FileStorage | None) -> str | None:
|
||||
if file_storage is None or not file_storage.filename:
|
||||
return None
|
||||
|
||||
original_name = secure_filename(file_storage.filename)
|
||||
suffix = Path(original_name).suffix.lower()
|
||||
if suffix not in ALLOWED_AVATAR_EXTENSIONS:
|
||||
raise ValueError("Bitte ein Bild als PNG, JPG, WEBP oder GIF hochladen.")
|
||||
|
||||
upload_dir = Path(current_app.config["AVATAR_UPLOAD_DIR"])
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = f"{uuid4().hex}{suffix}"
|
||||
file_storage.save(upload_dir / filename)
|
||||
return url_for("main.uploaded_avatar", filename=filename)
|
||||
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import combinations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import CostParticipant, User
|
||||
|
||||
|
||||
GERMAN_MONTH_NAMES = {
|
||||
1: "Januar",
|
||||
2: "Februar",
|
||||
3: "Maerz",
|
||||
4: "April",
|
||||
5: "Mai",
|
||||
6: "Juni",
|
||||
7: "Juli",
|
||||
8: "August",
|
||||
9: "September",
|
||||
10: "Oktober",
|
||||
11: "November",
|
||||
12: "Dezember",
|
||||
}
|
||||
|
||||
|
||||
def active_users() -> list[User]:
|
||||
users = db.session.scalars(
|
||||
select(User).where(User.is_active.is_(True)).order_by(User.role.asc(), User.display_name.asc())
|
||||
).all()
|
||||
return list(users)
|
||||
|
||||
|
||||
def personal_users() -> list[User]:
|
||||
users = active_users()
|
||||
non_admin = [user for user in users if not user.is_admin()]
|
||||
return (non_admin or users)[:2]
|
||||
|
||||
|
||||
def personal_account_names() -> dict[str, str]:
|
||||
users = personal_users()
|
||||
names = {
|
||||
"persoenlich-flo": users[0].ui_name if len(users) > 0 else "Persoenlich 1",
|
||||
"persoenlich-desi": users[1].ui_name if len(users) > 1 else "Persoenlich 2",
|
||||
}
|
||||
return names
|
||||
|
||||
|
||||
def format_planning_month(label: str) -> str:
|
||||
year, month = [int(part) for part in label.split("-", 1)]
|
||||
return f"{GERMAN_MONTH_NAMES.get(month, label)} {year}"
|
||||
|
||||
|
||||
def sync_user_participants() -> bool:
|
||||
changed = False
|
||||
users = db.session.scalars(select(User).order_by(User.id.asc())).all()
|
||||
existing_by_user_id = {
|
||||
participant.linked_user_id: participant
|
||||
for participant in db.session.scalars(
|
||||
select(CostParticipant).where(CostParticipant.is_app_user.is_(True))
|
||||
).all()
|
||||
if participant.linked_user_id is not None
|
||||
}
|
||||
|
||||
for user in users:
|
||||
participant = existing_by_user_id.get(user.id)
|
||||
if participant is None:
|
||||
participant = CostParticipant(
|
||||
name=user.ui_name,
|
||||
avatar_url=user.avatar_url,
|
||||
is_app_user=True,
|
||||
linked_user_id=user.id,
|
||||
is_external=False,
|
||||
is_active=user.is_active,
|
||||
)
|
||||
db.session.add(participant)
|
||||
changed = True
|
||||
continue
|
||||
if participant.name != user.ui_name:
|
||||
participant.name = user.ui_name
|
||||
changed = True
|
||||
if participant.avatar_url != user.avatar_url:
|
||||
participant.avatar_url = user.avatar_url
|
||||
changed = True
|
||||
if participant.is_external:
|
||||
participant.is_external = False
|
||||
changed = True
|
||||
if participant.is_app_user is not True:
|
||||
participant.is_app_user = True
|
||||
changed = True
|
||||
if participant.is_active != user.is_active:
|
||||
participant.is_active = user.is_active
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def encode_benefit_scope(selected_user_ids: list[int] | set[int], available_users: list[User]) -> str:
|
||||
available_ids = [user.id for user in available_users]
|
||||
selected_ids = [user_id for user_id in available_ids if user_id in set(selected_user_ids)]
|
||||
if not selected_ids or selected_ids == available_ids:
|
||||
return "all-users"
|
||||
return "users:" + ",".join(str(user_id) for user_id in selected_ids)
|
||||
|
||||
|
||||
def decode_benefit_scope(scope: str | None, available_users: list[User]) -> list[int]:
|
||||
available_ids = [user.id for user in available_users]
|
||||
if not available_ids:
|
||||
return []
|
||||
if scope in {None, "", "both", "all-users"}:
|
||||
return available_ids
|
||||
if scope in {"flo", "desi"}:
|
||||
mapping = personal_account_names()
|
||||
label = mapping["persoenlich-flo"] if scope == "flo" else mapping["persoenlich-desi"]
|
||||
return [user.id for user in available_users if user.ui_name == label][:1] or available_ids
|
||||
if scope.startswith("users:"):
|
||||
parsed_ids = []
|
||||
for raw_id in scope.removeprefix("users:").split(","):
|
||||
raw_id = raw_id.strip()
|
||||
if raw_id.isdigit():
|
||||
parsed_ids.append(int(raw_id))
|
||||
normalized = [user_id for user_id in available_ids if user_id in parsed_ids]
|
||||
return normalized or available_ids
|
||||
return available_ids
|
||||
|
||||
|
||||
def benefit_scope_label(scope: str | None, available_users: list[User]) -> str:
|
||||
selected_ids = decode_benefit_scope(scope, available_users)
|
||||
users_by_id = {user.id: user for user in available_users}
|
||||
names = [users_by_id[user_id].ui_name for user_id in selected_ids if user_id in users_by_id]
|
||||
if not names:
|
||||
return "Alle Nutzer"
|
||||
if len(names) == len(available_users):
|
||||
return "Alle Nutzer"
|
||||
if len(names) == 1:
|
||||
return names[0]
|
||||
if len(names) == 2:
|
||||
return " & ".join(names)
|
||||
return ", ".join(names[:-1]) + f" & {names[-1]}"
|
||||
|
||||
|
||||
def benefit_scope_options(available_users: list[User]) -> list[dict[str, str]]:
|
||||
if not available_users:
|
||||
return [{"value": "all-users", "label": "Alle Nutzer"}]
|
||||
|
||||
options: list[dict[str, str]] = []
|
||||
available_ids = [user.id for user in available_users]
|
||||
for size in range(len(available_users), 0, -1):
|
||||
for user_combo in combinations(available_users, size):
|
||||
user_ids = [user.id for user in user_combo]
|
||||
value = encode_benefit_scope(user_ids, available_users)
|
||||
if size == len(available_users):
|
||||
label = "Alle Nutzer"
|
||||
elif size == 1:
|
||||
label = user_combo[0].ui_name
|
||||
elif size == 2:
|
||||
label = " & ".join(user.ui_name for user in user_combo)
|
||||
else:
|
||||
label = ", ".join(user.ui_name for user in user_combo[:-1]) + f" & {user_combo[-1].ui_name}"
|
||||
options.append({"value": value, "label": label})
|
||||
|
||||
seen_values = set()
|
||||
deduplicated = []
|
||||
for option in options:
|
||||
if option["value"] in seen_values:
|
||||
continue
|
||||
seen_values.add(option["value"])
|
||||
deduplicated.append(option)
|
||||
|
||||
if "all-users" not in seen_values:
|
||||
deduplicated.insert(0, {"value": encode_benefit_scope(available_ids, available_users), "label": "Alle Nutzer"})
|
||||
return deduplicated
|
||||
@@ -0,0 +1,6 @@
|
||||
bind = "0.0.0.0:8000"
|
||||
workers = 2
|
||||
threads = 4
|
||||
accesslog = "-"
|
||||
errorlog = "-"
|
||||
timeout = 60
|
||||
@@ -0,0 +1 @@
|
||||
Single-database configuration for Flask.
|
||||
@@ -0,0 +1,50 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic,flask_migrate
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[logger_flask_migrate]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = flask_migrate
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,113 @@
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger('alembic.env')
|
||||
|
||||
|
||||
def get_engine():
|
||||
try:
|
||||
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||
return current_app.extensions['migrate'].db.get_engine()
|
||||
except (TypeError, AttributeError):
|
||||
# this works with Flask-SQLAlchemy>=3
|
||||
return current_app.extensions['migrate'].db.engine
|
||||
|
||||
|
||||
def get_engine_url():
|
||||
try:
|
||||
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||
'%', '%%')
|
||||
except AttributeError:
|
||||
return str(get_engine().url).replace('%', '%%')
|
||||
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||
target_db = current_app.extensions['migrate'].db
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def get_metadata():
|
||||
if hasattr(target_db, 'metadatas'):
|
||||
return target_db.metadatas[None]
|
||||
return target_db.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
# this callback is used to prevent an auto-migration from being generated
|
||||
# when there are no changes to the schema
|
||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info('No changes in schema detected.')
|
||||
|
||||
conf_args = current_app.extensions['migrate'].configure_args
|
||||
if conf_args.get("process_revision_directives") is None:
|
||||
conf_args["process_revision_directives"] = process_revision_directives
|
||||
|
||||
connectable = get_engine()
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=get_metadata(),
|
||||
**conf_args
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,39 @@
|
||||
"""add entry benefit scope
|
||||
|
||||
Revision ID: 46efbd09438b
|
||||
Revises: 71ff8f291d18
|
||||
Create Date: 2026-04-20 22:26:46.698956
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '46efbd09438b'
|
||||
down_revision = '71ff8f291d18'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('entry', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
'benefit_scope',
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
server_default='both',
|
||||
)
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('entry', schema=None) as batch_op:
|
||||
batch_op.drop_column('benefit_scope')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,35 @@
|
||||
"""add distribution ranges to month
|
||||
|
||||
Revision ID: 5f1c2e87a921
|
||||
Revises: c4a1d9b9e2f1
|
||||
Create Date: 2026-04-20 15:45:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "5f1c2e87a921"
|
||||
down_revision = "c4a1d9b9e2f1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("month", sa.Column("savings_min_pct", sa.Numeric(5, 2), nullable=False, server_default="15.00"))
|
||||
op.add_column("month", sa.Column("savings_max_pct", sa.Numeric(5, 2), nullable=False, server_default="20.00"))
|
||||
op.add_column("month", sa.Column("vacation_min_pct", sa.Numeric(5, 2), nullable=False, server_default="5.00"))
|
||||
op.add_column("month", sa.Column("vacation_max_pct", sa.Numeric(5, 2), nullable=False, server_default="8.00"))
|
||||
op.add_column("month", sa.Column("leisure_min_pct", sa.Numeric(5, 2), nullable=False, server_default="5.00"))
|
||||
op.add_column("month", sa.Column("leisure_max_pct", sa.Numeric(5, 2), nullable=False, server_default="10.00"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("month", "leisure_max_pct")
|
||||
op.drop_column("month", "leisure_min_pct")
|
||||
op.drop_column("month", "vacation_max_pct")
|
||||
op.drop_column("month", "vacation_min_pct")
|
||||
op.drop_column("month", "savings_max_pct")
|
||||
op.drop_column("month", "savings_min_pct")
|
||||
@@ -0,0 +1,239 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 71ff8f291d18
|
||||
Revises:
|
||||
Create Date: 2026-04-20 16:47:07.607628
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '71ff8f291d18'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('account',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=120), nullable=False),
|
||||
sa.Column('slug', sa.String(length=120), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name'),
|
||||
sa.UniqueConstraint('slug')
|
||||
)
|
||||
op.create_table('month',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('label', sa.String(length=7), nullable=False),
|
||||
sa.Column('year', sa.Integer(), nullable=False),
|
||||
sa.Column('month', sa.Integer(), nullable=False),
|
||||
sa.Column('auto_created', sa.Boolean(), nullable=False),
|
||||
sa.Column('is_locked', sa.Boolean(), nullable=False),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('label')
|
||||
)
|
||||
op.create_table('user',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('username', sa.String(length=80), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('role', sa.String(length=20), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('email'),
|
||||
sa.UniqueConstraint('username')
|
||||
)
|
||||
op.create_table('allocation_suggestion',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('month_id', sa.Integer(), nullable=False),
|
||||
sa.Column('target_account_id', sa.Integer(), nullable=False),
|
||||
sa.Column('suggested_amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.Column('reason', sa.Text(), nullable=True),
|
||||
sa.Column('strategy_key', sa.String(length=80), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['month_id'], ['month.id'], ),
|
||||
sa.ForeignKeyConstraint(['target_account_id'], ['account.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('audit_log',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('action', sa.String(length=120), nullable=False),
|
||||
sa.Column('entity_type', sa.String(length=80), nullable=False),
|
||||
sa.Column('entity_id', sa.Integer(), nullable=True),
|
||||
sa.Column('payload_json', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('category',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('account_id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=120), nullable=False),
|
||||
sa.Column('slug', sa.String(length=120), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['account.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('account_id', 'slug')
|
||||
)
|
||||
op.create_table('cost_participant',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=120), nullable=False),
|
||||
sa.Column('is_app_user', sa.Boolean(), nullable=False),
|
||||
sa.Column('linked_user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('is_external', sa.Boolean(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['linked_user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_table('in_app_notification',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('type', sa.String(length=50), nullable=False),
|
||||
sa.Column('title', sa.String(length=150), nullable=False),
|
||||
sa.Column('body', sa.Text(), nullable=False),
|
||||
sa.Column('action_url', sa.String(length=255), nullable=True),
|
||||
sa.Column('is_read', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('monthly_allocation',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('month_id', sa.Integer(), nullable=False),
|
||||
sa.Column('target_account_id', sa.Integer(), nullable=False),
|
||||
sa.Column('label', sa.String(length=120), nullable=False),
|
||||
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.Column('source', sa.String(length=30), nullable=False),
|
||||
sa.Column('is_locked', sa.Boolean(), nullable=False),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['month_id'], ['month.id'], ),
|
||||
sa.ForeignKeyConstraint(['target_account_id'], ['account.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('month_id', 'target_account_id')
|
||||
)
|
||||
op.create_table('monthly_income',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('month_id', sa.Integer(), nullable=False),
|
||||
sa.Column('label', sa.String(length=120), nullable=False),
|
||||
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['month_id'], ['month.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('notification_preference',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('notify_month_end', sa.Boolean(), nullable=False),
|
||||
sa.Column('notify_missing_distribution', sa.Boolean(), nullable=False),
|
||||
sa.Column('notify_missing_values', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id')
|
||||
)
|
||||
op.create_table('push_subscription',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('endpoint', sa.Text(), nullable=False),
|
||||
sa.Column('p256dh_key', sa.Text(), nullable=False),
|
||||
sa.Column('auth_key', sa.Text(), nullable=False),
|
||||
sa.Column('user_agent', sa.String(length=255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('entry',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('category_id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=120), nullable=False),
|
||||
sa.Column('slug', sa.String(length=120), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('default_amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.Column('amount_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['category.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('category_id', 'slug')
|
||||
)
|
||||
op.create_table('entry_share_rule',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('entry_id', sa.Integer(), nullable=False),
|
||||
sa.Column('participant_id', sa.Integer(), nullable=False),
|
||||
sa.Column('share_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('share_value', sa.Numeric(precision=12, scale=4), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entry_id'], ['entry.id'], ),
|
||||
sa.ForeignKeyConstraint(['participant_id'], ['cost_participant.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('entry_id', 'participant_id')
|
||||
)
|
||||
op.create_table('monthly_entry_value',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('month_id', sa.Integer(), nullable=False),
|
||||
sa.Column('entry_id', sa.Integer(), nullable=False),
|
||||
sa.Column('planned_amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.Column('note', sa.Text(), nullable=True),
|
||||
sa.Column('created_by', sa.Integer(), nullable=True),
|
||||
sa.Column('updated_by', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
|
||||
sa.ForeignKeyConstraint(['entry_id'], ['entry.id'], ),
|
||||
sa.ForeignKeyConstraint(['month_id'], ['month.id'], ),
|
||||
sa.ForeignKeyConstraint(['updated_by'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('month_id', 'entry_id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('monthly_entry_value')
|
||||
op.drop_table('entry_share_rule')
|
||||
op.drop_table('entry')
|
||||
op.drop_table('push_subscription')
|
||||
op.drop_table('notification_preference')
|
||||
op.drop_table('monthly_income')
|
||||
op.drop_table('monthly_allocation')
|
||||
op.drop_table('in_app_notification')
|
||||
op.drop_table('cost_participant')
|
||||
op.drop_table('category')
|
||||
op.drop_table('audit_log')
|
||||
op.drop_table('allocation_suggestion')
|
||||
op.drop_table('user')
|
||||
op.drop_table('month')
|
||||
op.drop_table('account')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add entry allocation target
|
||||
|
||||
Revision ID: 8d3f0c61bb21
|
||||
Revises: 5f1c2e87a921
|
||||
Create Date: 2026-04-20 16:10:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "8d3f0c61bb21"
|
||||
down_revision = "5f1c2e87a921"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"entry",
|
||||
sa.Column("is_allocation_target", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("entry", "is_allocation_target")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""add community accounts
|
||||
|
||||
Revision ID: a91b7c4d2f10
|
||||
Revises: 8d3f0c61bb21
|
||||
Create Date: 2026-04-21 11:30:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a91b7c4d2f10"
|
||||
down_revision = "8d3f0c61bb21"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"community_account",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=120), nullable=False),
|
||||
sa.Column("slug", sa.String(length=120), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("account_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("linked_account_slug", sa.String(length=120), nullable=True),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
sa.UniqueConstraint("slug"),
|
||||
)
|
||||
|
||||
with op.batch_alter_table("category", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("community_account_id", sa.Integer(), nullable=True))
|
||||
batch_op.create_foreign_key(
|
||||
"fk_category_community_account_id",
|
||||
"community_account",
|
||||
["community_account_id"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("category", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("fk_category_community_account_id", type_="foreignkey")
|
||||
batch_op.drop_column("community_account_id")
|
||||
|
||||
op.drop_table("community_account")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""expand entry benefit scope
|
||||
|
||||
Revision ID: ab4c2d1e9a10
|
||||
Revises: d9f3c6a1b7f0
|
||||
Create Date: 2026-04-21 16:05:00.000000
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "ab4c2d1e9a10"
|
||||
down_revision = "d9f3c6a1b7f0"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("entry", schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"benefit_scope",
|
||||
existing_type=sa.String(length=20),
|
||||
type_=sa.String(length=120),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("entry", schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"benefit_scope",
|
||||
existing_type=sa.String(length=120),
|
||||
type_=sa.String(length=20),
|
||||
existing_nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""add avatar fields
|
||||
|
||||
Revision ID: c1f8d92b4e31
|
||||
Revises: a91b7c4d2f10
|
||||
Create Date: 2026-04-21 12:20:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c1f8d92b4e31"
|
||||
down_revision = "a91b7c4d2f10"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("user", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("avatar_url", sa.String(length=255), nullable=True))
|
||||
|
||||
with op.batch_alter_table("cost_participant", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("avatar_url", sa.String(length=255), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("cost_participant", schema=None) as batch_op:
|
||||
batch_op.drop_column("avatar_url")
|
||||
|
||||
with op.batch_alter_table("user", schema=None) as batch_op:
|
||||
batch_op.drop_column("avatar_url")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add personal split to month
|
||||
|
||||
Revision ID: c4a1d9b9e2f1
|
||||
Revises: 46efbd09438b
|
||||
Create Date: 2026-04-20 15:10:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c4a1d9b9e2f1"
|
||||
down_revision = "46efbd09438b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"month",
|
||||
sa.Column("personal_split_desi_pct", sa.Numeric(5, 2), nullable=False, server_default="50.00"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("month", "personal_split_desi_pct")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""add user display name
|
||||
|
||||
Revision ID: d9f3c6a1b7f0
|
||||
Revises: c1f8d92b4e31
|
||||
Create Date: 2026-04-21 18:20:00.000000
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "d9f3c6a1b7f0"
|
||||
down_revision = "c1f8d92b4e31"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("user", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("display_name", sa.String(length=120), nullable=True))
|
||||
|
||||
op.execute('UPDATE user SET display_name = username WHERE display_name IS NULL')
|
||||
|
||||
with op.batch_alter_table("user", schema=None) as batch_op:
|
||||
batch_op.alter_column("display_name", existing_type=sa.String(length=120), nullable=False)
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("user", schema=None) as batch_op:
|
||||
batch_op.drop_column("display_name")
|
||||
@@ -0,0 +1,8 @@
|
||||
Flask==3.1.0
|
||||
Flask-Login==0.6.3
|
||||
Flask-Migrate==4.1.0
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
gunicorn==23.0.0
|
||||
pytest==8.3.5
|
||||
python-dotenv==1.0.1
|
||||
pywebpush==2.0.3
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
cd /app/code
|
||||
|
||||
export FLASK_APP="${FLASK_APP:-wsgi:app}"
|
||||
export SALDO_DATA_DIR="${SALDO_DATA_DIR:-/app/data}"
|
||||
|
||||
mkdir -p "${SALDO_DATA_DIR}"
|
||||
|
||||
python -m flask db upgrade
|
||||
python -m flask seed
|
||||
|
||||
exec gunicorn -c gunicorn.conf.py wsgi:app
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import create_app
|
||||
from app.extensions import db
|
||||
from app.models import NotificationPreference, User
|
||||
from app.seed import seed_data, seed_demo_data
|
||||
|
||||
|
||||
class TestConfig:
|
||||
TESTING = True
|
||||
SECRET_KEY = "test-secret"
|
||||
DATA_DIR = Path(tempfile.mkdtemp())
|
||||
SQLALCHEMY_DATABASE_URI = "sqlite://"
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
VAPID_PUBLIC_KEY = ""
|
||||
VAPID_PRIVATE_KEY = ""
|
||||
VAPID_CLAIMS = {"sub": "mailto:test@example.com"}
|
||||
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"},
|
||||
}
|
||||
DEFAULT_PERSONAL_SPLIT_DESI_PCT = 50.0
|
||||
STRONG_INCOME_CHANGE_THRESHOLD = 150.0
|
||||
APP_NAME = "Saldo Test"
|
||||
CSRF_ENABLED = False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = create_app(TestConfig)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
seed_data()
|
||||
seed_demo_data()
|
||||
yield app
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_app():
|
||||
app = create_app(TestConfig)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
seed_data()
|
||||
yield app
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_client(empty_app):
|
||||
return empty_app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user(app):
|
||||
return User.query.filter_by(username="admin").first()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def editor_user(app):
|
||||
return User.query.filter_by(username="mitglied1").first()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logged_in_client(client, admin_user):
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
data={"username": admin_user.username, "password": "testpass"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return client
|
||||
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Account, AllocationSuggestion, Month
|
||||
from app.utils.users import personal_account_names
|
||||
|
||||
|
||||
def test_comparison_to_previous_month(app):
|
||||
service = app.extensions["saldo.month_service"]
|
||||
april = Month.query.filter_by(label="2026-04").first()
|
||||
may = service.copy_month(april, 2026, 5, auto_created=True)
|
||||
may.incomes[0].amount = Decimal("3300.00")
|
||||
may.entry_values[0].planned_amount = Decimal("950.00")
|
||||
db.session.commit()
|
||||
summary = service.compute_summary(may)
|
||||
assert summary.deltas["income_delta"] == Decimal("200.00")
|
||||
assert summary.deltas["cost_delta"] == Decimal("30.00")
|
||||
|
||||
|
||||
def test_suggestion_logic_reacts_to_income_change(app):
|
||||
service = app.extensions["saldo.month_service"]
|
||||
month = Month.query.filter_by(label="2026-04").first()
|
||||
month.incomes[0].amount = Decimal("3500.00")
|
||||
service.refresh_suggestions(month, "Test")
|
||||
db.session.commit()
|
||||
suggestions = AllocationSuggestion.query.filter_by(month_id=month.id).all()
|
||||
assert len(suggestions) == 5
|
||||
sparen = next(item for item in suggestions if item.target_account.slug == "sparen")
|
||||
urlaub = next(item for item in suggestions if item.target_account.slug == "urlaub")
|
||||
freizeit = next(item for item in suggestions if item.target_account.slug == "freizeit")
|
||||
assert sparen.suggested_amount > Decimal("0.00")
|
||||
assert urlaub.suggested_amount > Decimal("0.00")
|
||||
assert freizeit.suggested_amount > Decimal("0.00")
|
||||
|
||||
|
||||
def test_accepting_suggestions_updates_allocations(app):
|
||||
service = app.extensions["saldo.month_service"]
|
||||
allocation_service = app.extensions["saldo.allocation_service"]
|
||||
month = Month.query.filter_by(label="2026-04").first()
|
||||
service.refresh_suggestions(month, "Test")
|
||||
allocation_service.accept_all(month)
|
||||
service.sync_distribution_entries_from_allocations(month)
|
||||
db.session.commit()
|
||||
assert all(item.source == "accepted_suggestion" for item in month.allocations if not item.is_locked)
|
||||
flo_allocation = next(item for item in month.allocations if item.target_account.slug == "persoenlich-flo")
|
||||
flo_entry_value = next(
|
||||
item
|
||||
for item in month.entry_values
|
||||
if item.entry.name == "Person 1" and item.entry.category.account.slug == "persoenlich-flo"
|
||||
)
|
||||
assert flo_entry_value.planned_amount == flo_allocation.amount
|
||||
|
||||
|
||||
def test_personal_distribution_entry_updates_matching_allocation(app):
|
||||
service = app.extensions["saldo.month_service"]
|
||||
month = Month.query.filter_by(label="2026-04").first()
|
||||
flo_value = next(
|
||||
item
|
||||
for item in month.entry_values
|
||||
if item.entry.name == "Person 1" and item.entry.category.account.slug == "persoenlich-flo"
|
||||
)
|
||||
flo_value.planned_amount = Decimal("333.33")
|
||||
|
||||
changed = service.sync_distribution_allocation_from_entry(
|
||||
month,
|
||||
flo_value.entry,
|
||||
mark_manual=True,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flo_allocation = next(item for item in month.allocations if item.target_account.slug == "persoenlich-flo")
|
||||
personal_labels = personal_account_names()
|
||||
assert changed is True
|
||||
assert flo_allocation.amount == Decimal("333.33")
|
||||
assert flo_allocation.label == personal_labels["persoenlich-flo"]
|
||||
assert flo_allocation.source == "manual"
|
||||
|
||||
|
||||
def test_personal_split_changes_personal_suggestions(app):
|
||||
service = app.extensions["saldo.month_service"]
|
||||
month = Month.query.filter_by(label="2026-04").first()
|
||||
month.personal_split_desi_pct = Decimal("40.00")
|
||||
|
||||
service.refresh_suggestions(month, "Split angepasst")
|
||||
db.session.commit()
|
||||
|
||||
flo_suggestion = next(item for item in month.suggestions if item.target_account.slug == "persoenlich-flo")
|
||||
desi_suggestion = next(item for item in month.suggestions if item.target_account.slug == "persoenlich-desi")
|
||||
|
||||
assert flo_suggestion.suggested_amount > desi_suggestion.suggested_amount
|
||||
|
||||
|
||||
def test_manual_priority_accounts_leave_remaining_suggestions_for_personal_only(app):
|
||||
service = app.extensions["saldo.month_service"]
|
||||
month = Month.query.filter_by(label="2026-04").first()
|
||||
|
||||
service.refresh_suggestions(month, "Neu berechnen")
|
||||
db.session.commit()
|
||||
|
||||
allocations = {item.target_account.slug: item.amount for item in month.allocations}
|
||||
suggestions = {item.target_account.slug: item.suggested_amount for item in month.suggestions}
|
||||
summary = service.compute_summary(month)
|
||||
|
||||
assert suggestions["sparen"] >= allocations["sparen"]
|
||||
assert suggestions["urlaub"] >= allocations["urlaub"]
|
||||
assert suggestions["freizeit"] >= allocations["freizeit"]
|
||||
assert suggestions["persoenlich-flo"] == allocations["persoenlich-flo"]
|
||||
assert suggestions["persoenlich-desi"] == allocations["persoenlich-desi"]
|
||||
assert summary.remainder == Decimal("0.00")
|
||||
|
||||
|
||||
def test_personal_allocation_is_reduced_when_fixed_costs_rise(app):
|
||||
service = app.extensions["saldo.month_service"]
|
||||
month = Month.query.filter_by(label="2026-04").first()
|
||||
fixed_cost_value = next(item for item in month.entry_values if item.entry.name == "Miete")
|
||||
original_fixed_cost = fixed_cost_value.planned_amount
|
||||
original_total = sum(
|
||||
item.amount
|
||||
for item in month.allocations
|
||||
if item.target_account.slug in {"persoenlich-flo", "persoenlich-desi"}
|
||||
)
|
||||
|
||||
fixed_cost_value.planned_amount = original_fixed_cost + Decimal("50.00")
|
||||
service.refresh_suggestions(month, "Fixkosten gestiegen")
|
||||
db.session.commit()
|
||||
|
||||
personal_total = sum(
|
||||
item.amount
|
||||
for item in month.allocations
|
||||
if item.target_account.slug in {"persoenlich-flo", "persoenlich-desi"}
|
||||
)
|
||||
summary = service.compute_summary(month)
|
||||
|
||||
assert personal_total == original_total - Decimal("50.00")
|
||||
assert summary.remainder == Decimal("0.00")
|
||||
|
||||
|
||||
def test_personal_allocation_can_fall_to_zero_before_remainder_turns_negative(app):
|
||||
service = app.extensions["saldo.month_service"]
|
||||
month = Month.query.filter_by(label="2026-04").first()
|
||||
fixed_cost_value = next(item for item in month.entry_values if item.entry.name == "Miete")
|
||||
|
||||
fixed_cost_value.planned_amount = Decimal("9999.99")
|
||||
service.refresh_suggestions(month, "Defizit")
|
||||
db.session.commit()
|
||||
|
||||
flo_allocation = next(item for item in month.allocations if item.target_account.slug == "persoenlich-flo")
|
||||
desi_allocation = next(item for item in month.allocations if item.target_account.slug == "persoenlich-desi")
|
||||
summary = service.compute_summary(month)
|
||||
|
||||
assert flo_allocation.amount == Decimal("0.00")
|
||||
assert desi_allocation.amount == Decimal("0.00")
|
||||
assert summary.remainder < Decimal("0.00")
|
||||
|
||||
|
||||
def test_seeded_distribution_entries_are_marked_as_allocation_targets(app):
|
||||
month = Month.query.filter_by(label="2026-04").first()
|
||||
target_entries = {
|
||||
item.entry.name: item.entry.is_allocation_target
|
||||
for item in month.entry_values
|
||||
if item.entry.name in {"Sparziel", "Reisebudget", "Freizeitbudget", "Person 1", "Person 2"}
|
||||
}
|
||||
|
||||
assert target_entries["Sparziel"] is True
|
||||
assert target_entries["Reisebudget"] is True
|
||||
assert target_entries["Freizeitbudget"] is True
|
||||
assert target_entries["Person 1"] is True
|
||||
assert target_entries["Person 2"] is True
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Month
|
||||
|
||||
|
||||
def test_auto_creates_missing_month(app):
|
||||
service = app.extensions["saldo.month_service"]
|
||||
month = service.ensure_month(date(2026, 5, 1))
|
||||
assert month.label == "2026-05"
|
||||
assert month.auto_created is True
|
||||
|
||||
|
||||
def test_copy_previous_month_keeps_values(app):
|
||||
service = app.extensions["saldo.month_service"]
|
||||
april = Month.query.filter_by(label="2026-04").first()
|
||||
copied = service.copy_month(april, 2026, 6, auto_created=False)
|
||||
db.session.commit()
|
||||
assert copied.label == "2026-06"
|
||||
assert len(copied.entry_values) == len(april.entry_values)
|
||||
assert copied.incomes[0].amount == april.incomes[0].amount
|
||||
|
||||
|
||||
def test_remainder_calculation(app):
|
||||
service = app.extensions["saldo.month_service"]
|
||||
month = Month.query.filter_by(label="2026-04").first()
|
||||
summary = service.compute_summary(month)
|
||||
assert summary.total_income == Decimal("5550.00")
|
||||
assert summary.remainder == summary.total_income - summary.total_costs
|
||||
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import AllocationSuggestion, Category, CommunityAccount, Month, MonthlyEntryValue
|
||||
|
||||
|
||||
def test_health_route(client):
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json["status"] == "ok"
|
||||
|
||||
|
||||
def test_first_run_redirects_to_setup(empty_client):
|
||||
response = empty_client.get("/", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert "/auth/setup" in response.headers["Location"]
|
||||
|
||||
|
||||
def test_current_month_is_available_after_login(logged_in_client):
|
||||
response = logged_in_client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"2026-04" in response.data or b"2026-05" in response.data
|
||||
assert b"Dauerauftr\xc3\xa4ge pr\xc3\xbcfen" in response.data
|
||||
assert b"Extern mitzuteilen" in response.data
|
||||
|
||||
|
||||
def test_analytics_route_uses_new_cost_focused_sections(logged_in_client):
|
||||
response = logged_in_client.get("/analytics")
|
||||
content = response.get_data(as_text=True)
|
||||
assert response.status_code == 200
|
||||
assert "Kategorien im Monat" in content
|
||||
assert "Kosten nach Zuordnung" in content
|
||||
assert "Budgets im Monatsverlauf" in content
|
||||
assert "Größte Einträge im Monat" in content
|
||||
assert "Sparkonten" in content
|
||||
assert "category-chart-back" in content
|
||||
assert "entry-drilldown-chart" not in content
|
||||
assert '"Pers\\u00f6nliche Auszahlung"' in content
|
||||
assert "Person A" in content
|
||||
assert "Person B" in content
|
||||
|
||||
|
||||
def test_planning_detail_refreshes_stale_suggestions_after_distribution_sync(logged_in_client):
|
||||
month = Month.query.filter_by(label="2026-04").first()
|
||||
sparen_value = next(
|
||||
item for item in month.entry_values if item.entry.name == "Sparziel"
|
||||
)
|
||||
sparen_allocation = next(
|
||||
item for item in month.allocations if item.target_account.slug == "sparen"
|
||||
)
|
||||
sparen_value.planned_amount = Decimal("0.00")
|
||||
sparen_allocation.amount = Decimal("1539.20")
|
||||
for suggestion in month.suggestions:
|
||||
suggestion.suggested_amount = Decimal("0.00")
|
||||
db.session.commit()
|
||||
|
||||
response = logged_in_client.get("/planning/2026-04")
|
||||
|
||||
db.session.refresh(sparen_value)
|
||||
refreshed_suggestions = AllocationSuggestion.query.filter_by(month_id=month.id).all()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert sparen_value.planned_amount == Decimal("1539.20")
|
||||
assert sum((item.suggested_amount for item in refreshed_suggestions), Decimal("0.00")) > Decimal("0.00")
|
||||
|
||||
|
||||
def test_community_entry_can_be_updated_via_annual_amount(app, logged_in_client):
|
||||
month = Month.query.filter_by(label="2026-04").first()
|
||||
value = next(
|
||||
item for item in month.entry_values if item.entry.name == "Miete"
|
||||
)
|
||||
|
||||
response = logged_in_client.post(
|
||||
f"/planning/{month.label}/entry",
|
||||
data={
|
||||
"value_id": value.id,
|
||||
"return_dialog": f"category-dialog-{value.entry.category_id}",
|
||||
"entry_name": value.entry.name,
|
||||
"category_id": value.entry.category_id,
|
||||
"planned_amount": "",
|
||||
"annual_amount": "1800.00",
|
||||
"benefit_scope": value.entry.benefit_scope,
|
||||
"note": value.note or "",
|
||||
},
|
||||
)
|
||||
|
||||
updated_value = db.session.get(MonthlyEntryValue, value.id)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert updated_value.planned_amount == Decimal("150.00")
|
||||
|
||||
|
||||
def test_distribution_dialog_shows_direct_budget_form(logged_in_client):
|
||||
response = logged_in_client.get("/planning/2026-04")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"Budget direkt anpassen" in response.data
|
||||
assert b"Sparkonto" in response.data
|
||||
|
||||
|
||||
def test_planning_shows_budgets_and_community_accounts(logged_in_client):
|
||||
response = logged_in_client.get("/planning/2026-04")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"Budgets" in response.data
|
||||
assert b"Gemeinschaftskonten" in response.data
|
||||
assert b"Privatkonto 1" in response.data
|
||||
assert b"Privatkonto 2" in response.data
|
||||
|
||||
|
||||
def test_community_account_can_assign_budget_categories(logged_in_client):
|
||||
community_account = CommunityAccount.query.filter_by(slug="hauptkonto").first()
|
||||
category = Category.query.filter_by(slug="wohnen").first()
|
||||
|
||||
response = logged_in_client.post(
|
||||
f"/planning/2026-04/community-accounts/{community_account.id}",
|
||||
data={
|
||||
"name": community_account.name,
|
||||
"description": community_account.description or "",
|
||||
"category_ids": [str(category.id)],
|
||||
},
|
||||
)
|
||||
|
||||
db.session.refresh(category)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert category.community_account_id == community_account.id
|
||||
|
||||
|
||||
def test_community_account_rejects_budget_assigned_to_other_account(logged_in_client):
|
||||
primary_account = CommunityAccount.query.filter_by(slug="hauptkonto").first()
|
||||
secondary_response = logged_in_client.post(
|
||||
"/planning/2026-04/community-accounts",
|
||||
data={"name": "Fixkostenkonto", "description": ""},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert secondary_response.status_code == 200
|
||||
secondary_account = CommunityAccount.query.filter_by(slug="fixkostenkonto").first()
|
||||
category = Category.query.filter_by(slug="wohnen").first()
|
||||
category.community_account_id = primary_account.id
|
||||
db.session.commit()
|
||||
|
||||
response = logged_in_client.post(
|
||||
f"/planning/2026-04/community-accounts/{secondary_account.id}",
|
||||
data={
|
||||
"name": secondary_account.name,
|
||||
"description": secondary_account.description or "",
|
||||
"category_ids": [str(category.id)],
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
db.session.refresh(category)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert category.community_account_id == primary_account.id
|
||||
assert b"bereits anderen Konten zugewiesen" in response.data
|
||||
|
||||
|
||||
def test_community_account_can_be_deleted_and_unassigns_budgets(logged_in_client):
|
||||
community_account = CommunityAccount.query.filter_by(slug="hauptkonto").first()
|
||||
category = Category.query.filter_by(slug="wohnen").first()
|
||||
category.community_account_id = community_account.id
|
||||
db.session.commit()
|
||||
|
||||
response = logged_in_client.post(
|
||||
f"/planning/2026-04/community-accounts/{community_account.id}/delete"
|
||||
)
|
||||
|
||||
db.session.refresh(category)
|
||||
db.session.refresh(community_account)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert community_account.is_active is False
|
||||
assert category.community_account_id is None
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import CostParticipant, Entry, InAppNotification, Month, User
|
||||
|
||||
|
||||
def test_share_logic_with_external_guest(app):
|
||||
month = Month.query.filter_by(label="2026-04").first()
|
||||
service = app.extensions["saldo.share_service"]
|
||||
shared_value = next(item for item in month.entry_values if item.entry.name == "Streaming 1")
|
||||
result = service.calculate_entry_shares(shared_value)
|
||||
guest_share = next(item for item in result["shares"] if item["participant_name"] == "Gast")
|
||||
assert guest_share["amount"] == Decimal("5.99")
|
||||
assert result["external_total"] == guest_share["amount"]
|
||||
|
||||
|
||||
def test_external_guest_shares_do_not_reduce_distribution_pool(app):
|
||||
month = Month.query.filter_by(label="2026-04").first()
|
||||
service = app.extensions["saldo.month_service"]
|
||||
shared_value = next(item for item in month.entry_values if item.entry.name == "Streaming 1")
|
||||
summary = service.compute_summary(month)
|
||||
|
||||
assert shared_value.entry.share_rules
|
||||
assert summary.fixed_costs < summary.total_costs
|
||||
|
||||
|
||||
def test_push_reminder_logic_creates_in_app_notifications(app):
|
||||
notification_service = app.extensions["saldo.notification_service"]
|
||||
count = notification_service.run_monthly_checks(date(2026, 4, 27))
|
||||
assert count >= 1
|
||||
assert InAppNotification.query.count() >= 1
|
||||
|
||||
|
||||
def test_setup_route_can_create_first_admin(empty_client, empty_app):
|
||||
response = empty_client.post(
|
||||
"/auth/setup",
|
||||
data={
|
||||
"username": "setup-admin",
|
||||
"display_name": "Setup Admin",
|
||||
"email": "setup-admin@example.invalid",
|
||||
"password": "supersecret",
|
||||
"password_confirm": "supersecret",
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with empty_app.app_context():
|
||||
user = User.query.filter_by(username="setup-admin").first()
|
||||
assert user is not None
|
||||
assert user.role == "admin"
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "2026-04" in response.get_data(as_text=True) or "2026-05" in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_admin_route_requires_admin(client, app, editor_user):
|
||||
client.post(
|
||||
"/auth/login",
|
||||
data={"username": editor_user.username, "password": "testpass"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
response = client.get("/admin/")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_admin_can_access_admin_route(logged_in_client):
|
||||
response = logged_in_client.get("/admin/")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_admin_can_create_entry_and_backfill_existing_month(logged_in_client, app):
|
||||
from app.models import Category, Entry, Month, MonthlyEntryValue
|
||||
|
||||
category = Category.query.filter_by(slug="haushalt").first()
|
||||
month = Month.query.filter_by(label="2026-04").first()
|
||||
before = MonthlyEntryValue.query.filter_by(month_id=month.id).count()
|
||||
response = logged_in_client.post(
|
||||
"/admin/entries",
|
||||
data={
|
||||
"category_id": category.id,
|
||||
"name": "Tierfutter",
|
||||
"slug": "tierfutter",
|
||||
"default_amount": "45.00",
|
||||
"amount_type": "fixed",
|
||||
"sort_order": "99",
|
||||
"description": "",
|
||||
"is_active": "on",
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
entry = Entry.query.filter_by(slug="tierfutter").first()
|
||||
assert entry is not None
|
||||
assert MonthlyEntryValue.query.filter_by(month_id=month.id).count() == before + 1
|
||||
|
||||
|
||||
def test_participant_avatar_can_be_uploaded(logged_in_client, app):
|
||||
response = logged_in_client.post(
|
||||
"/planning/2026-04/participants",
|
||||
data={
|
||||
"name": "Mika",
|
||||
"is_external": "on",
|
||||
"return_dialog": "split-people-dialog",
|
||||
"avatar_file": (io.BytesIO(b"fake-image-bytes"), "mika.png"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
participant = CostParticipant.query.filter_by(name="Mika").first()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert participant is not None
|
||||
assert participant.avatar_url is not None
|
||||
assert participant.avatar_url.startswith("/media/avatars/")
|
||||