diff --git a/app/admin/routes.py b/app/admin/routes.py index 9cfa021..9afb4c8 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -2,6 +2,7 @@ from __future__ import annotations from flask import Blueprint, flash, redirect, render_template, request, url_for from flask_login import login_required +from sqlalchemy.exc import IntegrityError from sqlalchemy import select from app.extensions import db @@ -51,6 +52,9 @@ def index(): entries = db.session.scalars( select(Entry).order_by(Entry.category_id.asc(), Entry.sort_order.asc(), Entry.name.asc()) ).all() + inactive_accounts = [account for account in accounts if not account.is_active] + inactive_categories = [category for category in categories if not category.is_active] + inactive_entries = [entry for entry in entries if not entry.is_active] return render_template( "admin/index.html", users=users, @@ -58,6 +62,9 @@ def index(): accounts=accounts, categories=categories, entries=entries, + inactive_accounts=inactive_accounts, + inactive_categories=inactive_categories, + inactive_entries=inactive_entries, ) @@ -263,6 +270,53 @@ def update_entry(entry_id: int): return redirect(url_for("admin.index")) +@admin_bp.route("/accounts//delete", methods=["POST"]) +@login_required +@admin_required +def delete_account(account_id: int): + account = Account.query.get_or_404(account_id) + if account.is_active: + flash("Nur inaktive Konten können hier endgültig gelöscht werden.", "danger") + return redirect(url_for("admin.index")) + try: + db.session.delete(account) + db.session.commit() + except IntegrityError: + db.session.rollback() + flash("Konto konnte nicht gelöscht werden, weil noch abhängige Daten existieren.", "danger") + return redirect(url_for("admin.index")) + flash("Konto endgültig gelöscht.", "success") + return redirect(url_for("admin.index")) + + +@admin_bp.route("/categories//delete", methods=["POST"]) +@login_required +@admin_required +def delete_category(category_id: int): + category = Category.query.get_or_404(category_id) + if category.is_active: + flash("Nur inaktive Kategorien können hier endgültig gelöscht werden.", "danger") + return redirect(url_for("admin.index")) + db.session.delete(category) + db.session.commit() + flash("Kategorie endgültig gelöscht.", "success") + return redirect(url_for("admin.index")) + + +@admin_bp.route("/entries//delete", methods=["POST"]) +@login_required +@admin_required +def delete_entry(entry_id: int): + entry = Entry.query.get_or_404(entry_id) + if entry.is_active: + flash("Nur inaktive Einträge können hier endgültig gelöscht werden.", "danger") + return redirect(url_for("admin.index")) + db.session.delete(entry) + db.session.commit() + flash("Eintrag endgültig gelöscht.", "success") + return redirect(url_for("admin.index")) + + @admin_bp.route("/entries//share-rules", methods=["POST"]) @login_required @admin_required diff --git a/app/services/month_service.py b/app/services/month_service.py index 068e2a0..c1a26e8 100644 --- a/app/services/month_service.py +++ b/app/services/month_service.py @@ -190,7 +190,7 @@ class MonthService: ( self.share_service.calculate_entry_shares(item)["internal_total"] for item in month.entry_values - if item.entry_id not in distribution_entry_ids + if item.entry_id not in distribution_entry_ids and self._is_visible_monthly_value(item) ), Decimal("0.00"), ) @@ -368,19 +368,12 @@ class MonthService: def _distribution_entry_values(self, month: Month) -> dict[str, MonthlyEntryValue]: grouped: dict[str, list[MonthlyEntryValue]] = {} for value in month.entry_values: + if not self._is_visible_monthly_value(value): + continue 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 - ): + if account is None or account.slug not in self.allocation_service.TARGET_SLUGS or not entry.is_allocation_target: continue grouped.setdefault(account.slug, []).append(value) @@ -397,6 +390,19 @@ class MonthService: preferred[account_slug] = values[0] return preferred + def _is_visible_monthly_value(self, value: MonthlyEntryValue) -> bool: + entry = value.entry + category = entry.category if entry else None + account = category.account if category else None + return bool( + entry is not None + and category is not None + and account is not None + and entry.is_active + and category.is_active + and account.is_active + ) + def _distribution_label(self, account_slug: str, fallback: str) -> str: personal_labels = personal_account_names() if account_slug in personal_labels: diff --git a/app/templates/admin/index.html b/app/templates/admin/index.html index fb166d9..74565a3 100644 --- a/app/templates/admin/index.html +++ b/app/templates/admin/index.html @@ -71,4 +71,53 @@ {% endfor %} + +
+
+
+

Inaktive Elemente

+ Ausgeblendete Konten, Kategorien und Einträge können hier endgültig gelöscht werden. +
+
+ + {% if inactive_accounts or inactive_categories or inactive_entries %} + {% for account in inactive_accounts %} +
+
+ Konto: {{ account.name }} + {{ account.slug }} +
+
+ +
+
+ {% endfor %} + + {% for category in inactive_categories %} +
+
+ Kategorie: {{ category.name }} + {{ category.account.name }} · {{ category.slug }} +
+
+ +
+
+ {% endfor %} + + {% for entry in inactive_entries %} +
+
+ Eintrag: {{ entry.name }} + {{ entry.category.name }} · {{ entry.slug }} +
+
+ +
+
+ {% endfor %} + {% else %} +
Aktuell gibt es keine inaktiven Konten, Kategorien oder Einträge.
+ {% endif %} +
{% endblock %} diff --git a/tests/test_comparison_and_allocations.py b/tests/test_comparison_and_allocations.py index 6357935..2a55320 100644 --- a/tests/test_comparison_and_allocations.py +++ b/tests/test_comparison_and_allocations.py @@ -180,3 +180,18 @@ def test_personal_account_names_fill_with_admin_if_only_one_editor_is_active(app assert personal_labels["persoenlich-flo"] == "Person A" assert personal_labels["persoenlich-desi"] == "Admin" + + +def test_compute_summary_ignores_inactive_budget_entries(app): + service = app.extensions["saldo.month_service"] + month = Month.query.filter_by(label="2026-04").first() + value = next(item for item in month.entry_values if item.entry.name == "Miete") + + baseline = service.compute_summary(month) + value.entry.is_active = False + db.session.commit() + + summary = service.compute_summary(month) + + assert summary.fixed_costs == baseline.fixed_costs - Decimal("920.00") + assert summary.total_costs == baseline.total_costs - Decimal("920.00") diff --git a/tests/test_shares_notifications_auth.py b/tests/test_shares_notifications_auth.py index 4027683..4006f3f 100644 --- a/tests/test_shares_notifications_auth.py +++ b/tests/test_shares_notifications_auth.py @@ -72,6 +72,33 @@ def test_admin_can_access_admin_route(logged_in_client): assert response.status_code == 200 +def test_admin_route_shows_inactive_cleanup_section(logged_in_client, app): + entry = Entry.query.filter_by(name="Lebensmittel").first() + entry.is_active = False + db.session.commit() + + response = logged_in_client.get("/admin/") + + assert response.status_code == 200 + assert "Inaktive Elemente" in response.get_data(as_text=True) + assert "Endgültig löschen" in response.get_data(as_text=True) + + +def test_admin_can_hard_delete_inactive_entry(logged_in_client, app): + entry = Entry.query.filter_by(name="Lebensmittel").first() + entry.is_active = False + entry_id = entry.id + db.session.commit() + + response = logged_in_client.post( + f"/admin/entries/{entry_id}/delete", + follow_redirects=True, + ) + + assert response.status_code == 200 + assert Entry.query.get(entry_id) is None + + def test_admin_can_create_entry_and_backfill_existing_month(logged_in_client, app): from app.models import Category, Entry, Month, MonthlyEntryValue