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
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user