- 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
98 lines
3.2 KiB
Python
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
|