fix: confirm deletes and restore recreated planning items
This commit is contained in:
+104
-11
@@ -681,10 +681,24 @@ def create_category(label: str):
|
|||||||
flash("Bitte einen Bereich für die Kategorie wählen.", "danger")
|
flash("Bitte einen Bereich für die Kategorie wählen.", "danger")
|
||||||
return _dialog_redirect(month.label, request.form.get("return_dialog") or None)
|
return _dialog_redirect(month.label, request.form.get("return_dialog") or None)
|
||||||
account_id = account.id
|
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:
|
if existing is not None:
|
||||||
flash("Kategorie existiert bereits.", "info")
|
if existing.is_active:
|
||||||
return redirect(url_for("planning.detail", label=label))
|
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 = (
|
last_sort = (
|
||||||
db.session.scalar(
|
db.session.scalar(
|
||||||
select(Category.sort_order)
|
select(Category.sort_order)
|
||||||
@@ -702,7 +716,7 @@ def create_category(label: str):
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
name=name,
|
name=name,
|
||||||
slug=slugify(name),
|
slug=slug,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
sort_order=last_sort + 1,
|
sort_order=last_sort + 1,
|
||||||
)
|
)
|
||||||
@@ -747,7 +761,7 @@ def create_entry(label: str):
|
|||||||
category_id = request.form.get("category_id")
|
category_id = request.form.get("category_id")
|
||||||
category = None
|
category = None
|
||||||
if category_id:
|
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:
|
elif category_name and account_id:
|
||||||
category = Category.query.filter_by(
|
category = Category.query.filter_by(
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
@@ -772,14 +786,82 @@ def create_entry(label: str):
|
|||||||
)
|
)
|
||||||
db.session.add(category)
|
db.session.add(category)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
elif not category.is_active:
|
||||||
|
category.is_active = True
|
||||||
|
db.session.flush()
|
||||||
if category is None:
|
if category is None:
|
||||||
flash("Bitte Konto und Kategorie angeben.", "danger")
|
flash("Bitte Konto und Kategorie angeben.", "danger")
|
||||||
return redirect(url_for("planning.detail", label=label))
|
return redirect(url_for("planning.detail", label=label))
|
||||||
slug = slugify(request.form.get("slug", "") or name)
|
slug = slugify(request.form.get("slug", "") or name)
|
||||||
existing = Entry.query.filter_by(category_id=category.id, slug=slug).first()
|
existing = Entry.query.filter_by(category_id=category.id, slug=slug).first()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
flash("Eintrag existiert bereits.", "info")
|
if existing.is_active:
|
||||||
return redirect(url_for("planning.detail", label=label))
|
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 = (
|
last_sort = (
|
||||||
db.session.scalar(
|
db.session.scalar(
|
||||||
select(Entry.sort_order)
|
select(Entry.sort_order)
|
||||||
@@ -872,10 +954,21 @@ def create_community_account(label: str):
|
|||||||
if not name:
|
if not name:
|
||||||
flash("Bitte einen Namen für das Gemeinschaftskonto angeben.", "danger")
|
flash("Bitte einen Namen für das Gemeinschaftskonto angeben.", "danger")
|
||||||
return _dialog_redirect(label, "community-account-create-dialog")
|
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:
|
if existing is not None:
|
||||||
flash("Gemeinschaftskonto existiert bereits.", "info")
|
if existing.is_active:
|
||||||
return _dialog_redirect(label, "community-account-create-dialog")
|
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 = (
|
last_sort = (
|
||||||
db.session.scalar(
|
db.session.scalar(
|
||||||
select(CommunityAccount.sort_order)
|
select(CommunityAccount.sort_order)
|
||||||
@@ -887,7 +980,7 @@ def create_community_account(label: str):
|
|||||||
)
|
)
|
||||||
community_account = CommunityAccount(
|
community_account = CommunityAccount(
|
||||||
name=name,
|
name=name,
|
||||||
slug=slugify(name),
|
slug=slug,
|
||||||
account_type="shared",
|
account_type="shared",
|
||||||
description=request.form.get("description", "").strip() or None,
|
description=request.form.get("description", "").strip() or None,
|
||||||
sort_order=last_sort + 1,
|
sort_order=last_sort + 1,
|
||||||
|
|||||||
@@ -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 () => {
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
await registerServiceWorker();
|
await registerServiceWorker();
|
||||||
injectCsrfTokens();
|
injectCsrfTokens();
|
||||||
@@ -423,6 +434,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
mountDialogs();
|
mountDialogs();
|
||||||
mountPersonalSplitSync();
|
mountPersonalSplitSync();
|
||||||
mountAnnualAmountSync();
|
mountAnnualAmountSync();
|
||||||
|
mountDeleteConfirmations();
|
||||||
document.querySelectorAll("[data-enable-push]").forEach((node) => {
|
document.querySelectorAll("[data-enable-push]").forEach((node) => {
|
||||||
node.addEventListener("click", async (event) => {
|
node.addEventListener("click", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -226,7 +226,7 @@
|
|||||||
<div class="dialog-action-row dialog-action-spread">
|
<div class="dialog-action-row dialog-action-spread">
|
||||||
<button class="ghost-button small-button" type="submit">Speichern</button>
|
<button class="ghost-button small-button" type="submit">Speichern</button>
|
||||||
{% if month.incomes|length > 1 %}
|
{% if month.incomes|length > 1 %}
|
||||||
<button class="ghost-button danger-button small-button" type="submit" formaction="{{ url_for('planning.delete_income', label=month.label, income_id=income.id) }}">Löschen</button>
|
<button class="ghost-button danger-button small-button" type="submit" formaction="{{ url_for('planning.delete_income', label=month.label, income_id=income.id) }}" formnovalidate data-confirm-submit="Einkommenszeile wirklich löschen?">Löschen</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -354,7 +354,7 @@
|
|||||||
<p class="muted">`{{ card.community_account.name }}` wird ausgeblendet und seine Budget-Zuordnungen werden gelöst.</p>
|
<p class="muted">`{{ card.community_account.name }}` wird ausgeblendet und seine Budget-Zuordnungen werden gelöst.</p>
|
||||||
<form method="post" action="{{ url_for('planning.delete_community_account', label=month.label, community_account_id=card.community_account.id) }}" class="dialog-action-row dialog-action-spread">
|
<form method="post" action="{{ url_for('planning.delete_community_account', label=month.label, community_account_id=card.community_account.id) }}" class="dialog-action-row dialog-action-spread">
|
||||||
<button class="ghost-button" type="button" data-open-dialog="community-account-item-{{ card.community_account.id }}">Zurück</button>
|
<button class="ghost-button" type="button" data-open-dialog="community-account-item-{{ card.community_account.id }}">Zurück</button>
|
||||||
<button class="primary-button danger-fill-button" type="submit">Jetzt löschen</button>
|
<button class="primary-button danger-fill-button" type="submit" data-confirm-submit="Konto wirklich löschen?">Jetzt löschen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
@@ -599,7 +599,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('planning.delete_category', label=month.label, category_id=category_data.category.id) }}">
|
<form method="post" action="{{ url_for('planning.delete_category', label=month.label, category_id=category_data.category.id) }}">
|
||||||
<button class="ghost-button danger-button" type="submit">Kategorie löschen</button>
|
<button class="ghost-button danger-button" type="submit" data-confirm-submit="Kategorie wirklich löschen?">Kategorie löschen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
@@ -794,7 +794,7 @@
|
|||||||
<form method="post" action="{{ url_for('planning.delete_entry', label=month.label, entry_id=item.entry.id) }}" class="dialog-action-row dialog-action-spread">
|
<form method="post" action="{{ url_for('planning.delete_entry', label=month.label, entry_id=item.entry.id) }}" class="dialog-action-row dialog-action-spread">
|
||||||
<input type="hidden" name="return_dialog" value="{{ category_data.dialog_id }}">
|
<input type="hidden" name="return_dialog" value="{{ category_data.dialog_id }}">
|
||||||
<button class="ghost-button" type="button" data-open-dialog="{{ item.dialog_id }}">Zurück</button>
|
<button class="ghost-button" type="button" data-open-dialog="{{ item.dialog_id }}">Zurück</button>
|
||||||
<button class="primary-button danger-fill-button" type="submit">Jetzt löschen</button>
|
<button class="primary-button danger-fill-button" type="submit" data-confirm-submit="Eintrag wirklich löschen?">Jetzt löschen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
@@ -111,6 +111,16 @@ def test_planning_shows_budgets_and_community_accounts(logged_in_client):
|
|||||||
assert b"Auszahlung Person B" in response.data
|
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):
|
def test_community_account_can_assign_budget_categories(logged_in_client):
|
||||||
community_account = CommunityAccount.query.filter_by(slug="hauptkonto").first()
|
community_account = CommunityAccount.query.filter_by(slug="hauptkonto").first()
|
||||||
category = Category.query.filter_by(slug="wohnen").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
|
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):
|
def test_community_account_rejects_budget_assigned_to_other_account(logged_in_client):
|
||||||
primary_account = CommunityAccount.query.filter_by(slug="hauptkonto").first()
|
primary_account = CommunityAccount.query.filter_by(slug="hauptkonto").first()
|
||||||
secondary_response = logged_in_client.post(
|
secondary_response = logged_in_client.post(
|
||||||
|
|||||||
Reference in New Issue
Block a user