release: publish saldo 0.1.0

This commit is contained in:
2026-04-21 21:17:36 +02:00
commit 6f5e704739
95 changed files with 9196 additions and 0 deletions
+462
View File
@@ -0,0 +1,462 @@
from __future__ import annotations
from decimal import Decimal
from app.extensions import db
from app.models import (
Account,
Category,
CommunityAccount,
CostParticipant,
Entry,
EntryShareRule,
MonthlyIncome,
NotificationPreference,
User,
)
ACCOUNT_TREE = {
"gemeinschaftskonto": {
"name": "Budgets",
"categories": {
"wohnen": ["Miete", "Zusatzmiete"],
"fixkosten": [
"Internet",
"Mobilfunk 1",
"Mobilfunk 2",
"Rechtsschutz",
"Haftpflicht",
"Zusatzversicherung",
"Altersvorsorge",
"Rundfunkbeitrag",
],
"mitgliedschaften": [
"Vereinsbeitrag",
"Magazin",
"Lernplattform",
"Streaming 1",
"Streaming 2",
"Streaming 3",
"Cloudspeicher",
"Tabler",
],
"technik": ["Server", "Hosting", "Domains", "Software"],
"energie": ["Strom", "Gas"],
"haushalt": ["Lebensmittel", "Drogerie", "Haushaltsbedarf"],
"mobilitaet": ["Auto", "Fahrrad"],
"finanzen": ["Bankgebuehren", "Kreditrate 1", "Kreditrate 2"],
},
},
"sparen": {"name": "Sparen", "categories": {"sparen": ["Sparziel"]}},
"urlaub": {"name": "Urlaub", "categories": {"urlaub": ["Reisebudget"]}},
"freizeit": {"name": "Freizeit", "categories": {"freizeit": ["Freizeitbudget"]}},
"persoenlich-flo": {"name": "Persönlich 1", "categories": {"persoenliche-auszahlung": ["Person 1"]}},
"persoenlich-desi": {"name": "Persönlich 2", "categories": {"persoenliche-auszahlung": ["Person 2"]}},
}
COMMUNITY_ACCOUNT_DEFAULTS = [
{
"name": "Hauptkonto",
"slug": "hauptkonto",
"account_type": "shared",
"linked_account_slug": None,
},
{
"name": "Privatkonto 1",
"slug": "privatkonto-1",
"account_type": "personal",
"linked_account_slug": "persoenlich-flo",
},
{
"name": "Privatkonto 2",
"slug": "privatkonto-2",
"account_type": "personal",
"linked_account_slug": "persoenlich-desi",
},
]
LEGACY_COMMUNITY_ACCOUNT_SLUGS = {
"hauptkonto": "gemeinschaftskonto",
"privatkonto-1": "flo-privat",
"privatkonto-2": "desi-privat",
}
LEGACY_CATEGORY_SLUGS = {
("gemeinschaftskonto", "wohnen"): "miete",
("gemeinschaftskonto", "fixkosten"): "kommunikation",
("gemeinschaftskonto", "mitgliedschaften"): "abos",
("gemeinschaftskonto", "technik"): "server",
("gemeinschaftskonto", "finanzen"): "schulden",
("persoenlich-flo", "persoenliche-auszahlung"): "flo",
("persoenlich-desi", "persoenliche-auszahlung"): "desi",
}
LEGACY_ENTRY_NAMES = {
("persoenlich-flo", "persoenliche-auszahlung", "Person 1"): "persönliche Auszahlung",
("persoenlich-desi", "persoenliche-auszahlung", "Person 2"): "persönliche Auszahlung",
}
ENTRY_TARGET_CATEGORY = {
"Miete": "wohnen",
"Zusatzmiete": "wohnen",
"Internet": "fixkosten",
"Mobilfunk 1": "fixkosten",
"Mobilfunk 2": "fixkosten",
"Rechtsschutz": "fixkosten",
"Haftpflicht": "fixkosten",
"Zusatzversicherung": "fixkosten",
"Altersvorsorge": "fixkosten",
"Rundfunkbeitrag": "fixkosten",
"Lebensmittel": "haushalt",
"Drogerie": "haushalt",
"Haushaltsbedarf": "haushalt",
"Auto": "mobilitaet",
"Fahrrad": "mobilitaet",
"Vereinsbeitrag": "mitgliedschaften",
"Magazin": "mitgliedschaften",
"Lernplattform": "mitgliedschaften",
"Streaming 1": "mitgliedschaften",
"Streaming 2": "mitgliedschaften",
"Streaming 3": "mitgliedschaften",
"Cloudspeicher": "mitgliedschaften",
"Tabler": "mitgliedschaften",
"Server": "technik",
"Hosting": "technik",
"Domains": "technik",
"Software": "technik",
"Bankgebuehren": "finanzen",
"Kreditrate 1": "finanzen",
"Kreditrate 2": "finanzen",
}
def slugify(value: str) -> str:
return (
value.lower()
.replace(" ", "-")
.replace("ö", "oe")
.replace("ü", "ue")
.replace("ä", "ae")
.replace("/", "-")
.replace("+", "plus")
)
def seed_data() -> None:
# Basisdaten nur für die fachliche Grundstruktur, ohne Demo-Benutzer,
# Beispiel-Personen oder vorausgefüllte Monatsdaten.
community_accounts = {}
for sort_order, data in enumerate(COMMUNITY_ACCOUNT_DEFAULTS, start=1):
community_account = CommunityAccount.query.filter_by(slug=data["slug"]).first()
if community_account is None and LEGACY_COMMUNITY_ACCOUNT_SLUGS.get(data["slug"]):
community_account = CommunityAccount.query.filter_by(
slug=LEGACY_COMMUNITY_ACCOUNT_SLUGS[data["slug"]]
).first()
if community_account is None:
community_account = CommunityAccount(
name=data["name"],
slug=data["slug"],
account_type=data["account_type"],
linked_account_slug=data["linked_account_slug"],
sort_order=sort_order,
is_active=True,
)
db.session.add(community_account)
else:
community_account.name = data["name"]
community_account.slug = data["slug"]
community_account.account_type = data["account_type"]
community_account.linked_account_slug = data["linked_account_slug"]
community_account.sort_order = sort_order
community_account.is_active = True
community_accounts[data["slug"]] = community_account
db.session.flush()
sort_order = 1
account_categories = {}
for account_slug, account_data in ACCOUNT_TREE.items():
account = Account.query.filter_by(slug=account_slug).first()
if account is None:
account = Account(
name=account_data["name"],
slug=account_slug,
sort_order=sort_order,
is_active=True,
)
db.session.add(account)
db.session.flush()
else:
account.name = account_data["name"]
account.sort_order = sort_order
account.is_active = True
if account_slug == "freizeit":
account.sort_order = 2
sort_order += 1
category_sort = 1
account_categories[account_slug] = {}
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:
category = Category(
account_id=account.id,
community_account_id=(
community_accounts["hauptkonto"].id
if account_slug == "gemeinschaftskonto"
else None
),
name=category_slug.replace("-", " ").title(),
slug=category_slug,
sort_order=category_sort,
is_active=True,
)
db.session.add(category)
db.session.flush()
else:
category.name = category_slug.replace("-", " ").title()
category.slug = category_slug
category.sort_order = category_sort
category.is_active = True
if account_slug == "gemeinschaftskonto" and category.community_account_id is None:
category.community_account_id = community_accounts["hauptkonto"].id
account_categories[account_slug][category_slug] = category
category_sort += 1
for index, entry_name in enumerate(entries, start=1):
default_amount = Decimal("0.00")
if entry_name == "Miete":
default_amount = Decimal("920.00")
elif entry_name == "Lebensmittel":
default_amount = Decimal("520.00")
elif entry_name == "Streaming 1":
default_amount = Decimal("17.99")
elif entry_name in {"Sparziel", "Reisebudget", "Freizeitbudget", "persönliche Auszahlung"}:
default_amount = Decimal("0.00")
entry_slug = slugify(entry_name)
entry = Entry.query.filter_by(category_id=category.id, slug=entry_slug).first()
if entry is None:
entry = (
Entry.query.join(Category)
.filter(
Category.account_id == account.id,
Entry.slug == entry_slug,
)
.first()
)
if entry is not None:
entry.category_id = category.id
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 None:
entry = Entry(
category_id=category.id,
name=entry_name,
slug=entry_slug,
default_amount=default_amount,
amount_type="fixed",
benefit_scope=(
"all-users"
),
is_allocation_target=entry_name in {
"Sparziel",
"Reisebudget",
"Freizeitbudget",
"Person 1",
"Person 2",
},
sort_order=index,
is_active=True,
)
db.session.add(entry)
db.session.flush()
else:
entry.name = entry_name
entry.default_amount = default_amount
entry.amount_type = "fixed"
entry.benefit_scope = "all-users"
entry.is_allocation_target = entry_name in {
"Sparziel",
"Reisebudget",
"Freizeitbudget",
"Person 1",
"Person 2",
}
entry.sort_order = index
entry.is_active = True
gemeinschaft = Account.query.filter_by(slug="gemeinschaftskonto").first()
if gemeinschaft:
target_categories = account_categories["gemeinschaftskonto"]
with db.session.no_autoflush:
for category in gemeinschaft.categories:
for entry in list(category.entries):
target_slug = ENTRY_TARGET_CATEGORY.get(entry.name)
if not target_slug:
continue
target_category = target_categories.get(target_slug)
if not target_category:
continue
existing_target_entry = Entry.query.filter_by(
category_id=target_category.id,
slug=entry.slug,
).first()
if existing_target_entry and existing_target_entry.id != entry.id:
for monthly_value in entry.monthly_values:
monthly_value.entry_id = existing_target_entry.id
existing_rule_participants = {
rule.participant_id for rule in existing_target_entry.share_rules
}
for rule in list(entry.share_rules):
if rule.participant_id in existing_rule_participants:
db.session.delete(rule)
continue
rule.entry_id = existing_target_entry.id
db.session.delete(entry)
continue
entry.category_id = target_category.id
entry.benefit_scope = "all-users"
entry.is_allocation_target = entry.name in {
"Sparziel",
"Reisebudget",
"Freizeitbudget",
"Person 1",
"Person 2",
}
for category in gemeinschaft.categories:
if category.slug not in ACCOUNT_TREE["gemeinschaftskonto"]["categories"]:
category.is_active = False
elif category.community_account_id is None:
category.community_account_id = community_accounts["hauptkonto"].id
db.session.commit()
def seed_demo_data() -> None:
from datetime import date
from flask import current_app
seed_data()
admin = User.query.filter_by(username="admin").first()
if admin is None:
admin = User(username="admin", display_name="Admin", email="admin@example.invalid", role="admin")
admin.set_password("testpass")
db.session.add(admin)
editor_a = User.query.filter_by(username="mitglied1").first()
if editor_a is None:
editor_a = User(
username="mitglied1",
display_name="Person A",
email="person-a@example.invalid",
role="editor",
)
editor_a.set_password("testpass")
db.session.add(editor_a)
editor_b = User.query.filter_by(username="mitglied2").first()
if editor_b is None:
editor_b = User(
username="mitglied2",
display_name="Person B",
email="person-b@example.invalid",
role="editor",
)
editor_b.set_password("testpass")
db.session.add(editor_b)
db.session.flush()
for user in [admin, editor_a, editor_b]:
if user.notification_preference is None:
db.session.add(NotificationPreference(user_id=user.id))
participants = {
"Person A": CostParticipant.query.filter_by(name="Person A").first(),
"Person B": CostParticipant.query.filter_by(name="Person B").first(),
"Gast": CostParticipant.query.filter_by(name="Gast").first(),
}
if participants["Person A"] is None:
participants["Person A"] = CostParticipant(
name="Person A", is_app_user=True, linked_user_id=editor_a.id, is_external=False
)
db.session.add(participants["Person A"])
participants["Person A"].is_app_user = True
participants["Person A"].linked_user_id = editor_a.id
participants["Person A"].is_external = False
participants["Person A"].avatar_url = editor_a.avatar_url
if participants["Person B"] is None:
participants["Person B"] = CostParticipant(
name="Person B", is_app_user=True, linked_user_id=editor_b.id, is_external=False
)
db.session.add(participants["Person B"])
participants["Person B"].is_app_user = True
participants["Person B"].linked_user_id = editor_b.id
participants["Person B"].is_external = False
if participants["Gast"] is None:
participants["Gast"] = CostParticipant(name="Gast", is_app_user=False, is_external=True)
db.session.add(participants["Gast"])
participants["Gast"].is_app_user = False
participants["Gast"].is_external = True
db.session.flush()
shared_entry = Entry.query.join(Category).filter(Entry.name == "Streaming 1").first()
if shared_entry is not None:
for person in participants.values():
rule = EntryShareRule.query.filter_by(
entry_id=shared_entry.id, participant_id=person.id
).first()
if rule is None:
db.session.add(
EntryShareRule(
entry_id=shared_entry.id,
participant_id=person.id,
share_type="equal",
)
)
month_service = current_app.extensions["saldo.month_service"]
month = month_service.ensure_month(date(2026, 4, 1))
while len(month.incomes) < 2:
db.session.add(
MonthlyIncome(
month_id=month.id,
label=f"Einkommen {len(month.incomes) + 1}",
amount=Decimal("0.00"),
sort_order=len(month.incomes) + 1,
)
)
db.session.flush()
if not month.incomes:
db.session.flush()
for income in month.incomes:
if income.sort_order == 1:
income.label = "Einkommen 1"
income.amount = Decimal("3100.00")
else:
income.label = "Einkommen 2"
income.amount = Decimal("2450.00")
for allocation in month.allocations:
if allocation.target_account.slug == "sparen":
allocation.amount = Decimal("350.00")
elif allocation.target_account.slug == "urlaub":
allocation.amount = Decimal("180.00")
elif allocation.target_account.slug == "freizeit":
allocation.amount = Decimal("120.00")
else:
allocation.amount = Decimal("220.00")
month_service.refresh_suggestions(month, "Demo-Daten initialisiert")
db.session.commit()