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() 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: class Config:
APP_NAME = "Saldo" APP_NAME = "Saldo"
SECRET_KEY = os.getenv("SECRET_KEY") or secrets.token_hex(32)
DATA_DIR = _default_data_dir() DATA_DIR = _default_data_dir()
SECRET_KEY = _secret_key(DATA_DIR)
AVATAR_UPLOAD_DIR = DATA_DIR / "avatars" AVATAR_UPLOAD_DIR = DATA_DIR / "avatars"
SQLALCHEMY_DATABASE_URI = os.getenv( SQLALCHEMY_DATABASE_URI = os.getenv(
"DATABASE_URL", "DATABASE_URL",
+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,
+124 -29
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 {
@@ -266,7 +280,11 @@ def detail(label: str):
) )
month_values = {item.entry_id: item for item in month.entry_values} month_values = {item.entry_id: item for item in month.entry_values}
distribution_bucket = { distribution_bucket = {
"account": None, "account": type(
"SyntheticAccount",
(),
{"name": "Sparen & Verteilung", "slug": "sparen-und-verteilung"},
)(),
"categories": [], "categories": [],
"total": Decimal("0.00"), "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_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,
), ),
@@ -337,12 +357,8 @@ def detail(label: str):
} }
) )
if account.slug in {"sparen", "urlaub", "freizeit"}: if account.slug in {"sparen", "urlaub", "freizeit"}:
if distribution_bucket["account"] is None: flattened_entries = [entry for category_card in category_cards for entry in category_card["entries"]]
distribution_bucket["account"] = type( direct_entry = _resolve_distribution_direct_entry(flattened_entries)
"SyntheticAccount",
(),
{"name": "Sparen & Verteilung", "slug": "sparen-und-verteilung"},
)()
distribution_bucket["categories"].append( distribution_bucket["categories"].append(
{ {
"category": type( "category": type(
@@ -350,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}",
@@ -358,26 +374,13 @@ 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
continue continue
if account.slug in {"persoenlich-flo", "persoenlich-desi"}: 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( personal_category = next(
(item for item in distribution_bucket["categories"] if item.get("is_personal_split")), (item for item in distribution_bucket["categories"] if item.get("is_personal_split")),
None, None,
@@ -412,7 +415,6 @@ def detail(label: str):
planning_accounts.append( planning_accounts.append(
{"account": account, "categories": category_cards, "total": account_total} {"account": account, "categories": category_cards, "total": account_total}
) )
if distribution_bucket["account"] is not None:
personal_category = next( personal_category = next(
(item for item in distribution_bucket["categories"] if item.get("is_personal_split")), (item for item in distribution_bucket["categories"] if item.get("is_personal_split")),
None, None,
@@ -683,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)
@@ -704,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,
) )
@@ -774,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)
@@ -874,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)
@@ -889,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,
+107 -2
View File
@@ -13,6 +13,7 @@ from app.models import (
MonthlyIncome, MonthlyIncome,
NotificationPreference, NotificationPreference,
User, User,
to_decimal,
) )
@@ -130,6 +131,19 @@ ENTRY_TARGET_CATEGORY = {
"Kreditrate 2": "finanzen", "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: def slugify(value: str) -> str:
return ( 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, # Basisdaten nur für die fachliche Grundstruktur, ohne Demo-Benutzer,
# Beispiel-Personen oder vorausgefüllte Monatsdaten. # Beispiel-Personen oder vorausgefüllte Monatsdaten.
community_accounts = {} community_accounts = {}
@@ -196,6 +295,8 @@ def seed_data() -> None:
sort_order += 1 sort_order += 1
category_sort = 1 category_sort = 1
account_categories[account_slug] = {} account_categories[account_slug] = {}
if not include_example_entries:
continue
for category_slug, entries in account_data["categories"].items(): for category_slug, entries in account_data["categories"].items():
category = Category.query.filter_by(account_id=account.id, slug=category_slug).first() category = Category.query.filter_by(account_id=account.id, slug=category_slug).first()
legacy_slug = LEGACY_CATEGORY_SLUGS.get((account_slug, category_slug)) legacy_slug = LEGACY_CATEGORY_SLUGS.get((account_slug, category_slug))
@@ -293,6 +394,7 @@ def seed_data() -> None:
gemeinschaft = Account.query.filter_by(slug="gemeinschaftskonto").first() gemeinschaft = Account.query.filter_by(slug="gemeinschaftskonto").first()
if gemeinschaft: if gemeinschaft:
if include_example_entries:
target_categories = account_categories["gemeinschaftskonto"] target_categories = account_categories["gemeinschaftskonto"]
with db.session.no_autoflush: with db.session.no_autoflush:
for category in gemeinschaft.categories: for category in gemeinschaft.categories:
@@ -334,6 +436,9 @@ def seed_data() -> None:
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:
_restore_existing_standard_structure()
_restore_existing_budget_visibility()
db.session.commit() db.session.commit()
@@ -342,7 +447,7 @@ def seed_demo_data() -> None:
from datetime import date from datetime import date
from flask import current_app from flask import current_app
seed_data() seed_data(include_example_entries=True)
admin = User.query.filter_by(username="admin").first() admin = User.query.filter_by(username="admin").first()
if admin is None: if admin is None:
+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 %}
+45 -8
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">
@@ -117,7 +117,6 @@
<section class="account-board"> <section class="account-board">
{% for account_data in planning_accounts %} {% for account_data in planning_accounts %}
{% if account_data.categories %}
<article class="panel account-panel premium-panel"> <article class="panel account-panel premium-panel">
<div class="panel-head account-head"> <div class="panel-head account-head">
<div> <div>
@@ -146,6 +145,7 @@
</button> </button>
{% endif %} {% endif %}
</div> </div>
{% if account_data.categories %}
<div class="category-summary-grid"> <div class="category-summary-grid">
{% for category_data in account_data.categories %} {% 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 }}"> <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> </button>
{% endfor %} {% endfor %}
</div> </div>
</article> {% 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 %} {% endif %}
</div>
{% endif %}
</article>
{% endfor %} {% endfor %}
</section> </section>
@@ -216,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">
@@ -445,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>
@@ -588,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