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,8 @@
|
||||
"""Sammelrouter für alle /api-Endpunkte."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import system
|
||||
|
||||
api_router = APIRouter(prefix="/api")
|
||||
api_router.include_router(system.router)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Systemendpunkte: Health-Check und Versionsinformation (ohne Authentifizierung)."""
|
||||
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
|
||||
router = APIRouter(tags=["system"])
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = Field(description="'ok' wenn Anwendung und Datenbank erreichbar sind.")
|
||||
database: str = Field(description="'ok' oder 'unavailable'.")
|
||||
time: datetime = Field(description="Aktuelle Serverzeit in Europe/Berlin.")
|
||||
|
||||
|
||||
class VersionResponse(BaseModel):
|
||||
name: str
|
||||
version: str
|
||||
environment: str
|
||||
timezone: str
|
||||
currency: str
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse, summary="Health-Check")
|
||||
async def health(session: AsyncSession = Depends(get_session)) -> HealthResponse:
|
||||
"""Prüft die Datenbankverbindung. Wird vom Docker-Healthcheck verwendet."""
|
||||
database = "ok"
|
||||
try:
|
||||
await session.execute(text("SELECT 1"))
|
||||
except Exception:
|
||||
database = "unavailable"
|
||||
return HealthResponse(
|
||||
status="ok" if database == "ok" else "degraded",
|
||||
database=database,
|
||||
time=datetime.now(ZoneInfo(settings.timezone)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/version", response_model=VersionResponse, summary="Versionsinformation")
|
||||
async def version() -> VersionResponse:
|
||||
return VersionResponse(
|
||||
name=settings.app_name,
|
||||
version=settings.app_version,
|
||||
environment=settings.environment,
|
||||
timezone=settings.timezone,
|
||||
currency=settings.default_currency,
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Zentrale Anwendungskonfiguration über Umgebungsvariablen."""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, PostgresDsn, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Alle Einstellungen stammen aus der Umgebung bzw. einer .env-Datei."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# --- Allgemein -----------------------------------------------------------
|
||||
app_name: str = "moneyfy"
|
||||
app_version: str = "0.1.0"
|
||||
environment: Literal["development", "production", "test"] = "development"
|
||||
debug: bool = False
|
||||
timezone: str = "Europe/Berlin"
|
||||
holiday_region: str = "DE-NW"
|
||||
default_currency: str = "EUR"
|
||||
|
||||
# --- Datenbank -----------------------------------------------------------
|
||||
database_url: PostgresDsn = Field(
|
||||
default="postgresql+asyncpg://moneyfy:moneyfy@localhost:5432/moneyfy",
|
||||
description="Async-DSN (asyncpg) für SQLAlchemy.",
|
||||
)
|
||||
|
||||
# --- Sicherheit ----------------------------------------------------------
|
||||
secret_key: str = Field(default="change-me-in-production", min_length=8)
|
||||
access_token_ttl_minutes: int = 30
|
||||
refresh_token_ttl_days: int = 14
|
||||
cookie_secure: bool = True
|
||||
cookie_domain: str | None = None
|
||||
|
||||
# Erst-Start: Admin-Benutzer
|
||||
moneyfy_admin_user: str = "admin"
|
||||
moneyfy_admin_password: str | None = None
|
||||
|
||||
# --- OIDC (vorbereitet, nicht implementiert) -----------------------------
|
||||
oidc_enabled: bool = False
|
||||
oidc_issuer: str | None = None
|
||||
oidc_client_id: str | None = None
|
||||
oidc_client_secret: str | None = None
|
||||
oidc_scopes: str = "openid profile email"
|
||||
|
||||
# --- Logo-Service --------------------------------------------------------
|
||||
logo_storage_dir: Path = Path("/data/logos")
|
||||
logodev_api_key: str | None = None
|
||||
brandfetch_api_key: str | None = None
|
||||
logo_http_timeout_seconds: float = 5.0
|
||||
logo_http_retries: int = 2
|
||||
logo_max_upload_bytes: int = 1_048_576
|
||||
|
||||
# --- Benachrichtigungen --------------------------------------------------
|
||||
notifications_enabled: bool = True
|
||||
scheduler_enabled: bool = True
|
||||
notification_hour: int = 7
|
||||
notification_minute: int = 0
|
||||
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int = 587
|
||||
smtp_user: str | None = None
|
||||
smtp_password: str | None = None
|
||||
smtp_from: str | None = None
|
||||
smtp_use_tls: bool = True
|
||||
smtp_use_ssl: bool = False
|
||||
|
||||
apprise_urls: str | None = None
|
||||
|
||||
public_base_url: str = "http://localhost:8087"
|
||||
|
||||
# --- CORS ----------------------------------------------------------------
|
||||
cors_origins: str = ""
|
||||
|
||||
@field_validator("database_url", mode="before")
|
||||
@classmethod
|
||||
def _force_async_driver(cls, value: object) -> object:
|
||||
"""Erlaubt sowohl `postgresql://` als auch `postgresql+asyncpg://` in der Umgebung."""
|
||||
if isinstance(value, str) and value.startswith("postgresql://"):
|
||||
return value.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
return value
|
||||
|
||||
@property
|
||||
def sync_database_url(self) -> str:
|
||||
"""Synchrone Variante der DSN – wird von Alembic benötigt."""
|
||||
return str(self.database_url).replace("+asyncpg", "+psycopg")
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||
|
||||
@property
|
||||
def apprise_url_list(self) -> list[str]:
|
||||
if not self.apprise_urls:
|
||||
return []
|
||||
return [url.strip() for url in self.apprise_urls.split(",") if url.strip()]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Gecachte Settings-Instanz."""
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Einheitliche Fehlerdarstellung im RFC-7807-Stil: {"detail": ..., "code": ...}."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
# Starlette benennt die 422-Konstante gerade um – fester Wert vermeidet die Abhängigkeit.
|
||||
HTTP_422 = 422
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""Basisklasse für fachliche Fehler mit stabilem Fehlercode."""
|
||||
|
||||
status_code: int = status.HTTP_400_BAD_REQUEST
|
||||
code: str = "bad_request"
|
||||
|
||||
def __init__(self, detail: str, *, code: str | None = None, status_code: int | None = None):
|
||||
super().__init__(detail)
|
||||
self.detail = detail
|
||||
if code is not None:
|
||||
self.code = code
|
||||
if status_code is not None:
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class NotFoundError(AppError):
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
code = "not_found"
|
||||
|
||||
|
||||
class ConflictError(AppError):
|
||||
status_code = status.HTTP_409_CONFLICT
|
||||
code = "conflict"
|
||||
|
||||
|
||||
class ValidationError(AppError):
|
||||
status_code = HTTP_422
|
||||
code = "validation_error"
|
||||
|
||||
|
||||
class AuthError(AppError):
|
||||
status_code = status.HTTP_401_UNAUTHORIZED
|
||||
code = "unauthorized"
|
||||
|
||||
|
||||
def problem(
|
||||
status_code: int, detail: str, code: str, extra: dict[str, Any] | None = None
|
||||
) -> JSONResponse:
|
||||
body: dict[str, Any] = {"detail": detail, "code": code}
|
||||
if extra:
|
||||
body.update(extra)
|
||||
return JSONResponse(status_code=status_code, content=body)
|
||||
|
||||
|
||||
# Zuordnung der Standard-HTTP-Statuscodes auf sprechende Fehlercodes.
|
||||
_STATUS_CODES = {
|
||||
400: "bad_request",
|
||||
401: "unauthorized",
|
||||
403: "forbidden",
|
||||
404: "not_found",
|
||||
405: "method_not_allowed",
|
||||
409: "conflict",
|
||||
413: "payload_too_large",
|
||||
415: "unsupported_media_type",
|
||||
422: "validation_error",
|
||||
429: "too_many_requests",
|
||||
500: "internal_error",
|
||||
}
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
"""Registriert alle Handler, damit jede Fehlerantwort dasselbe Format hat."""
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def _app_error(_: Request, exc: AppError) -> JSONResponse:
|
||||
return problem(exc.status_code, exc.detail, exc.code)
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def _http_error(_: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
code = _STATUS_CODES.get(exc.status_code, "error")
|
||||
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
|
||||
return problem(exc.status_code, detail, code)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def _validation_error(_: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
return problem(
|
||||
HTTP_422,
|
||||
"Die Anfrage enthält ungültige Felder.",
|
||||
"validation_error",
|
||||
{"errors": _serialise_errors(exc.errors())},
|
||||
)
|
||||
|
||||
|
||||
def _serialise_errors(errors: list[Any]) -> list[dict[str, Any]]:
|
||||
"""Pydantic-Fehler auf JSON-serialisierbare Felder reduzieren."""
|
||||
result = []
|
||||
for error in errors:
|
||||
result.append(
|
||||
{
|
||||
"loc": [str(part) for part in error.get("loc", [])],
|
||||
"msg": error.get("msg", ""),
|
||||
"type": error.get("type", ""),
|
||||
}
|
||||
)
|
||||
return result
|
||||
@@ -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
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Einstiegspunkt der moneyfy-Anwendung."""
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.core.config import settings
|
||||
from app.core.errors import register_exception_handlers
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if settings.debug else logging.INFO,
|
||||
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("moneyfy")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Start- und Stopplogik der Anwendung."""
|
||||
settings.logo_storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("moneyfy %s gestartet (%s)", settings.app_version, settings.environment)
|
||||
yield
|
||||
logger.info("moneyfy wird beendet")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="moneyfy",
|
||||
version=settings.app_version,
|
||||
description="Planung monatlicher Kosten und Einkünfte.",
|
||||
openapi_url="/api/openapi.json",
|
||||
docs_url="/api/docs",
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
if settings.cors_origin_list:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
register_exception_handlers(app)
|
||||
app.include_router(api_router)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""SQLAlchemy-Modelle. Import hier hält Alembics Autogenerate vollständig."""
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.core import Account, AppUser, Category, LogoAsset, Merchant
|
||||
from app.models.enums import (
|
||||
AccountType,
|
||||
BusinessDayShift,
|
||||
EntryKind,
|
||||
LogoSource,
|
||||
LogoStatus,
|
||||
NotificationChannel,
|
||||
NotificationStatus,
|
||||
NotificationType,
|
||||
OccurrenceStatus,
|
||||
)
|
||||
from app.models.finance import (
|
||||
AmountVersion,
|
||||
Budget,
|
||||
BudgetTemplate,
|
||||
Occurrence,
|
||||
Recurrence,
|
||||
ReserveLedger,
|
||||
SavingsGoal,
|
||||
Transaction,
|
||||
)
|
||||
from app.models.notification import NotificationLog, NotificationRule
|
||||
|
||||
__all__ = [
|
||||
"Account",
|
||||
"AccountType",
|
||||
"AmountVersion",
|
||||
"AppUser",
|
||||
"Base",
|
||||
"Budget",
|
||||
"BudgetTemplate",
|
||||
"BusinessDayShift",
|
||||
"Category",
|
||||
"EntryKind",
|
||||
"LogoAsset",
|
||||
"LogoSource",
|
||||
"LogoStatus",
|
||||
"Merchant",
|
||||
"NotificationChannel",
|
||||
"NotificationLog",
|
||||
"NotificationRule",
|
||||
"NotificationStatus",
|
||||
"NotificationType",
|
||||
"Occurrence",
|
||||
"OccurrenceStatus",
|
||||
"Recurrence",
|
||||
"ReserveLedger",
|
||||
"SavingsGoal",
|
||||
"Transaction",
|
||||
]
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Stammdaten: Konten, Kategorien, Firmen, Logos, Benutzer."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, CreatedAtMixin, Money, TimestampMixin
|
||||
from app.models.enums import (
|
||||
AccountType,
|
||||
EntryKind,
|
||||
LogoSource,
|
||||
LogoStatus,
|
||||
pg_enum,
|
||||
)
|
||||
|
||||
|
||||
class Account(Base, TimestampMixin):
|
||||
"""Ein Konto.
|
||||
|
||||
Der Saldo wird aus Eröffnungssaldo plus allen bestätigten Buchungen fortgeschrieben.
|
||||
"""
|
||||
|
||||
__tablename__ = "account"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
type: Mapped[AccountType] = mapped_column(
|
||||
pg_enum(AccountType, "account_type"), nullable=False, default=AccountType.CHECKING
|
||||
)
|
||||
iban_last4: Mapped[str | None] = mapped_column(String(4), nullable=True)
|
||||
opening_balance: Mapped[Decimal] = mapped_column(Money, nullable=False, default=Decimal("0.00"))
|
||||
opening_balance_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3b82f6")
|
||||
icon: Mapped[str] = mapped_column(String(64), nullable=False, default="wallet")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("name", name="uq_account_name"),
|
||||
Index("ix_account_is_active", "is_active"),
|
||||
)
|
||||
|
||||
|
||||
class Category(Base):
|
||||
"""Zweistufiger Kategoriebaum – Unterkategorien haben genau einen Elternknoten."""
|
||||
|
||||
__tablename__ = "category"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("category.id", ondelete="RESTRICT"), nullable=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
kind: Mapped[EntryKind] = mapped_column(pg_enum(EntryKind, "entry_kind"), nullable=False)
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#64748b")
|
||||
icon: Mapped[str] = mapped_column(String(64), nullable=False, default="circle")
|
||||
is_fixed_cost: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
parent: Mapped["Category | None"] = relationship(
|
||||
remote_side="Category.id", back_populates="children"
|
||||
)
|
||||
children: Mapped[list["Category"]] = relationship(
|
||||
back_populates="parent", cascade="save-update, merge", order_by="Category.sort_order"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("parent_id", "name", name="uq_category_parent_id"),
|
||||
Index("ix_category_kind", "kind"),
|
||||
)
|
||||
|
||||
|
||||
class LogoAsset(Base):
|
||||
"""Im Volume abgelegtes Logo. Auslieferung erfolgt ausschließlich lokal."""
|
||||
|
||||
__tablename__ = "logo_asset"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
sha256: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
mime: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
file_path: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
|
||||
class Merchant(Base, CreatedAtMixin):
|
||||
"""Firma bzw. Zahlungsempfänger inklusive Markenlogo und -farbe."""
|
||||
|
||||
__tablename__ = "merchant"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
normalized_name: Mapped[str] = mapped_column(String(160), nullable=False, unique=True)
|
||||
domain: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
aliases: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(Text), nullable=False, default=list, server_default="{}"
|
||||
)
|
||||
logo_asset_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("logo_asset.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
brand_color: Mapped[str | None] = mapped_column(String(9), nullable=True)
|
||||
brand_color_dark: Mapped[str | None] = mapped_column(String(9), nullable=True)
|
||||
logo_source: Mapped[LogoSource | None] = mapped_column(
|
||||
pg_enum(LogoSource, "logo_source"), nullable=True
|
||||
)
|
||||
logo_status: Mapped[LogoStatus] = mapped_column(
|
||||
pg_enum(LogoStatus, "logo_status"), nullable=False, default=LogoStatus.PENDING
|
||||
)
|
||||
|
||||
logo_asset: Mapped[LogoAsset | None] = relationship(lazy="joined")
|
||||
|
||||
__table_args__ = (Index("ix_merchant_normalized_name", "normalized_name"),)
|
||||
|
||||
|
||||
class AppUser(Base, CreatedAtMixin):
|
||||
"""Single-User-Betrieb; `external_subject` ist für eine spätere OIDC-Anbindung vorgesehen."""
|
||||
|
||||
__tablename__ = "app_user"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(120), nullable=False, unique=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
external_subject: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"password_hash IS NOT NULL OR external_subject IS NOT NULL",
|
||||
name="local_or_external_login",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Aufzählungstypen des Datenmodells (werden als native PostgreSQL-Enums angelegt)."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
|
||||
|
||||
class AccountType(StrEnum):
|
||||
CHECKING = "checking"
|
||||
CREDIT_CARD = "credit_card"
|
||||
SAVINGS = "savings"
|
||||
CASH = "cash"
|
||||
|
||||
|
||||
class EntryKind(StrEnum):
|
||||
"""Richtung eines Postens: Ausgabe oder Einnahme."""
|
||||
|
||||
EXPENSE = "expense"
|
||||
INCOME = "income"
|
||||
|
||||
|
||||
class BusinessDayShift(StrEnum):
|
||||
"""Verschiebung, wenn ein Fälligkeitstag auf Wochenende/Feiertag fällt."""
|
||||
|
||||
NONE = "none"
|
||||
NEXT = "next"
|
||||
PREVIOUS = "previous"
|
||||
|
||||
|
||||
class OccurrenceStatus(StrEnum):
|
||||
PLANNED = "planned"
|
||||
CONFIRMED = "confirmed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class LogoSource(StrEnum):
|
||||
SIMPLE_ICONS = "simple-icons"
|
||||
LOGODEV = "logodev"
|
||||
BRANDFETCH = "brandfetch"
|
||||
FAVICON = "favicon"
|
||||
UPLOAD = "upload"
|
||||
GENERATED = "generated"
|
||||
|
||||
|
||||
class LogoStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
RESOLVED = "resolved"
|
||||
FAILED = "failed"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class NotificationType(StrEnum):
|
||||
DUE_SOON = "due_soon"
|
||||
NOTICE_DEADLINE = "notice_deadline"
|
||||
BUDGET_EXCEEDED = "budget_exceeded"
|
||||
CONTRACT_RENEWAL = "contract_renewal"
|
||||
|
||||
|
||||
class NotificationChannel(StrEnum):
|
||||
SMTP = "smtp"
|
||||
APPRISE = "apprise"
|
||||
|
||||
|
||||
class NotificationStatus(StrEnum):
|
||||
SENT = "sent"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
def pg_enum(enum_cls: type[StrEnum], name: str) -> SAEnum:
|
||||
"""Erzeugt einen nativen PostgreSQL-Enum-Typ mit den String-Werten der Enum-Klasse."""
|
||||
return SAEnum(
|
||||
enum_cls,
|
||||
name=name,
|
||||
native_enum=True,
|
||||
values_callable=lambda cls: [member.value for member in cls],
|
||||
validate_strings=True,
|
||||
)
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Bewegungsdaten: Recurrences, Preishistorie, Occurrences, Buchungen, Budgets, Ziele."""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Date,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, CreatedAtMixin, Money, TimestampMixin
|
||||
from app.models.core import Account, Category, Merchant
|
||||
from app.models.enums import BusinessDayShift, EntryKind, OccurrenceStatus, pg_enum
|
||||
|
||||
|
||||
class Recurrence(Base, TimestampMixin):
|
||||
"""Wiederkehrende Zahlung oder Einkunft, beschrieben durch eine RFC-5545-RRULE."""
|
||||
|
||||
__tablename__ = "recurrence"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
kind: Mapped[EntryKind] = mapped_column(pg_enum(EntryKind, "entry_kind"), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
merchant_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("merchant.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
category_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("category.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
account_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("account.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
|
||||
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
is_variable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
|
||||
|
||||
# RRULE ohne DTSTART – der Startzeitpunkt steht separat in `dtstart`.
|
||||
rrule: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
dtstart: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
until: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
business_day_shift: Mapped[BusinessDayShift] = mapped_column(
|
||||
pg_enum(BusinessDayShift, "business_day_shift"),
|
||||
nullable=False,
|
||||
default=BusinessDayShift.NEXT,
|
||||
server_default="next",
|
||||
)
|
||||
holiday_region: Mapped[str] = mapped_column(
|
||||
String(8), nullable=False, default="DE-NW", server_default="DE-NW"
|
||||
)
|
||||
|
||||
# Ratenzahlung / Kredit
|
||||
installments_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
principal_amount: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||
|
||||
# Vertragsdaten
|
||||
contract_start: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
contract_min_term_months: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
contract_notice_period_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
contract_auto_renew_months: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
contract_cancelled_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
reserve_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tags: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(Text), nullable=False, default=list, server_default="{}"
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
merchant: Mapped[Merchant | None] = relationship(lazy="selectin")
|
||||
category: Mapped[Category] = relationship(lazy="selectin")
|
||||
account: Mapped[Account] = relationship(lazy="selectin")
|
||||
amount_versions: Mapped[list["AmountVersion"]] = relationship(
|
||||
back_populates="recurrence",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="AmountVersion.valid_from",
|
||||
lazy="selectin",
|
||||
)
|
||||
occurrences: Mapped[list["Occurrence"]] = relationship(
|
||||
back_populates="recurrence",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Occurrence.occurrence_date",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"installments_total IS NULL OR installments_total > 0", name="installments"
|
||||
),
|
||||
Index("ix_recurrence_kind_active", "kind", "is_active"),
|
||||
Index("ix_recurrence_category_id", "category_id"),
|
||||
Index("ix_recurrence_merchant_id", "merchant_id"),
|
||||
)
|
||||
|
||||
|
||||
class AmountVersion(Base, CreatedAtMixin):
|
||||
"""Preishistorie: gültig ist die Version mit dem größten `valid_from <= Fälligkeitsdatum`."""
|
||||
|
||||
__tablename__ = "amount_version"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
recurrence_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("recurrence.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
valid_from: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
recurrence: Mapped[Recurrence] = relationship(back_populates="amount_versions")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("recurrence_id", "valid_from", name="uq_amount_version_recurrence_id"),
|
||||
)
|
||||
|
||||
|
||||
class Occurrence(Base, TimestampMixin):
|
||||
"""Materialisierte Einzelfälligkeit – nur bei Abweichung, Bestätigung oder Auslassung."""
|
||||
|
||||
__tablename__ = "occurrence"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
recurrence_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("recurrence.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
# Immer das nominale (unverschobene) Datum – dadurch bleibt die Zuordnung stabil.
|
||||
occurrence_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
status: Mapped[OccurrenceStatus] = mapped_column(
|
||||
pg_enum(OccurrenceStatus, "occurrence_status"),
|
||||
nullable=False,
|
||||
default=OccurrenceStatus.PLANNED,
|
||||
)
|
||||
planned_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
actual_amount: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||
actual_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("account.id", ondelete="RESTRICT"), nullable=True
|
||||
)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
recurrence: Mapped[Recurrence] = relationship(back_populates="occurrences")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("recurrence_id", "occurrence_date", name="uq_occurrence_recurrence_id"),
|
||||
Index("ix_occurrence_date", "occurrence_date"),
|
||||
)
|
||||
|
||||
|
||||
class Transaction(Base, CreatedAtMixin):
|
||||
"""Einmalige Buchung ohne Wiederholung."""
|
||||
|
||||
__tablename__ = "transaction"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
kind: Mapped[EntryKind] = mapped_column(pg_enum(EntryKind, "entry_kind"), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
merchant_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("merchant.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
category_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("category.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
account_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("account.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
booking_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tags: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(Text), nullable=False, default=list, server_default="{}"
|
||||
)
|
||||
|
||||
merchant: Mapped[Merchant | None] = relationship(lazy="selectin")
|
||||
category: Mapped[Category] = relationship(lazy="selectin")
|
||||
account: Mapped[Account] = relationship(lazy="selectin")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_transaction_booking_date", "booking_date"),
|
||||
Index("ix_transaction_category_id", "category_id"),
|
||||
)
|
||||
|
||||
|
||||
class Budget(Base, CreatedAtMixin):
|
||||
"""Monatsbudget je Kategorie. `period_month` ist immer der Monatserste."""
|
||||
|
||||
__tablename__ = "budget"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
category_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("category.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
period_month: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
limit_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
rollover: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
category: Mapped[Category] = relationship(lazy="selectin")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("category_id", "period_month", name="uq_budget_category_id"),
|
||||
CheckConstraint("date_trunc('month', period_month) = period_month", name="month_start"),
|
||||
)
|
||||
|
||||
|
||||
class BudgetTemplate(Base, TimestampMixin):
|
||||
"""Dauerhaftes Budget ab einem Monat – erspart die Pflege jedes einzelnen Monats."""
|
||||
|
||||
__tablename__ = "budget_template"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
category_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("category.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
valid_from: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
valid_until: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
limit_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
rollover: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
category: Mapped[Category] = relationship(lazy="selectin")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("category_id", "valid_from", name="uq_budget_template_category_id"),
|
||||
CheckConstraint("date_trunc('month', valid_from) = valid_from", name="month_start"),
|
||||
)
|
||||
|
||||
|
||||
class SavingsGoal(Base, TimestampMixin):
|
||||
"""Sparziel mit Fortschritt und optionaler Zielrate."""
|
||||
|
||||
__tablename__ = "savings_goal"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
target_amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
target_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
current_amount: Mapped[Decimal] = mapped_column(Money, nullable=False, default=Decimal("0.00"))
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("account.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
monthly_contribution: Mapped[Decimal | None] = mapped_column(Money, nullable=True)
|
||||
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#10b981")
|
||||
icon: Mapped[str] = mapped_column(String(64), nullable=False, default="piggy-bank")
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
__table_args__ = (UniqueConstraint("name", name="uq_savings_goal_name"),)
|
||||
|
||||
|
||||
class ReserveLedger(Base, CreatedAtMixin):
|
||||
"""Monatliche Rücklagenbildung für nicht-monatliche Posten."""
|
||||
|
||||
__tablename__ = "reserve_ledger"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
recurrence_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("recurrence.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
period_month: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
amount: Mapped[Decimal] = mapped_column(Money, nullable=False)
|
||||
is_released: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("recurrence_id", "period_month", name="uq_reserve_ledger_recurrence_id"),
|
||||
CheckConstraint("date_trunc('month', period_month) = period_month", name="month_start"),
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Benachrichtigungsregeln und Versandprotokoll."""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, CreatedAtMixin
|
||||
from app.models.enums import (
|
||||
NotificationChannel,
|
||||
NotificationStatus,
|
||||
NotificationType,
|
||||
pg_enum,
|
||||
)
|
||||
|
||||
|
||||
class NotificationRule(Base, CreatedAtMixin):
|
||||
"""Eine Regel pro Ereignistyp und Kanal."""
|
||||
|
||||
__tablename__ = "notification_rule"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
type: Mapped[NotificationType] = mapped_column(
|
||||
pg_enum(NotificationType, "notification_type"), nullable=False
|
||||
)
|
||||
lead_days: Mapped[int] = mapped_column(Integer, nullable=False, default=7)
|
||||
channel: Mapped[NotificationChannel] = mapped_column(
|
||||
pg_enum(NotificationChannel, "notification_channel"), nullable=False
|
||||
)
|
||||
target: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
logs: Mapped[list["NotificationLog"]] = relationship(
|
||||
back_populates="rule", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class NotificationLog(Base):
|
||||
"""Versandprotokoll. `dedupe_day` verhindert Doppelversand am selben Tag."""
|
||||
|
||||
__tablename__ = "notification_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
rule_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("notification_rule.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
ref_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
ref_id: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
dedupe_day: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
status: Mapped[NotificationStatus] = mapped_column(
|
||||
pg_enum(NotificationStatus, "notification_status"), nullable=False
|
||||
)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
rule: Mapped[NotificationRule] = relationship(back_populates="logs")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"rule_id", "ref_type", "ref_id", "dedupe_day", name="uq_notification_log_rule_id"
|
||||
),
|
||||
Index("ix_notification_log_sent_at", "sent_at"),
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""CLI: Stammdaten seeden – `python -m app.scripts.seed`."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from app.db.session import SessionLocal, engine
|
||||
from app.services.seed import seed_all
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with SessionLocal() as session:
|
||||
result = await seed_all(session)
|
||||
await engine.dispose()
|
||||
print(
|
||||
f"Seed fertig: {result['categories']} Kategorien, "
|
||||
f"{result['notification_rules']} Benachrichtigungsregeln neu angelegt."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -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