1184 lines
48 KiB
Python
1184 lines
48 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
|
|
slug = slugify(name)
|
|
existing = Category.query.filter_by(account_id=account_id, slug=slug).first()
|
|
if existing is not None:
|
|
if existing.is_active:
|
|
flash("Kategorie existiert bereits.", "info")
|
|
return redirect(url_for("planning.detail", label=label))
|
|
existing.name = name
|
|
existing.slug = slug
|
|
existing.description = request.form.get("description", "").strip() or None
|
|
existing.community_account_id = (
|
|
int(request.form["community_account_id"])
|
|
if account.slug == "gemeinschaftskonto" and request.form.get("community_account_id")
|
|
else None
|
|
)
|
|
existing.is_active = True
|
|
db.session.commit()
|
|
flash("Kategorie wiederhergestellt.", "success")
|
|
return _dialog_redirect(month.label, _category_dialog_id(existing))
|
|
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=slug,
|
|
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()
|
|
elif not category.is_active:
|
|
category.is_active = True
|
|
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:
|
|
if existing.is_active:
|
|
flash("Eintrag existiert bereits.", "info")
|
|
return redirect(url_for("planning.detail", label=label))
|
|
existing.name = name
|
|
existing.slug = slug
|
|
existing.description = request.form.get("description", "").strip() or None
|
|
existing.default_amount = to_decimal(request.form.get("default_amount", "0"))
|
|
existing.amount_type = request.form.get("amount_type", "fixed")
|
|
existing.benefit_scope = request.form.get("benefit_scope", "all-users")
|
|
existing.is_allocation_target = request.form.get("is_allocation_target") == "on"
|
|
existing.is_active = True
|
|
entry = existing
|
|
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)
|
|
existing_values = {item.month_id: item for item in entry.monthly_values}
|
|
for target_month in months:
|
|
value = existing_values.get(target_month.id)
|
|
if value is None:
|
|
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,
|
|
)
|
|
)
|
|
elif target_month.id == month.id:
|
|
value.planned_amount = current_amount
|
|
value.note = current_note
|
|
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,
|
|
)
|
|
db.session.commit()
|
|
current_app.extensions["saldo.month_service"].refresh_suggestions(
|
|
month, reason="Eintrag wurde wiederhergestellt"
|
|
)
|
|
db.session.commit()
|
|
flash("Eintrag wiederhergestellt.", "success")
|
|
return_dialog = request.form.get("return_dialog") or _category_dialog_id(category)
|
|
return _dialog_redirect(label, return_dialog)
|
|
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")
|
|
slug = slugify(name)
|
|
existing = CommunityAccount.query.filter_by(slug=slug).first()
|
|
if existing is not None:
|
|
if existing.is_active:
|
|
flash("Gemeinschaftskonto existiert bereits.", "info")
|
|
return _dialog_redirect(label, "community-account-create-dialog")
|
|
existing.name = name
|
|
existing.slug = slug
|
|
existing.account_type = "shared"
|
|
existing.linked_account_slug = None
|
|
existing.description = request.form.get("description", "").strip() or None
|
|
existing.is_active = True
|
|
db.session.commit()
|
|
flash("Gemeinschaftskonto wiederhergestellt.", "success")
|
|
return _dialog_redirect(label)
|
|
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=slug,
|
|
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"})
|