release: publish saldo 0.1.0

This commit is contained in:
2026-04-21 21:17:36 +02:00
commit 6f5e704739
95 changed files with 9196 additions and 0 deletions
+174
View File
@@ -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)
+2
View File
@@ -0,0 +1,2 @@
from .routes import admin_bp
+291
View File
@@ -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"))
+2
View File
@@ -0,0 +1,2 @@
from .routes import auth_bp
+77
View File
@@ -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"))
+75
View File
@@ -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")
)
+9
View File
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
from .routes import main_bp
+339
View File
@@ -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)
+314
View File
@@ -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"))
+2
View File
@@ -0,0 +1,2 @@
from .routes import months_bp
+49
View File
@@ -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"))
+2
View File
@@ -0,0 +1,2 @@
from .routes import planning_bp
File diff suppressed because it is too large Load Diff
+462
View File
@@ -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()
+228
View File
@@ -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)} %."
)
+60
View File
@@ -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)
+429
View File
@@ -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)
+64
View File
@@ -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)
+32
View File
@@ -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
+97
View File
@@ -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,
}
File diff suppressed because it is too large Load Diff
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+432
View File
@@ -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();
});
});
});
+17
View File
@@ -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"
}
]
}
+35
View File
@@ -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 || "/"));
});
+9
View File
@@ -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 %}
+74
View File
@@ -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 %}
+33
View File
@@ -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>
+46
View File
@@ -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>
+91
View File
@@ -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>
+105
View File
@@ -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 %}
+196
View File
@@ -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 %}
+34
View File
@@ -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 %}
+815
View File
@@ -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 %}
+16
View File
@@ -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
+9
View File
@@ -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}"
+28
View File
@@ -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)
+171
View File
@@ -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