Compare commits

..

10 Commits

10 changed files with 397 additions and 51 deletions
+7
View File
@@ -26,6 +26,7 @@ main_bp = Blueprint("main", __name__)
def _community_account_cards(month, previous_month): def _community_account_cards(month, previous_month):
personal_label_map = personal_account_names()
community_accounts = CommunityAccount.query.filter_by(is_active=True).order_by( community_accounts = CommunityAccount.query.filter_by(is_active=True).order_by(
CommunityAccount.sort_order.asc(), CommunityAccount.name.asc() CommunityAccount.sort_order.asc(), CommunityAccount.name.asc()
).all() ).all()
@@ -73,9 +74,15 @@ def _community_account_cards(month, previous_month):
) )
assigned_budget_names = [category.name for category in assigned_categories] assigned_budget_names = [category.name for category in assigned_categories]
delta = current_total - previous_total delta = current_total - previous_total
display_name = (
f"Auszahlung {personal_label_map.get(community_account.linked_account_slug, community_account.name)}"
if community_account.account_type == "personal" and community_account.linked_account_slug
else community_account.name
)
cards.append( cards.append(
{ {
"community_account": community_account, "community_account": community_account,
"display_name": display_name,
"current_total": current_total, "current_total": current_total,
"previous_total": previous_total, "previous_total": previous_total,
"delta": delta, "delta": delta,
+119 -15
View File
@@ -102,6 +102,7 @@ def _resolve_avatar_url(existing: str | None = None) -> str | None:
def _community_account_totals(month, previous_month, community_accounts, budget_categories): def _community_account_totals(month, previous_month, community_accounts, budget_categories):
personal_label_map = personal_account_names()
current_entry_values = {item.entry_id: to_decimal(item.planned_amount) for item in month.entry_values} current_entry_values = {item.entry_id: to_decimal(item.planned_amount) for item in month.entry_values}
previous_entry_values = ( previous_entry_values = (
{item.entry_id: to_decimal(item.planned_amount) for item in previous_month.entry_values} {item.entry_id: to_decimal(item.planned_amount) for item in previous_month.entry_values}
@@ -157,9 +158,15 @@ def _community_account_totals(month, previous_month, community_accounts, budget_
) )
assigned_budget_names = [category.name for category in assigned_categories] assigned_budget_names = [category.name for category in assigned_categories]
delta = current_total - previous_total delta = current_total - previous_total
display_name = (
f"Auszahlung {personal_label_map.get(community_account.linked_account_slug, community_account.name)}"
if community_account.account_type == "personal" and community_account.linked_account_slug
else community_account.name
)
cards.append( cards.append(
{ {
"community_account": community_account, "community_account": community_account,
"display_name": display_name,
"current_total": current_total, "current_total": current_total,
"previous_total": previous_total, "previous_total": previous_total,
"delta": delta, "delta": delta,
@@ -170,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 {
@@ -333,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,
), ),
@@ -341,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(
@@ -348,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}",
@@ -356,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
@@ -674,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:
if existing.is_active:
flash("Kategorie existiert bereits.", "info") flash("Kategorie existiert bereits.", "info")
return redirect(url_for("planning.detail", label=label)) 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)
@@ -695,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,
) )
@@ -765,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:
if existing.is_active:
flash("Eintrag existiert bereits.", "info") flash("Eintrag existiert bereits.", "info")
return redirect(url_for("planning.detail", label=label)) 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)
@@ -865,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:
if existing.is_active:
flash("Gemeinschaftskonto existiert bereits.", "info") flash("Gemeinschaftskonto existiert bereits.", "info")
return _dialog_redirect(label, "community-account-create-dialog") 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)
@@ -880,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,
+42 -7
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",
} }
else:
_deactivate_placeholder_categories()
_deactivate_placeholder_entries()
for category in gemeinschaft.categories: for category in gemeinschaft.categories:
if category.slug not in ACCOUNT_TREE["gemeinschaftskonto"]["categories"]: if category.slug not in ACCOUNT_TREE["gemeinschaftskonto"]["categories"]:
category.is_active = False category.is_active = False
elif category.community_account_id is None: elif category.community_account_id is None:
category.community_account_id = community_accounts["hauptkonto"].id category.community_account_id = community_accounts["hauptkonto"].id
else:
if not include_example_entries: _restore_existing_standard_structure()
_deactivate_placeholder_categories() _restore_existing_budget_visibility()
_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 %}
+33 -6
View File
@@ -71,7 +71,7 @@
{% if card.is_read_only %} {% if card.is_read_only %}
<div class="summary-category-card summary-static-card community-account-card"> <div class="summary-category-card summary-static-card community-account-card">
<div class="summary-card-head"> <div class="summary-card-head">
<strong>{{ card.community_account.name }}</strong> <strong>{{ card.display_name }}</strong>
<span class="icon-label muted-label">Nur Anzeige</span> <span class="icon-label muted-label">Nur Anzeige</span>
</div> </div>
<div class="summary-card-meta"> <div class="summary-card-meta">
@@ -89,7 +89,7 @@
{% else %} {% else %}
<button type="button" class="summary-category-card community-account-card" data-open-dialog="community-account-item-{{ card.community_account.id }}"> <button type="button" class="summary-category-card community-account-card" data-open-dialog="community-account-item-{{ card.community_account.id }}">
<div class="summary-card-head"> <div class="summary-card-head">
<strong>{{ card.community_account.name }}</strong> <strong>{{ card.display_name }}</strong>
<img src="{{ url_for('static', filename='icons/pencil.svg') }}" alt="" class="ui-icon small-ui-icon"> <img src="{{ url_for('static', filename='icons/pencil.svg') }}" alt="" class="ui-icon small-ui-icon">
</div> </div>
<div class="summary-card-meta"> <div class="summary-card-meta">
@@ -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>
+2 -1
View File
@@ -34,7 +34,8 @@ def active_users() -> list[User]:
def personal_users() -> list[User]: def personal_users() -> list[User]:
users = active_users() users = active_users()
non_admin = [user for user in users if not user.is_admin()] non_admin = [user for user in users if not user.is_admin()]
return (non_admin or users)[:2] admin_users = [user for user in users if user.is_admin()]
return (non_admin + admin_users)[:2]
def personal_account_names() -> dict[str, str]: def personal_account_names() -> dict[str, str]:
+12 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from decimal import Decimal from decimal import Decimal
from app.extensions import db from app.extensions import db
from app.models import Account, AllocationSuggestion, Month from app.models import Account, AllocationSuggestion, Month, User
from app.utils.users import personal_account_names from app.utils.users import personal_account_names
@@ -169,3 +169,14 @@ def test_seeded_distribution_entries_are_marked_as_allocation_targets(app):
assert target_entries["Freizeitbudget"] is True assert target_entries["Freizeitbudget"] is True
assert target_entries["Person 1"] is True assert target_entries["Person 1"] is True
assert target_entries["Person 2"] is True assert target_entries["Person 2"] is True
def test_personal_account_names_fill_with_admin_if_only_one_editor_is_active(app):
editor_b = User.query.filter_by(username="mitglied2").first()
editor_b.is_active = False
db.session.commit()
personal_labels = personal_account_names()
assert personal_labels["persoenlich-flo"] == "Person A"
assert personal_labels["persoenlich-desi"] == "Admin"
+79 -2
View File
@@ -101,14 +101,38 @@ 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")
assert response.status_code == 200 assert response.status_code == 200
assert b"Budgets" in response.data assert b"Budgets" in response.data
assert b"Gemeinschaftskonten" in response.data assert b"Gemeinschaftskonten" in response.data
assert b"Privatkonto 1" in response.data assert b"Auszahlung Person A" in response.data
assert b"Privatkonto 2" 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):
@@ -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