Files
moneyfy/backend/app/services/seed.py
T
moneyfyandClaude Opus 5 0b06775be3 feat: Projektfundament mit Datenmodell, Migrationen und Seed
- Repo-Struktur für Backend, Frontend, Dokumentation und Gitea-Workflows
- FastAPI-Skeleton mit pydantic-settings und RFC-7807-artigem Fehlerformat
- Vollständiges Datenmodell (15 Tabellen) als SQLAlchemy-2.0-Modelle
- Alembic gegen die Async-Engine inklusive Erstmigration
- Idempotenter Seed für den deutschen Kategoriebaum und Standardregeln
- Endpunkte /api/health und /api/version
- pytest-Infrastruktur mit transaktionsisolierten Fixtures

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-09 13:12:08 +02:00

98 lines
3.2 KiB
Python

"""Idempotenter Seed der Stammdaten (Kategoriebaum, Benachrichtigungsregeln)."""
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.seed_data import SEED_TREE, SeedCategory
from app.models import Category, NotificationRule
from app.models.enums import EntryKind, NotificationChannel, NotificationType
logger = logging.getLogger(__name__)
# Standardregeln: Vorlaufzeiten gemäß Fachspezifikation, zunächst inaktiv per SMTP.
DEFAULT_RULES: tuple[tuple[NotificationType, int], ...] = (
(NotificationType.DUE_SOON, 3),
(NotificationType.NOTICE_DEADLINE, 30),
(NotificationType.BUDGET_EXCEEDED, 0),
(NotificationType.CONTRACT_RENEWAL, 30),
)
async def seed_categories(session: AsyncSession) -> int:
"""Legt den Standardbaum an. Bereits vorhandene Kategorien bleiben unverändert."""
created = 0
for kind, nodes in SEED_TREE:
for order, node in enumerate(nodes):
parent, was_created = await _upsert(session, node, kind, None, order)
created += int(was_created)
for child_order, child in enumerate(node.children):
_, child_created = await _upsert(session, child, kind, parent.id, child_order)
created += int(child_created)
return created
async def _upsert(
session: AsyncSession,
node: SeedCategory,
kind: EntryKind,
parent_id: int | None,
sort_order: int,
) -> tuple[Category, bool]:
"""Sucht die Kategorie über (parent_id, name) und legt sie bei Bedarf an."""
stmt = select(Category).where(Category.name == node.name)
stmt = stmt.where(
Category.parent_id.is_(None) if parent_id is None else Category.parent_id == parent_id
)
existing = (await session.execute(stmt)).scalar_one_or_none()
if existing is not None:
return existing, False
category = Category(
parent_id=parent_id,
name=node.name,
kind=kind,
color=node.color,
icon=node.icon,
is_fixed_cost=node.is_fixed_cost,
sort_order=sort_order,
)
session.add(category)
await session.flush()
return category, True
async def seed_notification_rules(session: AsyncSession) -> int:
"""Legt je Ereignistyp eine inaktive SMTP-Standardregel an."""
created = 0
for rule_type, lead_days in DEFAULT_RULES:
stmt = select(NotificationRule).where(
NotificationRule.type == rule_type,
NotificationRule.channel == NotificationChannel.SMTP,
)
if (await session.execute(stmt)).scalar_one_or_none() is not None:
continue
session.add(
NotificationRule(
type=rule_type,
lead_days=lead_days,
channel=NotificationChannel.SMTP,
is_active=False,
)
)
created += 1
await session.flush()
return created
async def seed_all(session: AsyncSession) -> dict[str, int]:
"""Führt alle Seeds aus und liefert die Anzahl neu angelegter Datensätze."""
result = {
"categories": await seed_categories(session),
"notification_rules": await seed_notification_rules(session),
}
await session.commit()
logger.info("Seed abgeschlossen: %s", result)
return result