Compare commits

..

8 Commits

7 changed files with 365 additions and 45 deletions
+119 -22
View File
@@ -177,6 +177,13 @@ def _community_account_totals(month, previous_month, community_accounts, budget_
return cards return cards
def _resolve_distribution_direct_entry(entry_rows: list[dict]) -> dict | None:
return next(
(entry_row for entry_row in entry_rows if entry_row["entry"].is_allocation_target),
entry_rows[0] if entry_rows else None,
)
def _distribution_hint_map(allocation_service, month, summary, allocations_by_slug, suggestions_by_slug) -> dict[str, dict]: def _distribution_hint_map(allocation_service, month, summary, allocations_by_slug, suggestions_by_slug) -> dict[str, dict]:
result = {} result = {}
return { return {
@@ -340,7 +347,9 @@ def detail(label: str):
"distribution_kind": "single" if account.slug in {"sparen", "urlaub", "freizeit"} else None, "distribution_kind": "single" if account.slug in {"sparen", "urlaub", "freizeit"} else None,
"distribution_suggestion_total": suggestion_total, "distribution_suggestion_total": suggestion_total,
"distribution_hint": distribution_hints.get(account.slug), "distribution_hint": distribution_hints.get(account.slug),
"direct_entry": next( "direct_entry": _resolve_distribution_direct_entry(entry_rows)
if account.slug in {"sparen", "urlaub", "freizeit"}
else next(
(entry_row for entry_row in entry_rows if entry_row["entry"].is_allocation_target), (entry_row for entry_row in entry_rows if entry_row["entry"].is_allocation_target),
None, None,
), ),
@@ -348,6 +357,8 @@ def detail(label: str):
} }
) )
if account.slug in {"sparen", "urlaub", "freizeit"}: if account.slug in {"sparen", "urlaub", "freizeit"}:
flattened_entries = [entry for category_card in category_cards for entry in category_card["entries"]]
direct_entry = _resolve_distribution_direct_entry(flattened_entries)
distribution_bucket["categories"].append( distribution_bucket["categories"].append(
{ {
"category": type( "category": type(
@@ -355,7 +366,7 @@ def detail(label: str):
(), (),
{"id": account.id * -100, "name": account.name, "account_id": account.id}, {"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"]], "entries": flattened_entries,
"total": account_total, "total": account_total,
"entry_count": sum(category_card["entry_count"] for category_card in category_cards), "entry_count": sum(category_card["entry_count"] for category_card in category_cards),
"dialog_id": f"distribution-dialog-{account.slug}", "dialog_id": f"distribution-dialog-{account.slug}",
@@ -363,15 +374,8 @@ def detail(label: str):
"distribution_suggestion_total": distribution_hints.get(account.slug, {}).get("remaining_amount", Decimal("0.00")), "distribution_suggestion_total": distribution_hints.get(account.slug, {}).get("remaining_amount", Decimal("0.00")),
"distribution_hint": distribution_hints.get(account.slug), "distribution_hint": distribution_hints.get(account.slug),
"distribution_account_slug": account.slug, "distribution_account_slug": account.slug,
"direct_entry": next( "direct_entry": direct_entry,
( "allow_new_entries": direct_entry is None,
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 distribution_bucket["total"] += account_total
@@ -681,10 +685,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 +720,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 +765,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 +790,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 +958,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 +984,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,
+46 -11
View File
@@ -13,6 +13,7 @@ from app.models import (
MonthlyIncome, MonthlyIncome,
NotificationPreference, NotificationPreference,
User, User,
to_decimal,
) )
@@ -203,6 +204,44 @@ def _deactivate_placeholder_categories() -> None:
category.community_account_id = None category.community_account_id = None
def _restore_existing_standard_structure() -> None:
for account_slug, account_data in ACCOUNT_TREE.items():
account = Account.query.filter_by(slug=account_slug).first()
if account is None:
continue
for category_slug, entries in account_data["categories"].items():
category = Category.query.filter_by(account_id=account.id, slug=category_slug).first()
legacy_slug = LEGACY_CATEGORY_SLUGS.get((account_slug, category_slug))
if category is None and legacy_slug:
category = Category.query.filter_by(account_id=account.id, slug=legacy_slug).first()
if category is None:
continue
category.is_active = True
for entry_name in entries:
entry_slug = slugify(entry_name)
entry = Entry.query.filter_by(category_id=category.id, slug=entry_slug).first()
legacy_entry_name = LEGACY_ENTRY_NAMES.get((account_slug, category_slug, entry_name))
if entry is None and legacy_entry_name:
entry = Entry.query.filter_by(
category_id=category.id,
slug=slugify(legacy_entry_name),
).first()
if entry is not None:
entry.is_active = True
def _restore_existing_budget_visibility() -> None:
gemeinschaft = Account.query.filter_by(slug="gemeinschaftskonto").first()
if gemeinschaft is None:
return
for category in gemeinschaft.categories:
if category.is_active:
continue
active_entries = [entry for entry in category.entries if entry.is_active]
if active_entries:
category.is_active = True
def seed_data(include_example_entries: bool = False) -> None: def seed_data(include_example_entries: bool = False) -> None:
# Basisdaten nur für die fachliche Grundstruktur, ohne Demo-Benutzer, # Basisdaten nur für die fachliche Grundstruktur, ohne Demo-Benutzer,
# Beispiel-Personen oder vorausgefüllte Monatsdaten. # Beispiel-Personen oder vorausgefüllte Monatsdaten.
@@ -392,18 +431,14 @@ def seed_data(include_example_entries: bool = False) -> None:
"Person 1", "Person 1",
"Person 2", "Person 2",
} }
for category in gemeinschaft.categories:
if category.slug not in ACCOUNT_TREE["gemeinschaftskonto"]["categories"]:
category.is_active = False
elif category.community_account_id is None:
category.community_account_id = community_accounts["hauptkonto"].id
else: else:
_deactivate_placeholder_categories() _restore_existing_standard_structure()
_deactivate_placeholder_entries() _restore_existing_budget_visibility()
for category in gemeinschaft.categories:
if category.slug not in ACCOUNT_TREE["gemeinschaftskonto"]["categories"]:
category.is_active = False
elif category.community_account_id is None:
category.community_account_id = community_accounts["hauptkonto"].id
if not include_example_entries:
_deactivate_placeholder_categories()
_deactivate_placeholder_entries()
db.session.commit() db.session.commit()
+48 -2
View File
@@ -717,11 +717,15 @@ h2 { font-size: 1.2rem; margin-bottom: 0; }
.bottom-nav { .bottom-nav {
position: fixed; position: fixed;
inset: auto 12px 12px 12px; left: max(8px, env(safe-area-inset-left));
right: max(8px, env(safe-area-inset-right));
bottom: calc(8px + env(safe-area-inset-bottom));
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 8px; gap: 8px;
padding: 10px; padding: 10px;
max-width: 540px;
margin: 0 auto;
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 24px; border-radius: 24px;
@@ -733,10 +737,18 @@ h2 { font-size: 1.2rem; margin-bottom: 0; }
display: grid; display: grid;
justify-items: center; justify-items: center;
gap: 6px; gap: 6px;
min-width: 0;
text-align: center; text-align: center;
font-size: 0.82rem; font-size: 0.76rem;
line-height: 1.1;
padding: 10px 6px; padding: 10px 6px;
border-radius: 16px; border-radius: 16px;
overflow-wrap: anywhere;
}
.bottom-nav a span {
display: inline-block;
max-width: 100%;
} }
.mobile-theme-toggle { .mobile-theme-toggle {
@@ -784,6 +796,40 @@ h2 { font-size: 1.2rem; margin-bottom: 0; }
} }
} }
@media (max-width: 560px) {
.content {
padding: 18px 14px 104px;
}
.bottom-nav {
gap: 4px;
padding: 6px;
border-radius: 20px;
}
.bottom-nav a {
gap: 4px;
font-size: 0.56rem;
line-height: 1.05;
padding: 7px 2px;
}
.bottom-nav a span {
display: block;
max-width: 100%;
min-height: 2.2em;
text-wrap: balance;
overflow-wrap: normal;
word-break: break-word;
hyphens: auto;
}
.bottom-nav a .ui-icon {
width: 18px;
height: 18px;
}
}
.planning-hero-strong { .planning-hero-strong {
padding-bottom: 6px; padding-bottom: 6px;
} }
+6 -6
View File
@@ -76,14 +76,14 @@
</div> </div>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<nav class="bottom-nav"> <nav class="bottom-nav">
<a href="{{ url_for('main.index') }}"><img src="{{ url_for('static', filename='icons/house.svg') }}" alt="" class="ui-icon">Übersicht</a> <a href="{{ url_for('main.index') }}" aria-label="Übersicht" title="Übersicht"><img src="{{ url_for('static', filename='icons/house.svg') }}" alt="" class="ui-icon"><span>Übersicht</span></a>
<a href="{{ url_for('planning.current') }}"><img src="{{ url_for('static', filename='icons/wallet.svg') }}" alt="" class="ui-icon">Planung</a> <a href="{{ url_for('planning.current') }}" aria-label="Planung" title="Planung"><img src="{{ url_for('static', filename='icons/wallet.svg') }}" alt="" class="ui-icon"><span>Planung</span></a>
<a href="{{ url_for('months.index') }}"><img src="{{ url_for('static', filename='icons/calendar.svg') }}" alt="" class="ui-icon">Monate</a> <a href="{{ url_for('months.index') }}" aria-label="Monate" title="Monate"><img src="{{ url_for('static', filename='icons/calendar.svg') }}" alt="" class="ui-icon"><span>Monate</span></a>
<a href="{{ url_for('main.analytics') }}"><img src="{{ url_for('static', filename='icons/chart-simple.svg') }}" alt="" class="ui-icon">Auswertungen</a> <a href="{{ url_for('main.analytics') }}" aria-label="Auswertungen" title="Auswertungen"><img src="{{ url_for('static', filename='icons/chart-simple.svg') }}" alt="" class="ui-icon"><span>Auswertungen</span></a>
{% if current_user.is_admin() %} {% if current_user.is_admin() %}
<a href="{{ url_for('admin.index') }}"><img src="{{ url_for('static', filename='icons/sliders.svg') }}" alt="" class="ui-icon">Optionen</a> <a href="{{ url_for('admin.index') }}" aria-label="Optionen" title="Optionen"><img src="{{ url_for('static', filename='icons/sliders.svg') }}" alt="" class="ui-icon"><span>Optionen</span></a>
{% else %} {% else %}
<a href="#" data-enable-push><img src="{{ url_for('static', filename='icons/bell.svg') }}" alt="" class="ui-icon">Push</a> <a href="#" data-enable-push aria-label="Push" title="Push"><img src="{{ url_for('static', filename='icons/bell.svg') }}" alt="" class="ui-icon"><span>Push</span></a>
{% endif %} {% endif %}
</nav> </nav>
{% endif %} {% endif %}
+31 -4
View File
@@ -226,10 +226,25 @@
<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="button" data-open-dialog="confirm-delete-income-{{ income.id }}">Löschen</button>
{% endif %} {% endif %}
</div> </div>
</form> </form>
{% if month.incomes|length > 1 %}
<dialog id="confirm-delete-income-{{ income.id }}" class="app-dialog confirm-dialog">
<form method="dialog" class="dialog-close-row">
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
</form>
<div class="stack-form">
<h3>Einkommenszeile wirklich löschen?</h3>
<p class="muted">`{{ income.label }}` wird aus diesem Monat entfernt.</p>
<form method="post" action="{{ url_for('planning.delete_income', label=month.label, income_id=income.id) }}" class="dialog-action-row dialog-action-spread">
<button class="ghost-button" type="button" data-open-dialog="income-dialog">Zurück</button>
<button class="primary-button danger-fill-button" type="submit">Jetzt löschen</button>
</form>
</div>
</dialog>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
<form method="post" action="{{ url_for('planning.create_income', label=month.label) }}" class="soft-form-section stack-form"> <form method="post" action="{{ url_for('planning.create_income', label=month.label) }}" class="soft-form-section stack-form">
@@ -455,7 +470,7 @@
<small>{{ category_data.total|currency }} · {{ category_data.entry_count }} Einträge</small> <small>{{ category_data.total|currency }} · {{ category_data.entry_count }} Einträge</small>
</div> </div>
{% if category_data.allow_new_entries %} {% if category_data.allow_new_entries %}
<button class="ghost-button icon-button" type="button" data-open-dialog="dialog-add-entry" data-account-id="{{ category_data.category.account_id }}" data-category-name="{{ category_data.category.name }}" data-return-dialog="{{ category_data.dialog_id }}" data-area="{{ 'budget' if category_data.category.account.slug == 'gemeinschaftskonto' else 'distribution' }}"> <button class="ghost-button icon-button" type="button" data-open-dialog="dialog-add-entry" data-account-id="{{ category_data.category.account_id }}" data-category-name="{{ category_data.category.name }}" data-return-dialog="{{ category_data.dialog_id }}" data-area="{{ 'budget' if account_data.account.slug == 'gemeinschaftskonto' else 'distribution' }}">
<img src="{{ url_for('static', filename='icons/plus.svg') }}" alt="" class="ui-icon small-ui-icon"> <img src="{{ url_for('static', filename='icons/plus.svg') }}" alt="" class="ui-icon small-ui-icon">
Eintrag Eintrag
</button> </button>
@@ -598,8 +613,20 @@
{% endfor %} {% endfor %}
</div> </div>
<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="button" data-open-dialog="confirm-delete-category-{{ category_data.category.id }}">Kategorie löschen</button>
<button class="ghost-button danger-button" type="submit">Kategorie löschen</button> </div>
</dialog>
<dialog id="confirm-delete-category-{{ category_data.category.id }}" class="app-dialog confirm-dialog">
<form method="dialog" class="dialog-close-row">
<button class="dialog-close-button" type="submit" aria-label="Schließen">×</button>
</form>
<div class="stack-form">
<h3>Kategorie wirklich löschen?</h3>
<p class="muted">`{{ category_data.category.name }}` wird ausgeblendet und ihre Einträge werden mit ausgeblendet.</p>
<form method="post" action="{{ url_for('planning.delete_category', label=month.label, category_id=category_data.category.id) }}" class="dialog-action-row dialog-action-spread">
<button class="ghost-button" type="button" data-open-dialog="{{ category_data.dialog_id }}">Zurück</button>
<button class="primary-button danger-fill-button" type="submit">Jetzt löschen</button>
</form> </form>
</div> </div>
</dialog> </dialog>
+77
View File
@@ -101,6 +101,19 @@ def test_distribution_dialog_shows_direct_budget_form(logged_in_client):
assert b"Sparkonto" in response.data assert b"Sparkonto" in response.data
def test_distribution_dialog_keeps_direct_budget_form_when_target_flag_is_missing(logged_in_client):
month = Month.query.filter_by(label="2026-04").first()
sparen_value = next(item for item in month.entry_values if item.entry.name == "Sparziel")
sparen_value.entry.is_allocation_target = False
db.session.commit()
response = logged_in_client.get("/planning/2026-04")
assert response.status_code == 200
assert b"Budget direkt anpassen" in response.data
assert b"Monatliches Budget" in response.data
def test_planning_shows_budgets_and_community_accounts(logged_in_client): def test_planning_shows_budgets_and_community_accounts(logged_in_client):
response = logged_in_client.get("/planning/2026-04") response = logged_in_client.get("/planning/2026-04")
@@ -111,6 +124,17 @@ 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_app_confirmation_dialogs(logged_in_client):
response = logged_in_client.get("/planning/2026-04")
assert response.status_code == 200
assert b"confirm-delete-income-" in response.data
assert b"confirm-delete-category-" in response.data
assert b"confirm-delete-community-account-" in response.data
assert b"confirm-delete-entry-" in response.data
assert b"data-confirm-submit" not 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 +154,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(
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from app.extensions import db
from app.models import Account, Category, Entry
from app.seed import seed_data
def test_seed_restores_hidden_budget_categories_with_active_entries(app):
budget_account = Account.query.filter_by(slug="gemeinschaftskonto").first()
category = Category(
account_id=budget_account.id,
name="Legacy Budget",
slug="legacy-budget",
is_active=False,
sort_order=999,
)
db.session.add(category)
db.session.flush()
db.session.add(
Entry(
category_id=category.id,
name="Legacy Eintrag",
slug="legacy-eintrag",
default_amount=0,
amount_type="fixed",
benefit_scope="all-users",
is_active=True,
sort_order=1,
)
)
db.session.commit()
seed_data()
db.session.expire_all()
restored = Category.query.filter_by(account_id=budget_account.id, slug="legacy-budget").first()
assert restored is not None
assert restored.is_active is True