Files
saldo/app/planning/routes.py
T

1091 lines
43 KiB
Python

from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from flask import (
Blueprint,
current_app,
flash,
g,
jsonify,
redirect,
render_template,
request,
url_for,
)
from flask_login import current_user, login_required
from sqlalchemy import select
from app.extensions import db
from app.models import (
Account,
Category,
CommunityAccount,
CostParticipant,
Entry,
EntryShareRule,
Month,
MonthlyAllocation,
MonthlyEntryValue,
MonthlyIncome,
PushSubscription,
to_decimal,
)
from app.seed import slugify
from app.utils.uploads import save_avatar_upload
from app.utils.users import (
active_users,
benefit_scope_label,
benefit_scope_options,
decode_benefit_scope,
format_planning_month,
personal_account_names,
sync_user_participants,
)
planning_bp = Blueprint("planning", __name__, url_prefix="/planning")
def _dialog_redirect(label: str, dialog_id: str | None = None):
if dialog_id:
return redirect(url_for("planning.detail", label=label, dialog=dialog_id))
return redirect(url_for("planning.detail", label=label))
def _category_dialog_id(category: Category | None) -> str | None:
if category is None or category.account is None:
return None
if category.account.slug in {"sparen", "urlaub", "freizeit"}:
return f"distribution-dialog-{category.account.slug}"
if category.account.slug in {"persoenlich-flo", "persoenlich-desi"}:
return "personal-split-dialog"
return f"category-dialog-{category.id}"
def _account_from_form(area_key: str = "area") -> Account | None:
account_id = request.form.get("account_id")
if account_id:
return Account.query.get(int(account_id))
area = (request.form.get(area_key) or "").strip()
if area == "budget":
return Account.query.filter_by(slug="gemeinschaftskonto").first()
if area == "distribution":
return Account.query.filter_by(slug="sparen").first()
return None
def _resolve_monthly_amount(form, monthly_key: str = "planned_amount", annual_key: str = "annual_amount") -> Decimal:
monthly_raw = (form.get(monthly_key) or "").strip()
annual_raw = (form.get(annual_key) or "").strip()
if monthly_raw:
return to_decimal(monthly_raw)
if annual_raw:
return (to_decimal(annual_raw) / Decimal("12.00")).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
return Decimal("0.00")
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
def _community_account_totals(month, previous_month, community_accounts, budget_categories):
personal_label_map = personal_account_names()
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 {}
)
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 {}
)
cards = []
for community_account in community_accounts:
if community_account.account_type == "personal" and community_account.linked_account_slug:
current_total = next(
(
to_decimal(item.amount)
for item in month.allocations
if item.target_account and item.target_account.slug == 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
display_name = (
f"Auszahlung {personal_label_map.get(community_account.linked_account_slug, community_account.name)}"
if community_account.account_type == "personal" and community_account.linked_account_slug
else community_account.name
)
cards.append(
{
"community_account": community_account,
"display_name": display_name,
"current_total": current_total,
"previous_total": previous_total,
"delta": delta,
"assigned_budget_names": assigned_budget_names,
"is_read_only": community_account.account_type == "personal",
}
)
return cards
def _distribution_hint_map(allocation_service, month, summary, allocations_by_slug, suggestions_by_slug) -> dict[str, dict]:
result = {}
return {
slug: {
**hint,
"current_pct": (
(to_decimal(allocations_by_slug.get(slug).amount) / summary.total_income * Decimal("100.00")).quantize(Decimal("0.01"))
if summary.total_income > Decimal("0.00") and allocations_by_slug.get(slug) is not None
else Decimal("0.00")
),
"status": (
"good"
if summary.total_income <= Decimal("0.00")
else "bad"
if (
allocations_by_slug.get(slug) is not None
and (to_decimal(allocations_by_slug.get(slug).amount) / summary.total_income * Decimal("100.00")) < hint["min_pct"]
)
else "good"
),
"remaining_amount": max(
Decimal("0.00"),
to_decimal(suggestions_by_slug.get(slug).suggested_amount if suggestions_by_slug.get(slug) else 0)
- to_decimal(allocations_by_slug.get(slug).amount if allocations_by_slug.get(slug) else 0),
),
"target_amount": to_decimal(suggestions_by_slug.get(slug).suggested_amount if suggestions_by_slug.get(slug) else 0),
}
for slug in allocation_service.TARGET_SLUGS
if (hint := allocation_service.strategy_hint(month, slug)) is not None
}
@planning_bp.route("/")
@login_required
def current():
return redirect(url_for("planning.detail", label=g.current_month.label))
@planning_bp.route("/<label>")
@login_required
def detail(label: str):
month = Month.query.filter_by(label=label).first_or_404()
if sync_user_participants():
db.session.commit()
month_service = current_app.extensions["saldo.month_service"]
allocation_service = current_app.extensions["saldo.allocation_service"]
distribution_changed = month_service.sync_distribution_entries_from_allocations(month)
month_service.refresh_suggestions(
month,
reason="Verteilung wurde synchronisiert" if distribution_changed else "Planungsansicht aktualisiert",
)
db.session.commit()
summary = month_service.compute_summary(month)
grouped_accounts = db.session.scalars(
select(Account).where(Account.is_active.is_(True)).order_by(Account.sort_order.asc())
).all()
account_priority = {
"sparen": 0,
"urlaub": 1,
"freizeit": 2,
"gemeinschaftskonto": 3,
"persoenlich-flo": 4,
"persoenlich-desi": 5,
}
grouped_accounts = sorted(
grouped_accounts,
key=lambda item: (account_priority.get(item.slug, 99), item.sort_order, item.name),
)
participants = list(
db.session.scalars(
select(CostParticipant)
.where(CostParticipant.is_active.is_(True))
.order_by(CostParticipant.is_external.asc(), CostParticipant.name.asc())
).all()
)
participants.sort(key=lambda item: (item.is_external, item.display_name.lower()))
available_users = active_users()
benefit_options = benefit_scope_options(available_users)
personal_label_map = personal_account_names()
community_accounts = db.session.scalars(
select(CommunityAccount)
.where(CommunityAccount.is_active.is_(True))
.order_by(CommunityAccount.sort_order.asc(), CommunityAccount.name.asc())
).all()
allocations_by_slug = {
item.target_account.slug: item for item in summary.current_allocations if item.target_account
}
suggestions_by_slug = {
item.target_account.slug: item for item in summary.suggestions if item.target_account
}
planning_accounts = []
distribution_hints = _distribution_hint_map(
allocation_service, month, summary, allocations_by_slug, suggestions_by_slug
)
month_values = {item.entry_id: item for item in month.entry_values}
distribution_bucket = {
"account": type(
"SyntheticAccount",
(),
{"name": "Sparen & Verteilung", "slug": "sparen-und-verteilung"},
)(),
"categories": [],
"total": Decimal("0.00"),
}
for account in grouped_accounts:
category_cards = []
account_total = Decimal("0.00")
for category in sorted(
[item for item in account.categories if item.is_active],
key=lambda item: item.sort_order,
):
entry_rows = []
category_total = Decimal("0.00")
for entry in sorted(
[item for item in category.entries if item.is_active],
key=lambda item: item.sort_order,
):
value = month_values.get(entry.id)
if value is None:
continue
amount = to_decimal(value.planned_amount)
category_total += amount
entry_rows.append(
{
"entry": entry,
"value": value,
"amount": amount,
"dialog_id": f"entry-dialog-{value.id}",
"share_names": ", ".join(
rule.participant.display_name for rule in entry.share_rules
),
"benefit_label": benefit_scope_label(entry.benefit_scope, available_users),
"benefit_user_ids": decode_benefit_scope(entry.benefit_scope, available_users),
"benefit_users": [
user
for user in available_users
if user.id in decode_benefit_scope(entry.benefit_scope, available_users)
],
"distribution_allocation": allocations_by_slug.get(account.slug),
"distribution_suggestion": suggestions_by_slug.get(account.slug),
"distribution_hint": distribution_hints.get(account.slug),
"is_distribution_entry": account.slug
in {"sparen", "urlaub", "freizeit", "persoenlich-flo", "persoenlich-desi"},
"is_allocation_target": entry.is_allocation_target,
}
)
account_total += category_total
suggestion_total = None
if account.slug in {"sparen", "urlaub", "freizeit"}:
suggestion = suggestions_by_slug.get(account.slug)
suggestion_total = (
to_decimal(suggestion.suggested_amount) if suggestion is not None else Decimal("0.00")
)
category_cards.append(
{
"category": category,
"entries": entry_rows,
"total": category_total,
"entry_count": len(entry_rows),
"dialog_id": f"category-dialog-{category.id}",
"distribution_kind": "single" if account.slug in {"sparen", "urlaub", "freizeit"} else None,
"distribution_suggestion_total": suggestion_total,
"distribution_hint": distribution_hints.get(account.slug),
"direct_entry": next(
(entry_row for entry_row in entry_rows if entry_row["entry"].is_allocation_target),
None,
),
"allow_new_entries": not any(entry["is_allocation_target"] for entry in entry_rows),
}
)
if account.slug in {"sparen", "urlaub", "freizeit"}:
distribution_bucket["categories"].append(
{
"category": type(
"SyntheticCategory",
(),
{"id": account.id * -100, "name": account.name, "account_id": account.id},
)(),
"entries": [entry for category_card in category_cards for entry in category_card["entries"]],
"total": account_total,
"entry_count": sum(category_card["entry_count"] for category_card in category_cards),
"dialog_id": f"distribution-dialog-{account.slug}",
"distribution_kind": "single",
"distribution_suggestion_total": distribution_hints.get(account.slug, {}).get("remaining_amount", Decimal("0.00")),
"distribution_hint": distribution_hints.get(account.slug),
"distribution_account_slug": account.slug,
"direct_entry": next(
(
category_card.get("direct_entry")
for category_card in category_cards
if category_card.get("direct_entry") is not None
),
None,
),
"allow_new_entries": False,
}
)
distribution_bucket["total"] += account_total
continue
if account.slug in {"persoenlich-flo", "persoenlich-desi"}:
personal_category = next(
(item for item in distribution_bucket["categories"] if item.get("is_personal_split")),
None,
)
if personal_category is None:
personal_category = {
"category": type(
"SyntheticCategory",
(),
{"id": -1, "name": "Persönliche Auszahlung", "account_id": account.id},
)(),
"entries": [],
"total": Decimal("0.00"),
"entry_count": 0,
"is_personal_split": True,
"dialog_id": "personal-split-dialog",
"distribution_kind": "personal",
"distribution_suggestion_total": Decimal("0.00"),
"distribution_hint": {
"label": "Persönliche Auszahlung",
},
"direct_entry": None,
"allow_new_entries": False,
}
distribution_bucket["categories"].append(personal_category)
for category_card in category_cards:
personal_category["entries"].extend(category_card["entries"])
personal_category["total"] += category_card["total"]
personal_category["entry_count"] += category_card["entry_count"]
distribution_bucket["total"] += account_total
continue
planning_accounts.append(
{"account": account, "categories": category_cards, "total": account_total}
)
personal_category = next(
(item for item in distribution_bucket["categories"] if item.get("is_personal_split")),
None,
)
if personal_category is not None:
personal_category["distribution_suggestion_total"] = max(
-summary.allocation_total,
summary.remainder,
)
personal_category["distribution_items"] = [
{
"slug": slug,
"label": personal_label_map.get(slug, slug),
"allocation": allocations_by_slug.get(slug),
"suggestion": suggestions_by_slug.get(slug),
"remaining_amount": max(
Decimal("0.00"),
to_decimal(suggestions_by_slug.get(slug).suggested_amount if suggestions_by_slug.get(slug) else 0)
- to_decimal(allocations_by_slug.get(slug).amount if allocations_by_slug.get(slug) else 0),
),
"auto_amount": to_decimal(allocations_by_slug.get(slug).amount if allocations_by_slug.get(slug) else 0),
}
for slug in ("persoenlich-flo", "persoenlich-desi")
]
planning_accounts.insert(0, distribution_bucket)
previous_month = month_service.previous_month(month.year, month.month)
budget_categories = db.session.scalars(
select(Category)
.join(Account)
.where(
Category.is_active.is_(True),
Account.slug == "gemeinschaftskonto",
)
.order_by(Category.sort_order.asc(), Category.name.asc())
).all()
community_account_cards = _community_account_totals(
month,
previous_month,
community_accounts,
budget_categories,
)
distribution_labels = {
account.id: (
personal_label_map.get(account.slug, account.name)
)
for account in grouped_accounts
}
internal_participants = [item for item in participants if not item.is_external]
external_participants = [item for item in participants if item.is_external]
return render_template(
"planning/detail.html",
month=month,
planning_heading=format_planning_month(month.label),
summary=summary,
planning_accounts=planning_accounts,
participants=participants,
benefit_options=benefit_options,
community_account_cards=community_account_cards,
community_account_summary={
"shared_count": len([item for item in community_account_cards if not item["is_read_only"]]),
"personal_count": len([item for item in community_account_cards if item["is_read_only"]]),
},
participant_summary={
"internal_count": len(internal_participants),
"external_count": len(external_participants),
},
personal_split={
"desi_pct": to_decimal(month.personal_split_desi_pct),
"flo_pct": Decimal("100.00") - to_decimal(month.personal_split_desi_pct),
},
accounts=grouped_accounts,
distribution_labels=distribution_labels,
distribution_hints=distribution_hints,
community_accounts=community_accounts,
categories=db.session.scalars(
select(Category)
.join(Account)
.where(Category.is_active.is_(True), Account.is_active.is_(True))
.order_by(Account.sort_order.asc(), Category.sort_order.asc(), Category.name.asc())
).all(),
)
@planning_bp.route("/<label>/income", methods=["POST"])
@login_required
def update_income(label: str):
month = Month.query.filter_by(label=label).first_or_404()
income = MonthlyIncome.query.get_or_404(int(request.form["income_id"]))
income.label = request.form.get("income_label", income.label).strip() or income.label
income.amount = to_decimal(request.form.get("amount", "0"))
db.session.commit()
current_app.extensions["saldo.month_service"].refresh_suggestions(
month, reason="Einkommen wurde geändert"
)
db.session.commit()
flash("Einkommen aktualisiert und Vorschläge neu berechnet.", "success")
return _dialog_redirect(label, request.form.get("return_dialog") or None)
@planning_bp.route("/<label>/incomes", methods=["POST"])
@login_required
def create_income(label: str):
month = Month.query.filter_by(label=label).first_or_404()
income_label = request.form.get("income_label", "").strip()
if not income_label:
flash("Bitte einen Namen für die Einkommenszeile angeben.", "danger")
return _dialog_redirect(label, "income-dialog")
last_sort = (
db.session.scalar(
select(MonthlyIncome.sort_order)
.where(MonthlyIncome.month_id == month.id)
.order_by(MonthlyIncome.sort_order.desc())
.limit(1)
)
or 0
)
db.session.add(
MonthlyIncome(
month_id=month.id,
label=income_label,
amount=to_decimal(request.form.get("amount", "0")),
sort_order=last_sort + 1,
)
)
db.session.commit()
current_app.extensions["saldo.month_service"].refresh_suggestions(
month, reason="Einkommenszeile wurde angelegt"
)
db.session.commit()
flash("Einkommenszeile angelegt.", "success")
return _dialog_redirect(label, "income-dialog")
@planning_bp.route("/<label>/incomes/<int:income_id>/delete", methods=["POST"])
@login_required
def delete_income(label: str, income_id: int):
month = Month.query.filter_by(label=label).first_or_404()
income = MonthlyIncome.query.get_or_404(income_id)
db.session.delete(income)
db.session.commit()
current_app.extensions["saldo.month_service"].refresh_suggestions(
month, reason="Einkommenszeile wurde gelöscht"
)
db.session.commit()
flash("Einkommenszeile entfernt.", "info")
return _dialog_redirect(label, "income-dialog")
@planning_bp.route("/<label>/distribution-settings/<slug>", methods=["POST"])
@login_required
def update_distribution_settings(label: str, slug: str):
month = Month.query.filter_by(label=label).first_or_404()
current_app.extensions["saldo.month_service"].update_target_range(
month,
slug,
request.form.get("min_pct", "0"),
request.form.get("max_pct", "0"),
)
db.session.commit()
current_app.extensions["saldo.month_service"].refresh_suggestions(
month, reason="Verteilungsbereich wurde geändert"
)
db.session.commit()
flash("Zielbereich aktualisiert.", "success")
return _dialog_redirect(label, request.form.get("return_dialog") or f"distribution-dialog-{slug}")
@planning_bp.route("/<label>/personal-split", methods=["POST"])
@login_required
def update_personal_split(label: str):
month = Month.query.filter_by(label=label).first_or_404()
current_app.extensions["saldo.month_service"].normalize_personal_split(
month,
request.form.get("flo_pct", "50"),
request.form.get("desi_pct", "50"),
)
db.session.commit()
current_app.extensions["saldo.month_service"].refresh_suggestions(
month, reason="Persönlicher Split wurde geändert"
)
db.session.commit()
flash("Split für persönliche Auszahlung aktualisiert.", "success")
return _dialog_redirect(label, request.form.get("return_dialog") or "personal-split-dialog")
@planning_bp.route("/<label>/entry", methods=["POST"])
@login_required
def update_entry(label: str):
month = Month.query.filter_by(label=label).first_or_404()
value = MonthlyEntryValue.query.get_or_404(int(request.form["value_id"]))
entry = value.entry
is_auto_personal_entry = (
entry.is_allocation_target
and entry.category
and entry.category.account
and entry.category.account.slug in {"persoenlich-flo", "persoenlich-desi"}
)
original_dialog_id = _category_dialog_id(entry.category)
if not is_auto_personal_entry:
value.planned_amount = _resolve_monthly_amount(request.form)
entry_name = request.form.get("entry_name", "").strip()
if entry_name:
entry.name = entry_name
entry.slug = slugify(request.form.get("entry_slug", "") or entry_name)
if request.form.get("category_id"):
entry.category_id = int(request.form["category_id"])
if request.form.get("amount_type"):
entry.amount_type = request.form["amount_type"]
entry.benefit_scope = request.form.get("benefit_scope", entry.benefit_scope or "all-users")
entry.is_allocation_target = request.form.get("is_allocation_target") == "on"
entry.description = request.form.get("description", "").strip() or None
note = request.form.get("note")
if note is not None:
value.note = note.strip() or None
value.updated_by = current_user.id
selected_participants = {
int(item) for item in request.form.getlist("participant_ids") if item.strip()
}
existing_rules = {rule.participant_id: rule for rule in entry.share_rules}
for participant_id, rule in list(existing_rules.items()):
if participant_id not in selected_participants:
db.session.delete(rule)
for participant_id in selected_participants:
rule = existing_rules.get(participant_id)
if rule is None:
db.session.add(
EntryShareRule(
entry_id=entry.id,
participant_id=participant_id,
share_type="equal",
)
)
else:
rule.share_type = "equal"
rule.share_value = None
current_app.extensions["saldo.month_service"].sync_distribution_allocation_from_entry(
month,
entry,
mark_manual=True,
)
distribution_allocation = next(
(
item
for item in month.allocations
if item.target_account and item.target_account.slug == entry.category.account.slug
),
None,
)
if distribution_allocation is not None:
distribution_allocation.is_locked = request.form.get("allocation_is_locked") == "on"
db.session.commit()
current_app.extensions["saldo.month_service"].refresh_suggestions(
month, reason="Kostenwert wurde geändert"
)
db.session.commit()
flash("Planwert gespeichert.", "success")
return_dialog = request.form.get("return_dialog") or _category_dialog_id(entry.category) or original_dialog_id
return _dialog_redirect(label, return_dialog)
@planning_bp.route("/<label>/categories", methods=["POST"])
@login_required
def create_category(label: str):
month = Month.query.filter_by(label=label).first_or_404()
name = request.form["name"].strip()
account = _account_from_form()
if account is None:
flash("Bitte einen Bereich für die Kategorie wählen.", "danger")
return _dialog_redirect(month.label, request.form.get("return_dialog") or None)
account_id = account.id
existing = Category.query.filter_by(account_id=account_id, slug=slugify(name)).first()
if existing is not None:
flash("Kategorie existiert bereits.", "info")
return redirect(url_for("planning.detail", label=label))
last_sort = (
db.session.scalar(
select(Category.sort_order)
.where(Category.account_id == account_id)
.order_by(Category.sort_order.desc())
.limit(1)
)
or 0
)
category = Category(
account_id=account_id,
community_account_id=(
int(request.form["community_account_id"])
if account.slug == "gemeinschaftskonto" and request.form.get("community_account_id")
else None
),
name=name,
slug=slugify(name),
is_active=True,
sort_order=last_sort + 1,
)
db.session.add(category)
db.session.commit()
flash("Kategorie angelegt.", "success")
return _dialog_redirect(month.label, _category_dialog_id(category))
@planning_bp.route("/<label>/categories/<int:category_id>", methods=["POST"])
@login_required
def update_category(label: str, category_id: int):
month = Month.query.filter_by(label=label).first_or_404()
category = Category.query.get_or_404(category_id)
name = request.form["name"].strip()
category.name = name
category.slug = slugify(request.form.get("slug", "") or name)
if request.form.get("account_id"):
category.account_id = int(request.form["account_id"])
category.description = request.form.get("description", "").strip() or None
target_account = Account.query.get(category.account_id)
category.community_account_id = (
int(request.form["community_account_id"])
if target_account
and target_account.slug == "gemeinschaftskonto"
and request.form.get("community_account_id")
else None
)
db.session.commit()
flash("Kategorie aktualisiert.", "success")
return _dialog_redirect(month.label, _category_dialog_id(category))
@planning_bp.route("/<label>/entries", methods=["POST"])
@login_required
def create_entry(label: str):
month = Month.query.filter_by(label=label).first_or_404()
name = request.form["name"].strip()
account = _account_from_form()
account_id = account.id if account is not None else int(request.form.get("account_id") or 0)
category_name = request.form.get("category_name", "").strip()
category_id = request.form.get("category_id")
category = None
if category_id:
category = Category.query.get_or_404(int(category_id))
elif category_name and account_id:
category = Category.query.filter_by(
account_id=account_id,
slug=slugify(category_name),
).first()
if category is None:
last_sort = (
db.session.scalar(
select(Category.sort_order)
.where(Category.account_id == account_id)
.order_by(Category.sort_order.desc())
.limit(1)
)
or 0
)
category = Category(
account_id=account_id,
name=category_name,
slug=slugify(category_name),
is_active=True,
sort_order=last_sort + 1,
)
db.session.add(category)
db.session.flush()
if category is None:
flash("Bitte Konto und Kategorie angeben.", "danger")
return redirect(url_for("planning.detail", label=label))
slug = slugify(request.form.get("slug", "") or name)
existing = Entry.query.filter_by(category_id=category.id, slug=slug).first()
if existing is not None:
flash("Eintrag existiert bereits.", "info")
return redirect(url_for("planning.detail", label=label))
last_sort = (
db.session.scalar(
select(Entry.sort_order)
.where(Entry.category_id == category.id)
.order_by(Entry.sort_order.desc())
.limit(1)
)
or 0
)
entry = Entry(
category_id=category.id,
name=name,
slug=slug,
description=request.form.get("description", "").strip() or None,
default_amount=to_decimal(request.form.get("default_amount", "0")),
amount_type=request.form.get("amount_type", "fixed"),
benefit_scope=request.form.get("benefit_scope", "all-users"),
is_allocation_target=request.form.get("is_allocation_target") == "on",
is_active=True,
sort_order=last_sort + 1,
)
db.session.add(entry)
db.session.flush()
months = Month.query.order_by(Month.year.asc(), Month.month.asc()).all()
current_note = request.form.get("note", "").strip() or None
current_amount = _resolve_monthly_amount(request.form)
for target_month in months:
db.session.add(
MonthlyEntryValue(
month_id=target_month.id,
entry_id=entry.id,
planned_amount=current_amount if target_month.id == month.id else entry.default_amount,
note=current_note if target_month.id == month.id else None,
created_by=current_user.id,
updated_by=current_user.id,
)
)
db.session.flush()
selected_participants = {
int(item) for item in request.form.getlist("participant_ids") if item.strip()
}
for participant_id in selected_participants:
db.session.add(
EntryShareRule(
entry_id=entry.id,
participant_id=participant_id,
share_type="equal",
)
)
current_app.extensions["saldo.month_service"].sync_distribution_allocation_from_entry(
month,
entry,
mark_manual=True,
)
db.session.commit()
current_app.extensions["saldo.month_service"].refresh_suggestions(
month, reason="Eintrag wurde angelegt"
)
db.session.commit()
flash("Eintrag angelegt.", "success")
return_dialog = request.form.get("return_dialog") or _category_dialog_id(category)
return _dialog_redirect(label, return_dialog)
@planning_bp.route("/<label>/participants", methods=["POST"])
@login_required
def create_participant(label: str):
name = request.form["name"].strip()
existing = CostParticipant.query.filter_by(name=name).first()
if existing is None:
participant = CostParticipant(
name=name,
avatar_url=_resolve_avatar_url(),
is_external=request.form.get("is_external") == "on",
is_active=True,
is_app_user=False,
)
db.session.add(participant)
db.session.commit()
flash("Person zum Splitten angelegt.", "success")
else:
flash("Person existiert bereits.", "info")
return _dialog_redirect(label, request.form.get("return_dialog") or "split-people-dialog")
@planning_bp.route("/<label>/community-accounts", methods=["POST"])
@login_required
def create_community_account(label: str):
name = request.form["name"].strip()
if not name:
flash("Bitte einen Namen für das Gemeinschaftskonto angeben.", "danger")
return _dialog_redirect(label, "community-account-create-dialog")
existing = CommunityAccount.query.filter_by(slug=slugify(name)).first()
if existing is not None:
flash("Gemeinschaftskonto existiert bereits.", "info")
return _dialog_redirect(label, "community-account-create-dialog")
last_sort = (
db.session.scalar(
select(CommunityAccount.sort_order)
.where(CommunityAccount.account_type == "shared")
.order_by(CommunityAccount.sort_order.desc())
.limit(1)
)
or 0
)
community_account = CommunityAccount(
name=name,
slug=slugify(name),
account_type="shared",
description=request.form.get("description", "").strip() or None,
sort_order=last_sort + 1,
is_active=True,
)
db.session.add(community_account)
db.session.commit()
flash("Gemeinschaftskonto angelegt.", "success")
return _dialog_redirect(label)
@planning_bp.route("/<label>/community-accounts/<int:community_account_id>", methods=["POST"])
@login_required
def update_community_account(label: str, community_account_id: int):
community_account = CommunityAccount.query.get_or_404(community_account_id)
if community_account.account_type != "shared":
flash("Persönliche Konten werden automatisch gepflegt.", "info")
return _dialog_redirect(label)
name = request.form["name"].strip()
community_account.name = name
community_account.slug = slugify(request.form.get("slug", "") or name)
community_account.description = request.form.get("description", "").strip() or None
selected_category_ids = {
int(item) for item in request.form.getlist("category_ids") if item.strip()
}
budget_categories = db.session.scalars(
select(Category)
.join(Account)
.where(
Category.is_active.is_(True),
Account.slug == "gemeinschaftskonto",
)
).all()
conflicting_categories = [
category.name
for category in budget_categories
if category.id in selected_category_ids
and category.community_account_id is not None
and category.community_account_id != community_account.id
]
if conflicting_categories:
flash(
"Diese Budgets sind bereits anderen Konten zugewiesen: "
+ ", ".join(sorted(conflicting_categories)),
"danger",
)
return _dialog_redirect(
label,
request.form.get("return_dialog") or f"community-account-item-{community_account.id}",
)
for category in budget_categories:
if category.id in selected_category_ids:
category.community_account_id = community_account.id
elif category.community_account_id == community_account.id:
category.community_account_id = None
db.session.commit()
flash("Gemeinschaftskonto aktualisiert.", "success")
return _dialog_redirect(label)
@planning_bp.route("/<label>/community-accounts/<int:community_account_id>/delete", methods=["POST"])
@login_required
def delete_community_account(label: str, community_account_id: int):
community_account = CommunityAccount.query.get_or_404(community_account_id)
if community_account.account_type != "shared":
flash("Persönliche Konten können hier nicht gelöscht werden.", "info")
return _dialog_redirect(label)
for category in Category.query.filter_by(community_account_id=community_account.id).all():
category.community_account_id = None
community_account.is_active = False
db.session.commit()
flash("Gemeinschaftskonto ausgeblendet.", "info")
return _dialog_redirect(label)
@planning_bp.route("/<label>/participants/<int:participant_id>", methods=["POST"])
@login_required
def update_participant(label: str, participant_id: int):
participant = CostParticipant.query.get_or_404(participant_id)
if participant.is_app_user:
flash("App-Nutzer werden automatisch aus den Benutzerkonten übernommen.", "info")
return _dialog_redirect(label, request.form.get("return_dialog") or "split-people-dialog")
name = request.form["name"].strip()
participant.name = name
participant.avatar_url = _resolve_avatar_url(participant.avatar_url)
participant.is_external = request.form.get("is_external") == "on"
participant.is_active = request.form.get("is_active") == "on"
db.session.commit()
flash("Split-Person aktualisiert.", "success")
return _dialog_redirect(label, request.form.get("return_dialog") or "split-people-dialog")
@planning_bp.route("/<label>/entries/<int:entry_id>/delete", methods=["POST"])
@login_required
def delete_entry(label: str, entry_id: int):
month = Month.query.filter_by(label=label).first_or_404()
entry = Entry.query.get_or_404(entry_id)
entry.is_active = False
db.session.commit()
current_app.extensions["saldo.month_service"].refresh_suggestions(
month, reason="Eintrag wurde gelöscht"
)
db.session.commit()
flash("Eintrag ausgeblendet.", "info")
return _dialog_redirect(label, request.form.get("return_dialog") or _category_dialog_id(entry.category))
@planning_bp.route("/<label>/categories/<int:category_id>/delete", methods=["POST"])
@login_required
def delete_category(label: str, category_id: int):
month = Month.query.filter_by(label=label).first_or_404()
category = Category.query.get_or_404(category_id)
category.is_active = False
for entry in category.entries:
entry.is_active = False
db.session.commit()
current_app.extensions["saldo.month_service"].refresh_suggestions(
month, reason="Kategorie wurde gelöscht"
)
db.session.commit()
flash("Kategorie ausgeblendet.", "info")
return _dialog_redirect(label)
@planning_bp.route("/<label>/allocation", methods=["POST"])
@login_required
def update_allocation(label: str):
allocation = MonthlyAllocation.query.get_or_404(int(request.form["allocation_id"]))
month = allocation.month
allocation.amount = to_decimal(request.form.get("amount", "0"))
allocation.is_locked = request.form.get("is_locked") == "on"
allocation.source = "manual"
current_app.extensions["saldo.month_service"].sync_distribution_entries_from_allocations(
month
)
current_app.extensions["saldo.month_service"].refresh_suggestions(
month, reason="Verteilung wurde geändert"
)
db.session.commit()
flash("Verteilung gespeichert.", "success")
return _dialog_redirect(label, request.form.get("return_dialog") or None)
@planning_bp.route("/<label>/suggestions/recalculate", methods=["POST"])
@login_required
def recalculate_suggestions(label: str):
month = Month.query.filter_by(label=label).first_or_404()
current_app.extensions["saldo.month_service"].refresh_suggestions(
month, reason="Vorschlag manuell neu berechnet"
)
db.session.commit()
flash("Vorschlag wurde neu berechnet.", "success")
return _dialog_redirect(label, request.form.get("return_dialog") or None)
@planning_bp.route("/<label>/suggestions/accept", methods=["POST"])
@login_required
def accept_suggestions(label: str):
month = Month.query.filter_by(label=label).first_or_404()
current_app.extensions["saldo.allocation_service"].accept_all(month)
current_app.extensions["saldo.month_service"].sync_distribution_entries_from_allocations(month)
current_app.extensions["saldo.month_service"].refresh_suggestions(
month, reason="Vorschlag wurde übernommen"
)
db.session.commit()
flash("Vorschlag wurde übernommen.", "success")
return _dialog_redirect(label, request.form.get("return_dialog") or None)
@planning_bp.route("/<label>/suggestions/<int:account_id>/accept", methods=["POST"])
@login_required
def accept_single_suggestion(label: str, account_id: int):
month = Month.query.filter_by(label=label).first_or_404()
current_app.extensions["saldo.allocation_service"].accept_single(month, account_id)
current_app.extensions["saldo.month_service"].sync_distribution_entries_from_allocations(month)
current_app.extensions["saldo.month_service"].refresh_suggestions(
month, reason="Vorschlagszeile wurde übernommen"
)
db.session.commit()
flash("Vorschlagszeile übernommen.", "success")
return _dialog_redirect(label, request.form.get("return_dialog") or None)
@planning_bp.route("/push/subscribe", methods=["POST"])
@login_required
def subscribe_push():
payload = request.get_json(force=True)
subscription = PushSubscription.query.filter_by(
user_id=current_user.id, endpoint=payload["endpoint"]
).first()
if subscription is None:
subscription = PushSubscription(user_id=current_user.id, endpoint=payload["endpoint"])
db.session.add(subscription)
subscription.p256dh_key = payload["keys"]["p256dh"]
subscription.auth_key = payload["keys"]["auth"]
subscription.user_agent = request.headers.get("User-Agent")
db.session.commit()
return jsonify({"status": "ok"})