"""Zentrale Anwendungskonfiguration über Umgebungsvariablen.""" from functools import lru_cache from pathlib import Path from typing import Literal from pydantic import Field, PostgresDsn, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict # Erkennbarer Platzhalter: erlaubt lokale Starts, ist in Produktion aber verboten. PLACEHOLDER_SECRET = "bitte-aendern-" + "0" * 32 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 ---------------------------------------------------------- # HS256 verlangt mindestens 32 Byte Schlüsselmaterial (RFC 7518, Abschnitt 3.2). secret_key: str = Field(default=PLACEHOLDER_SECRET, min_length=32) 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 @model_validator(mode="after") def _reject_placeholder_secret(self) -> "Settings": """In Produktion muss ein eigener Signaturschlüssel gesetzt sein.""" if self.environment == "production" and self.secret_key == PLACEHOLDER_SECRET: raise ValueError( "SECRET_KEY ist nicht gesetzt. Einen Schlüssel erzeugen mit: openssl rand -hex 32" ) return self @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()