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,51 @@
|
||||
"""Deklarative Basisklasse und gemeinsame Spaltentypen."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import DateTime, MetaData, Numeric
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
# Feste Namenskonventionen, damit Alembic stabile Constraint-Namen erzeugt.
|
||||
NAMING_CONVENTION = {
|
||||
"ix": "ix_%(column_0_label)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||
"pk": "pk_%(table_name)s",
|
||||
}
|
||||
|
||||
# Alle Geldbeträge einheitlich als NUMERIC(12,2) -> Decimal.
|
||||
Money = Numeric(12, 2, asdecimal=True)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""Zeitstempel in UTC – die Anzeige rechnet nach Europe/Berlin um."""
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||
|
||||
type_annotation_map = { # noqa: RUF012
|
||||
Decimal: Money,
|
||||
}
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""created_at/updated_at für Tabellen mit Änderungsverfolgung."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class CreatedAtMixin:
|
||||
"""Nur created_at für rein additive Tabellen."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, nullable=False
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Deutscher Standard-Kategoriebaum für den Erst-Seed."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.models.enums import EntryKind
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SeedCategory:
|
||||
"""Ein Knoten des Seed-Baums. Unterkategorien erben `kind` vom Elternknoten."""
|
||||
|
||||
name: str
|
||||
icon: str = "circle"
|
||||
color: str = "#64748b"
|
||||
is_fixed_cost: bool = False
|
||||
children: tuple["SeedCategory", ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
# Ausgaben ----------------------------------------------------------------------
|
||||
EXPENSE_TREE: tuple[SeedCategory, ...] = (
|
||||
SeedCategory(
|
||||
name="Wohnen",
|
||||
icon="house",
|
||||
color="#f97316",
|
||||
is_fixed_cost=True,
|
||||
children=(
|
||||
SeedCategory("Miete", "key-round", "#f97316", True),
|
||||
SeedCategory("Nebenkosten", "droplets", "#fb923c", True),
|
||||
SeedCategory("Strom", "zap", "#facc15", True),
|
||||
SeedCategory("Internet", "wifi", "#38bdf8", True),
|
||||
SeedCategory("Rundfunkbeitrag", "radio", "#a78bfa", True),
|
||||
),
|
||||
),
|
||||
SeedCategory(
|
||||
name="Versicherungen",
|
||||
icon="shield",
|
||||
color="#0ea5e9",
|
||||
is_fixed_cost=True,
|
||||
children=(
|
||||
SeedCategory("Haftpflicht", "shield-check", "#0ea5e9", True),
|
||||
SeedCategory("Hausrat", "shield-half", "#22d3ee", True),
|
||||
SeedCategory("Kfz", "car-front", "#38bdf8", True),
|
||||
SeedCategory("Kranken", "heart-pulse", "#f472b6", True),
|
||||
SeedCategory("BU", "briefcase-medical", "#818cf8", True),
|
||||
),
|
||||
),
|
||||
SeedCategory(
|
||||
name="Abos & Medien",
|
||||
icon="repeat",
|
||||
color="#a855f7",
|
||||
is_fixed_cost=True,
|
||||
children=(
|
||||
SeedCategory("Streaming", "clapperboard", "#e11d48", True),
|
||||
SeedCategory("Software", "app-window", "#8b5cf6", True),
|
||||
SeedCategory("Cloud", "cloud", "#60a5fa", True),
|
||||
SeedCategory("Zeitungen", "newspaper", "#94a3b8", True),
|
||||
),
|
||||
),
|
||||
SeedCategory(
|
||||
name="Mobilität",
|
||||
icon="car",
|
||||
color="#14b8a6",
|
||||
children=(
|
||||
SeedCategory("Kfz-Steuer", "landmark", "#14b8a6", True),
|
||||
SeedCategory("Sprit", "fuel", "#f59e0b"),
|
||||
SeedCategory("ÖPNV", "bus", "#10b981", True),
|
||||
SeedCategory("Werkstatt", "wrench", "#64748b"),
|
||||
),
|
||||
),
|
||||
SeedCategory(
|
||||
name="Lebenshaltung",
|
||||
icon="shopping-basket",
|
||||
color="#84cc16",
|
||||
children=(
|
||||
SeedCategory("Lebensmittel", "shopping-cart", "#84cc16"),
|
||||
SeedCategory("Drogerie", "sparkles", "#22c55e"),
|
||||
SeedCategory("Restaurant", "utensils", "#f97316"),
|
||||
),
|
||||
),
|
||||
SeedCategory(
|
||||
name="Finanzen",
|
||||
icon="banknote",
|
||||
color="#6366f1",
|
||||
children=(
|
||||
SeedCategory("Kredite", "hand-coins", "#ef4444", True),
|
||||
SeedCategory("Sparen", "piggy-bank", "#10b981", True),
|
||||
SeedCategory("Gebühren", "receipt", "#94a3b8", True),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Einkünfte ---------------------------------------------------------------------
|
||||
INCOME_TREE: tuple[SeedCategory, ...] = (
|
||||
SeedCategory(
|
||||
name="Einkünfte",
|
||||
icon="wallet",
|
||||
color="#22c55e",
|
||||
children=(
|
||||
SeedCategory("Gehalt", "briefcase", "#22c55e"),
|
||||
SeedCategory("Nebeneinkünfte", "laptop", "#4ade80"),
|
||||
SeedCategory("Erstattungen", "undo-2", "#34d399"),
|
||||
SeedCategory("Zinsen", "percent", "#a3e635"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SEED_TREE: tuple[tuple[EntryKind, tuple[SeedCategory, ...]], ...] = (
|
||||
(EntryKind.EXPENSE, EXPENSE_TREE),
|
||||
(EntryKind.INCOME, INCOME_TREE),
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Async-Engine und Session-Factory."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
str(settings.database_url),
|
||||
echo=settings.debug,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
SessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI-Dependency: eine Session pro Request."""
|
||||
async with SessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
Reference in New Issue
Block a user