Compare commits

...

13 Commits

11 changed files with 578 additions and 146 deletions
+16 -1
View File
@@ -10,10 +10,25 @@ def _default_data_dir() -> Path:
return Path(os.getenv("SALDO_DATA_DIR", Path.cwd() / "instance")).resolve()
def _secret_key(data_dir: Path) -> str:
configured = os.getenv("SECRET_KEY")
if configured:
return configured
secret_file = data_dir / ".secret_key"
if secret_file.exists():
return secret_file.read_text(encoding="utf-8").strip()
data_dir.mkdir(parents=True, exist_ok=True)
generated = secrets.token_hex(32)
secret_file.write_text(generated, encoding="utf-8")
return generated
class Config:
APP_NAME = "Saldo"
SECRET_KEY = os.getenv("SECRET_KEY") or secrets.token_hex(32)
DATA_DIR = _default_data_dir()
SECRET_KEY = _secret_key(DATA_DIR)
AVATAR_UPLOAD_DIR = DATA_DIR / "avatars"
SQLALCHEMY_DATABASE_URI = os.getenv(
"DATABASE_URL",
+7
View File
@@ -26,6 +26,7 @@ main_bp = Blueprint("main", __name__)
def _community_account_cards(month, previous_month):
personal_label_map = personal_account_names()
community_accounts = CommunityAccount.query.filter_by(is_active=True).order_by(
CommunityAccount.sort_order.asc(), CommunityAccount.name.asc()
).all()
@@ -73,9 +74,15 @@ def _community_account_cards(month, previous_month):
)
assigned_budget_names = [category.name for category in assigned_categories]
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(
{
"community_account": community_account,
"display_name": display_name,
"current_total": current_total,
"previous_total": previous_total,
"delta": delta,
+155 -60
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):
personal_label_map = personal_account_names()
current_entry_values = {item.entry_id: to_decimal(item.planned_amount) for item in month.entry_values}
previous_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]
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(
{
"community_account": community_account,
"display_name": display_name,
"current_total": current_total,
"previous_total": previous_total,
"delta": delta,
@@ -170,6 +177,13 @@ def _community_account_totals(month, previous_month, community_accounts, budget_
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]:
result = {}
return {
@@ -266,7 +280,11 @@ def detail(label: str):
)
month_values = {item.entry_id: item for item in month.entry_values}
distribution_bucket = {
"account": None,
"account": type(
"SyntheticAccount",
(),
{"name": "Sparen & Verteilung", "slug": "sparen-und-verteilung"},
)(),
"categories": [],
"total": Decimal("0.00"),
}
@@ -329,7 +347,9 @@ def detail(label: str):
"distribution_kind": "single" if account.slug in {"sparen", "urlaub", "freizeit"} else None,
"distribution_suggestion_total": suggestion_total,
"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),
None,
),
@@ -337,12 +357,8 @@ def detail(label: str):
}
)
if account.slug in {"sparen", "urlaub", "freizeit"}:
if distribution_bucket["account"] is None:
distribution_bucket["account"] = type(
"SyntheticAccount",
(),
{"name": "Sparen & Verteilung", "slug": "sparen-und-verteilung"},
)()
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(
{
"category": type(
@@ -350,7 +366,7 @@ def detail(label: str):
(),
{"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,
"entry_count": sum(category_card["entry_count"] for category_card in category_cards),
"dialog_id": f"distribution-dialog-{account.slug}",
@@ -358,26 +374,13 @@ def detail(label: str):
"distribution_suggestion_total": distribution_hints.get(account.slug, {}).get("remaining_amount", Decimal("0.00")),
"distribution_hint": distribution_hints.get(account.slug),
"distribution_account_slug": account.slug,
"direct_entry": next(
(
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,
"direct_entry": direct_entry,
"allow_new_entries": direct_entry is None,
}
)
distribution_bucket["total"] += account_total
continue
if account.slug in {"persoenlich-flo", "persoenlich-desi"}:
if distribution_bucket["account"] is None:
distribution_bucket["account"] = type(
"SyntheticAccount",
(),
{"name": "Sparen & Verteilung", "slug": "sparen-und-verteilung"},
)()
personal_category = next(
(item for item in distribution_bucket["categories"] if item.get("is_personal_split")),
None,
@@ -412,32 +415,31 @@ def detail(label: str):
planning_accounts.append(
{"account": account, "categories": category_cards, "total": account_total}
)
if distribution_bucket["account"] is not None:
personal_category = next(
(item for item in distribution_bucket["categories"] if item.get("is_personal_split")),
None,
personal_category = next(
(item for item in distribution_bucket["categories"] if item.get("is_personal_split")),
None,
)
if personal_category is not None:
personal_category["distribution_suggestion_total"] = max(
-summary.allocation_total,
summary.remainder,
)
if personal_category is not None:
personal_category["distribution_suggestion_total"] = max(
-summary.allocation_total,
summary.remainder,
)
personal_category["distribution_items"] = [
{
"slug": slug,
"label": personal_label_map.get(slug, slug),
"allocation": allocations_by_slug.get(slug),
"suggestion": suggestions_by_slug.get(slug),
"remaining_amount": max(
Decimal("0.00"),
to_decimal(suggestions_by_slug.get(slug).suggested_amount if suggestions_by_slug.get(slug) else 0)
- to_decimal(allocations_by_slug.get(slug).amount if allocations_by_slug.get(slug) else 0),
),
"auto_amount": to_decimal(allocations_by_slug.get(slug).amount if allocations_by_slug.get(slug) else 0),
}
for slug in ("persoenlich-flo", "persoenlich-desi")
]
planning_accounts.insert(0, distribution_bucket)
personal_category["distribution_items"] = [
{
"slug": slug,
"label": personal_label_map.get(slug, slug),
"allocation": allocations_by_slug.get(slug),
"suggestion": suggestions_by_slug.get(slug),
"remaining_amount": max(
Decimal("0.00"),
to_decimal(suggestions_by_slug.get(slug).suggested_amount if suggestions_by_slug.get(slug) else 0)
- to_decimal(allocations_by_slug.get(slug).amount if allocations_by_slug.get(slug) else 0),
),
"auto_amount": to_decimal(allocations_by_slug.get(slug).amount if allocations_by_slug.get(slug) else 0),
}
for slug in ("persoenlich-flo", "persoenlich-desi")
]
planning_accounts.insert(0, distribution_bucket)
previous_month = month_service.previous_month(month.year, month.month)
budget_categories = db.session.scalars(
select(Category)
@@ -683,10 +685,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)
@@ -704,7 +720,7 @@ def create_category(label: str):
else None
),
name=name,
slug=slugify(name),
slug=slug,
is_active=True,
sort_order=last_sort + 1,
)
@@ -749,7 +765,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,
@@ -774,14 +790,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)
@@ -874,10 +958,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)
@@ -889,7 +984,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,
+142 -37
View File
@@ -13,6 +13,7 @@ from app.models import (
MonthlyIncome,
NotificationPreference,
User,
to_decimal,
)
@@ -130,6 +131,19 @@ ENTRY_TARGET_CATEGORY = {
"Kreditrate 2": "finanzen",
}
EXAMPLE_ENTRY_NAMES = {
entry_name
for account_data in ACCOUNT_TREE.values()
for entries in account_data["categories"].values()
for entry_name in entries
}
EXAMPLE_CATEGORY_KEYS = {
(account_slug, category_slug)
for account_slug, account_data in ACCOUNT_TREE.items()
for category_slug in account_data["categories"].keys()
}
def slugify(value: str) -> str:
return (
@@ -143,7 +157,92 @@ def slugify(value: str) -> str:
)
def seed_data() -> None:
def _entry_has_user_data(entry: Entry) -> bool:
for value in entry.monthly_values:
if (
to_decimal(value.planned_amount) != Decimal("0.00")
or value.note
or value.created_by is not None
or value.updated_by is not None
):
return True
if entry.share_rules:
return True
return False
def _deactivate_placeholder_entries() -> None:
example_entries = Entry.query.filter(
Entry.name.in_(sorted(EXAMPLE_ENTRY_NAMES)),
Entry.is_active.is_(True),
).all()
for entry in example_entries:
if _entry_has_user_data(entry):
continue
entry.is_active = False
def _deactivate_placeholder_categories() -> None:
categories = (
Category.query.join(Account)
.filter(Category.is_active.is_(True), Account.is_active.is_(True))
.all()
)
for category in categories:
account = category.account
if account is None or (account.slug, category.slug) not in EXAMPLE_CATEGORY_KEYS:
continue
active_entries = [entry for entry in category.entries if entry.is_active]
if not active_entries:
category.is_active = False
category.community_account_id = None
continue
if all(entry.name in EXAMPLE_ENTRY_NAMES and not _entry_has_user_data(entry) for entry in active_entries):
for entry in active_entries:
entry.is_active = False
category.is_active = False
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:
# Basisdaten nur für die fachliche Grundstruktur, ohne Demo-Benutzer,
# Beispiel-Personen oder vorausgefüllte Monatsdaten.
community_accounts = {}
@@ -196,6 +295,8 @@ def seed_data() -> None:
sort_order += 1
category_sort = 1
account_categories[account_slug] = {}
if not include_example_entries:
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))
@@ -293,47 +394,51 @@ def seed_data() -> None:
gemeinschaft = Account.query.filter_by(slug="gemeinschaftskonto").first()
if gemeinschaft:
target_categories = account_categories["gemeinschaftskonto"]
with db.session.no_autoflush:
for category in gemeinschaft.categories:
for entry in list(category.entries):
target_slug = ENTRY_TARGET_CATEGORY.get(entry.name)
if not target_slug:
continue
target_category = target_categories.get(target_slug)
if not target_category:
continue
existing_target_entry = Entry.query.filter_by(
category_id=target_category.id,
slug=entry.slug,
).first()
if existing_target_entry and existing_target_entry.id != entry.id:
for monthly_value in entry.monthly_values:
monthly_value.entry_id = existing_target_entry.id
existing_rule_participants = {
rule.participant_id for rule in existing_target_entry.share_rules
if include_example_entries:
target_categories = account_categories["gemeinschaftskonto"]
with db.session.no_autoflush:
for category in gemeinschaft.categories:
for entry in list(category.entries):
target_slug = ENTRY_TARGET_CATEGORY.get(entry.name)
if not target_slug:
continue
target_category = target_categories.get(target_slug)
if not target_category:
continue
existing_target_entry = Entry.query.filter_by(
category_id=target_category.id,
slug=entry.slug,
).first()
if existing_target_entry and existing_target_entry.id != entry.id:
for monthly_value in entry.monthly_values:
monthly_value.entry_id = existing_target_entry.id
existing_rule_participants = {
rule.participant_id for rule in existing_target_entry.share_rules
}
for rule in list(entry.share_rules):
if rule.participant_id in existing_rule_participants:
db.session.delete(rule)
continue
rule.entry_id = existing_target_entry.id
db.session.delete(entry)
continue
entry.category_id = target_category.id
entry.benefit_scope = "all-users"
entry.is_allocation_target = entry.name in {
"Sparziel",
"Reisebudget",
"Freizeitbudget",
"Person 1",
"Person 2",
}
for rule in list(entry.share_rules):
if rule.participant_id in existing_rule_participants:
db.session.delete(rule)
continue
rule.entry_id = existing_target_entry.id
db.session.delete(entry)
continue
entry.category_id = target_category.id
entry.benefit_scope = "all-users"
entry.is_allocation_target = entry.name in {
"Sparziel",
"Reisebudget",
"Freizeitbudget",
"Person 1",
"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:
_restore_existing_standard_structure()
_restore_existing_budget_visibility()
db.session.commit()
@@ -342,7 +447,7 @@ def seed_demo_data() -> None:
from datetime import date
from flask import current_app
seed_data()
seed_data(include_example_entries=True)
admin = User.query.filter_by(username="admin").first()
if admin is None:
+48 -2
View File
@@ -717,11 +717,15 @@ h2 { font-size: 1.2rem; margin-bottom: 0; }
.bottom-nav {
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;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
padding: 10px;
max-width: 540px;
margin: 0 auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 24px;
@@ -733,10 +737,18 @@ h2 { font-size: 1.2rem; margin-bottom: 0; }
display: grid;
justify-items: center;
gap: 6px;
min-width: 0;
text-align: center;
font-size: 0.82rem;
font-size: 0.76rem;
line-height: 1.1;
padding: 10px 6px;
border-radius: 16px;
overflow-wrap: anywhere;
}
.bottom-nav a span {
display: inline-block;
max-width: 100%;
}
.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 {
padding-bottom: 6px;
}
+6 -6
View File
@@ -76,14 +76,14 @@
</div>
{% if current_user.is_authenticated %}
<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('planning.current') }}"><img src="{{ url_for('static', filename='icons/wallet.svg') }}" alt="" class="ui-icon">Planung</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('main.analytics') }}"><img src="{{ url_for('static', filename='icons/chart-simple.svg') }}" alt="" class="ui-icon">Auswertungen</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') }}" 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') }}" 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') }}" 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() %}
<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 %}
<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 %}
</nav>
{% endif %}
+73 -36
View File
@@ -71,7 +71,7 @@
{% if card.is_read_only %}
<div class="summary-category-card summary-static-card community-account-card">
<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>
</div>
<div class="summary-card-meta">
@@ -89,7 +89,7 @@
{% else %}
<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">
<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">
</div>
<div class="summary-card-meta">
@@ -117,35 +117,35 @@
<section class="account-board">
{% for account_data in planning_accounts %}
{% if account_data.categories %}
<article class="panel account-panel premium-panel">
<div class="panel-head account-head">
<div>
<h2>{{ account_data.account.name }}</h2>
<small>
Gesamtkosten {{ account_data.total|currency }}
{% if account_data.account.slug == "gemeinschaftskonto" %}
· Jährlich {{ (account_data.total * 12)|currency }}
{% endif %}
</small>
</div>
{% if account_data.account.slug == "sparen-und-verteilung" %}
<button class="ghost-button icon-button" type="button" data-open-dialog="dialog-add-category" data-area="distribution" data-placeholder="Name Sparkonto" data-dialog-label="Sparkonto">
<img src="{{ url_for('static', filename='icons/plus.svg') }}" alt="" class="ui-icon small-ui-icon">
Sparkonto
</button>
{% elif account_data.account.slug == "gemeinschaftskonto" %}
<button class="ghost-button icon-button" type="button" data-open-dialog="dialog-add-category" data-area="budget" data-placeholder="Name Budget">
<img src="{{ url_for('static', filename='icons/plus.svg') }}" alt="" class="ui-icon small-ui-icon">
Kategorie
</button>
{% else %}
<button class="ghost-button icon-button" type="button" data-open-dialog="dialog-add-category" data-account-id="{{ account_data.account.id }}">
<img src="{{ url_for('static', filename='icons/plus.svg') }}" alt="" class="ui-icon small-ui-icon">
Kategorie
</button>
{% endif %}
<article class="panel account-panel premium-panel">
<div class="panel-head account-head">
<div>
<h2>{{ account_data.account.name }}</h2>
<small>
Gesamtkosten {{ account_data.total|currency }}
{% if account_data.account.slug == "gemeinschaftskonto" %}
· Jährlich {{ (account_data.total * 12)|currency }}
{% endif %}
</small>
</div>
{% if account_data.account.slug == "sparen-und-verteilung" %}
<button class="ghost-button icon-button" type="button" data-open-dialog="dialog-add-category" data-area="distribution" data-placeholder="Name Sparkonto" data-dialog-label="Sparkonto">
<img src="{{ url_for('static', filename='icons/plus.svg') }}" alt="" class="ui-icon small-ui-icon">
Sparkonto
</button>
{% elif account_data.account.slug == "gemeinschaftskonto" %}
<button class="ghost-button icon-button" type="button" data-open-dialog="dialog-add-category" data-area="budget" data-placeholder="Name Budget">
<img src="{{ url_for('static', filename='icons/plus.svg') }}" alt="" class="ui-icon small-ui-icon">
Kategorie
</button>
{% else %}
<button class="ghost-button icon-button" type="button" data-open-dialog="dialog-add-category" data-account-id="{{ account_data.account.id }}">
<img src="{{ url_for('static', filename='icons/plus.svg') }}" alt="" class="ui-icon small-ui-icon">
Kategorie
</button>
{% endif %}
</div>
{% if account_data.categories %}
<div class="category-summary-grid">
{% for category_data in account_data.categories %}
<button type="button" class="summary-category-card {% if category_data.distribution_hint and category_data.distribution_hint.status %}range-status-{{ category_data.distribution_hint.status }}{% endif %}" data-open-dialog="{{ category_data.dialog_id }}">
@@ -184,8 +184,18 @@
</button>
{% endfor %}
</div>
</article>
{% endif %}
{% else %}
<div class="empty-state">
{% if account_data.account.slug == "gemeinschaftskonto" %}
Noch keine Budgets angelegt. Lege die erste Budget-Kategorie direkt hier an.
{% elif account_data.account.slug == "sparen-und-verteilung" %}
Noch keine Sparkonten angelegt. Lege Sparen, Urlaub oder weitere Verteilungsziele erst bei Bedarf an.
{% else %}
In diesem Bereich gibt es noch keine Kategorien.
{% endif %}
</div>
{% endif %}
</article>
{% endfor %}
</section>
@@ -216,10 +226,25 @@
<div class="dialog-action-row dialog-action-spread">
<button class="ghost-button small-button" type="submit">Speichern</button>
{% 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 %}
</div>
</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 %}
</div>
<form method="post" action="{{ url_for('planning.create_income', label=month.label) }}" class="soft-form-section stack-form">
@@ -445,7 +470,7 @@
<small>{{ category_data.total|currency }} · {{ category_data.entry_count }} Einträge</small>
</div>
{% 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">
Eintrag
</button>
@@ -588,8 +613,20 @@
{% endfor %}
</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="submit">Kategorie löschen</button>
<button class="ghost-button danger-button" type="button" data-open-dialog="confirm-delete-category-{{ category_data.category.id }}">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>
</div>
</dialog>
+2 -1
View File
@@ -34,7 +34,8 @@ def active_users() -> list[User]:
def personal_users() -> list[User]:
users = active_users()
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]:
+12 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from decimal import Decimal
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
@@ -169,3 +169,14 @@ def test_seeded_distribution_entries_are_marked_as_allocation_targets(app):
assert target_entries["Freizeitbudget"] is True
assert target_entries["Person 1"] 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
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):
response = logged_in_client.get("/planning/2026-04")
assert response.status_code == 200
assert b"Budgets" in response.data
assert b"Gemeinschaftskonten" in response.data
assert b"Privatkonto 1" in response.data
assert b"Privatkonto 2" in response.data
assert b"Auszahlung Person A" 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):
@@ -130,6 +154,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(
+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