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:
moneyfy
2026-09-09 13:12:08 +02:00
co-authored by Claude Opus 5
commit 0b06775be3
40 changed files with 2236 additions and 0 deletions
+113
View File
@@ -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()