Compare commits

...

3 Commits

4 changed files with 197 additions and 111 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",
+29 -38
View File
@@ -266,7 +266,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"),
} }
@@ -337,12 +341,6 @@ def detail(label: str):
} }
) )
if account.slug in {"sparen", "urlaub", "freizeit"}: 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"},
)()
distribution_bucket["categories"].append( distribution_bucket["categories"].append(
{ {
"category": type( "category": type(
@@ -372,12 +370,6 @@ def detail(label: str):
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,32 +404,31 @@ 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, )
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_items"] = [
personal_category["distribution_suggestion_total"] = max( {
-summary.allocation_total, "slug": slug,
summary.remainder, "label": personal_label_map.get(slug, slug),
) "allocation": allocations_by_slug.get(slug),
personal_category["distribution_items"] = [ "suggestion": suggestions_by_slug.get(slug),
{ "remaining_amount": max(
"slug": slug, Decimal("0.00"),
"label": personal_label_map.get(slug, slug), to_decimal(suggestions_by_slug.get(slug).suggested_amount if suggestions_by_slug.get(slug) else 0)
"allocation": allocations_by_slug.get(slug), - to_decimal(allocations_by_slug.get(slug).amount if allocations_by_slug.get(slug) else 0),
"suggestion": suggestions_by_slug.get(slug), ),
"remaining_amount": max( "auto_amount": to_decimal(allocations_by_slug.get(slug).amount if allocations_by_slug.get(slug) else 0),
Decimal("0.00"), }
to_decimal(suggestions_by_slug.get(slug).suggested_amount if suggestions_by_slug.get(slug) else 0) for slug in ("persoenlich-flo", "persoenlich-desi")
- to_decimal(allocations_by_slug.get(slug).amount if allocations_by_slug.get(slug) else 0), ]
), planning_accounts.insert(0, distribution_bucket)
"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) previous_month = month_service.previous_month(month.year, month.month)
budget_categories = db.session.scalars( budget_categories = db.session.scalars(
select(Category) select(Category)
+112 -42
View File
@@ -130,6 +130,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 +156,54 @@ 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 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 +256,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,47 +355,55 @@ def seed_data() -> None:
gemeinschaft = Account.query.filter_by(slug="gemeinschaftskonto").first() gemeinschaft = Account.query.filter_by(slug="gemeinschaftskonto").first()
if gemeinschaft: if gemeinschaft:
target_categories = account_categories["gemeinschaftskonto"] if include_example_entries:
with db.session.no_autoflush: target_categories = account_categories["gemeinschaftskonto"]
for category in gemeinschaft.categories: with db.session.no_autoflush:
for entry in list(category.entries): for category in gemeinschaft.categories:
target_slug = ENTRY_TARGET_CATEGORY.get(entry.name) for entry in list(category.entries):
if not target_slug: target_slug = ENTRY_TARGET_CATEGORY.get(entry.name)
continue if not target_slug:
target_category = target_categories.get(target_slug) continue
if not target_category: target_category = target_categories.get(target_slug)
continue if not target_category:
existing_target_entry = Entry.query.filter_by( continue
category_id=target_category.id, existing_target_entry = Entry.query.filter_by(
slug=entry.slug, category_id=target_category.id,
).first() slug=entry.slug,
if existing_target_entry and existing_target_entry.id != entry.id: ).first()
for monthly_value in entry.monthly_values: if existing_target_entry and existing_target_entry.id != entry.id:
monthly_value.entry_id = existing_target_entry.id for monthly_value in entry.monthly_values:
existing_rule_participants = { monthly_value.entry_id = existing_target_entry.id
rule.participant_id for rule in existing_target_entry.share_rules 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): else:
if rule.participant_id in existing_rule_participants: _deactivate_placeholder_categories()
db.session.delete(rule) _deactivate_placeholder_entries()
continue for category in gemeinschaft.categories:
rule.entry_id = existing_target_entry.id if category.slug not in ACCOUNT_TREE["gemeinschaftskonto"]["categories"]:
db.session.delete(entry) category.is_active = False
continue elif category.community_account_id is None:
entry.category_id = target_category.id category.community_account_id = community_accounts["hauptkonto"].id
entry.benefit_scope = "all-users"
entry.is_allocation_target = entry.name in { if not include_example_entries:
"Sparziel", _deactivate_placeholder_categories()
"Reisebudget", _deactivate_placeholder_entries()
"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
db.session.commit() db.session.commit()
@@ -342,7 +412,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:
+40 -30
View File
@@ -117,35 +117,35 @@
<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> <h2>{{ account_data.account.name }}</h2>
<h2>{{ account_data.account.name }}</h2> <small>
<small> Gesamtkosten {{ account_data.total|currency }}
Gesamtkosten {{ account_data.total|currency }} {% if account_data.account.slug == "gemeinschaftskonto" %}
{% if account_data.account.slug == "gemeinschaftskonto" %} · Jährlich {{ (account_data.total * 12)|currency }}
· Jährlich {{ (account_data.total * 12)|currency }} {% endif %}
{% endif %} </small>
</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> </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"> <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 %}
{% endif %} <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 %} {% endfor %}
</section> </section>