Compare commits
13 Commits
6f5e704739
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cd85cc5ae | |||
| 487912a1d6 | |||
| 8fd91f1857 | |||
| 53427e0b4d | |||
| 6ba568ea68 | |||
| d5a2721c5f | |||
| 1667eb5d26 | |||
| 26bfa7fb64 | |||
| 5b752ab7c4 | |||
| e0dadc4b20 | |||
| 8bf37c82b6 | |||
| 35af65dd25 | |||
| b9212d9c41 |
+16
-1
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
@@ -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]:
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user