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,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
|
||||
Reference in New Issue
Block a user