fix: align total costs with visible budgets
This commit is contained in:
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
@@ -51,6 +52,9 @@ def index():
|
|||||||
entries = db.session.scalars(
|
entries = db.session.scalars(
|
||||||
select(Entry).order_by(Entry.category_id.asc(), Entry.sort_order.asc(), Entry.name.asc())
|
select(Entry).order_by(Entry.category_id.asc(), Entry.sort_order.asc(), Entry.name.asc())
|
||||||
).all()
|
).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(
|
return render_template(
|
||||||
"admin/index.html",
|
"admin/index.html",
|
||||||
users=users,
|
users=users,
|
||||||
@@ -58,6 +62,9 @@ def index():
|
|||||||
accounts=accounts,
|
accounts=accounts,
|
||||||
categories=categories,
|
categories=categories,
|
||||||
entries=entries,
|
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"))
|
return redirect(url_for("admin.index"))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/accounts/<int:account_id>/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/<int:category_id>/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/<int:entry_id>/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/<int:entry_id>/share-rules", methods=["POST"])
|
@admin_bp.route("/entries/<int:entry_id>/share-rules", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ class MonthService:
|
|||||||
(
|
(
|
||||||
self.share_service.calculate_entry_shares(item)["internal_total"]
|
self.share_service.calculate_entry_shares(item)["internal_total"]
|
||||||
for item in month.entry_values
|
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"),
|
Decimal("0.00"),
|
||||||
)
|
)
|
||||||
@@ -368,19 +368,12 @@ class MonthService:
|
|||||||
def _distribution_entry_values(self, month: Month) -> dict[str, MonthlyEntryValue]:
|
def _distribution_entry_values(self, month: Month) -> dict[str, MonthlyEntryValue]:
|
||||||
grouped: dict[str, list[MonthlyEntryValue]] = {}
|
grouped: dict[str, list[MonthlyEntryValue]] = {}
|
||||||
for value in month.entry_values:
|
for value in month.entry_values:
|
||||||
|
if not self._is_visible_monthly_value(value):
|
||||||
|
continue
|
||||||
entry = value.entry
|
entry = value.entry
|
||||||
category = entry.category if entry else None
|
category = entry.category if entry else None
|
||||||
account = category.account if category else None
|
account = category.account if category else None
|
||||||
if (
|
if account is None or account.slug not in self.allocation_service.TARGET_SLUGS or not entry.is_allocation_target:
|
||||||
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
|
continue
|
||||||
grouped.setdefault(account.slug, []).append(value)
|
grouped.setdefault(account.slug, []).append(value)
|
||||||
|
|
||||||
@@ -397,6 +390,19 @@ class MonthService:
|
|||||||
preferred[account_slug] = values[0]
|
preferred[account_slug] = values[0]
|
||||||
return preferred
|
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:
|
def _distribution_label(self, account_slug: str, fallback: str) -> str:
|
||||||
personal_labels = personal_account_names()
|
personal_labels = personal_account_names()
|
||||||
if account_slug in personal_labels:
|
if account_slug in personal_labels:
|
||||||
|
|||||||
@@ -71,4 +71,53 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Inaktive Elemente</h2>
|
||||||
|
<small>Ausgeblendete Konten, Kategorien und Einträge können hier endgültig gelöscht werden.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if inactive_accounts or inactive_categories or inactive_entries %}
|
||||||
|
{% for account in inactive_accounts %}
|
||||||
|
<div class="month-row">
|
||||||
|
<div>
|
||||||
|
<strong>Konto: {{ account.name }}</strong>
|
||||||
|
<small>{{ account.slug }}</small>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('admin.delete_account', account_id=account.id) }}">
|
||||||
|
<button class="ghost-button danger-button" type="submit">Endgültig löschen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for category in inactive_categories %}
|
||||||
|
<div class="month-row">
|
||||||
|
<div>
|
||||||
|
<strong>Kategorie: {{ category.name }}</strong>
|
||||||
|
<small>{{ category.account.name }} · {{ category.slug }}</small>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('admin.delete_category', category_id=category.id) }}">
|
||||||
|
<button class="ghost-button danger-button" type="submit">Endgültig löschen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for entry in inactive_entries %}
|
||||||
|
<div class="month-row">
|
||||||
|
<div>
|
||||||
|
<strong>Eintrag: {{ entry.name }}</strong>
|
||||||
|
<small>{{ entry.category.name }} · {{ entry.slug }}</small>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('admin.delete_entry', entry_id=entry.id) }}">
|
||||||
|
<button class="ghost-button danger-button" type="submit">Endgültig löschen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">Aktuell gibt es keine inaktiven Konten, Kategorien oder Einträge.</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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-flo"] == "Person A"
|
||||||
assert personal_labels["persoenlich-desi"] == "Admin"
|
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")
|
||||||
|
|||||||
@@ -72,6 +72,33 @@ def test_admin_can_access_admin_route(logged_in_client):
|
|||||||
assert response.status_code == 200
|
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):
|
def test_admin_can_create_entry_and_backfill_existing_month(logged_in_client, app):
|
||||||
from app.models import Category, Entry, Month, MonthlyEntryValue
|
from app.models import Category, Entry, Month, MonthlyEntryValue
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user