diff --git a/app/planning/routes.py b/app/planning/routes.py index fd296a0..303d27c 100644 --- a/app/planning/routes.py +++ b/app/planning/routes.py @@ -681,10 +681,24 @@ def create_category(label: str): 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() + slug = slugify(name) + existing = Category.query.filter_by(account_id=account_id, slug=slug).first() if existing is not None: - flash("Kategorie existiert bereits.", "info") - return redirect(url_for("planning.detail", label=label)) + 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) @@ -702,7 +716,7 @@ def create_category(label: str): else None ), name=name, - slug=slugify(name), + slug=slug, is_active=True, sort_order=last_sort + 1, ) @@ -747,7 +761,7 @@ def create_entry(label: str): category_id = request.form.get("category_id") category = None if category_id: - category = Category.query.get_or_404(int(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, @@ -772,14 +786,82 @@ def create_entry(label: str): ) 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: - flash("Eintrag existiert bereits.", "info") - return redirect(url_for("planning.detail", label=label)) + 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) @@ -872,10 +954,21 @@ def create_community_account(label: str): 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() + slug = slugify(name) + existing = CommunityAccount.query.filter_by(slug=slug).first() if existing is not None: - flash("Gemeinschaftskonto existiert bereits.", "info") - return _dialog_redirect(label, "community-account-create-dialog") + 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) @@ -887,7 +980,7 @@ def create_community_account(label: str): ) community_account = CommunityAccount( name=name, - slug=slugify(name), + slug=slug, account_type="shared", description=request.form.get("description", "").strip() or None, sort_order=last_sort + 1, diff --git a/app/static/js/app.js b/app/static/js/app.js index d1a8f25..933a002 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -415,6 +415,17 @@ function mountThemeToggle() { }); } +function mountDeleteConfirmations() { + document.querySelectorAll("[data-confirm-submit]").forEach((node) => { + node.addEventListener("click", (event) => { + const message = node.dataset.confirmSubmit || "Wirklich löschen?"; + if (!window.confirm(message)) { + event.preventDefault(); + } + }); + }); +} + document.addEventListener("DOMContentLoaded", async () => { await registerServiceWorker(); injectCsrfTokens(); @@ -423,6 +434,7 @@ document.addEventListener("DOMContentLoaded", async () => { mountDialogs(); mountPersonalSplitSync(); mountAnnualAmountSync(); + mountDeleteConfirmations(); document.querySelectorAll("[data-enable-push]").forEach((node) => { node.addEventListener("click", async (event) => { event.preventDefault(); diff --git a/app/templates/planning/detail.html b/app/templates/planning/detail.html index cf4f702..83378b0 100644 --- a/app/templates/planning/detail.html +++ b/app/templates/planning/detail.html @@ -226,7 +226,7 @@
{% if month.incomes|length > 1 %} - + {% endif %}
@@ -354,7 +354,7 @@

`{{ card.community_account.name }}` wird ausgeblendet und seine Budget-Zuordnungen werden gelöst.

- +
@@ -599,7 +599,7 @@
- +
@@ -794,7 +794,7 @@
- +
diff --git a/tests/test_routes.py b/tests/test_routes.py index dc2e190..c98b555 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -111,6 +111,16 @@ def test_planning_shows_budgets_and_community_accounts(logged_in_client): assert b"Auszahlung Person B" in response.data +def test_planning_delete_actions_render_confirmation_hooks(logged_in_client): + response = logged_in_client.get("/planning/2026-04") + + assert response.status_code == 200 + assert b'data-confirm-submit="Einkommenszeile wirklich l\xc3\xb6schen?' in response.data + assert b'data-confirm-submit="Kategorie wirklich l\xc3\xb6schen?' in response.data + assert b'data-confirm-submit="Konto wirklich l\xc3\xb6schen?' in response.data + assert b'data-confirm-submit="Eintrag wirklich l\xc3\xb6schen?' in response.data + + def test_community_account_can_assign_budget_categories(logged_in_client): community_account = CommunityAccount.query.filter_by(slug="hauptkonto").first() category = Category.query.filter_by(slug="wohnen").first() @@ -130,6 +140,59 @@ def test_community_account_can_assign_budget_categories(logged_in_client): assert category.community_account_id == community_account.id +def test_deleted_category_can_be_created_again(logged_in_client): + create_response = logged_in_client.post( + "/planning/2026-04/categories", + data={"name": "Testbudget", "area": "budget"}, + ) + assert create_response.status_code == 302 + + category = Category.query.filter_by(slug="testbudget").first() + original_id = category.id + + delete_response = logged_in_client.post(f"/planning/2026-04/categories/{category.id}/delete") + assert delete_response.status_code == 302 + + recreate_response = logged_in_client.post( + "/planning/2026-04/categories", + data={"name": "Testbudget", "area": "budget"}, + ) + + restored = Category.query.filter_by(slug="testbudget").first() + + assert recreate_response.status_code == 302 + assert restored.id == original_id + assert restored.is_active is True + + +def test_deleted_community_account_can_be_created_again(logged_in_client): + create_response = logged_in_client.post( + "/planning/2026-04/community-accounts", + data={"name": "Testkonto", "description": ""}, + ) + assert create_response.status_code == 302 + + community_account = CommunityAccount.query.filter_by(slug="testkonto").first() + original_id = community_account.id + + delete_response = logged_in_client.post( + f"/planning/2026-04/community-accounts/{community_account.id}/delete" + ) + assert delete_response.status_code == 302 + + recreate_response = logged_in_client.post( + "/planning/2026-04/community-accounts", + data={"name": "Testkonto", "description": "Wieder da"}, + ) + + restored = CommunityAccount.query.filter_by(slug="testkonto").first() + + assert recreate_response.status_code == 302 + assert restored.id == original_id + assert restored.is_active is True + assert restored.description == "Wieder da" + + def test_community_account_rejects_budget_assigned_to_other_account(logged_in_client): primary_account = CommunityAccount.query.filter_by(slug="hauptkonto").first() secondary_response = logged_in_client.post(