Files
moneyfy/backend/app/core/config.py
T
moneyfyandClaude Opus 5 b586d27b77 feat(api): Core-API mit Authentifizierung, CRUD und Monatsreport
- Anmeldung über Argon2id und JWT in httpOnly-Cookies, Refresh mit echter
  Rotation über die neue Tabelle refresh_token
- AuthProvider-Protokoll als Vorbereitung für OIDC, Administrator-Anlage beim
  Erststart mit erzwungenem Passwortwechsel
- CRUD für Konten, Kategorien (zweistufiger Baum), Firmen, Recurrences,
  Preisversionen, Buchungen, Budgets, Vorlagen und Sparziele
- Fälligkeiten mit Overlay-Logik: abrufen, bestätigen, auslassen, zurücksetzen
- Kontosalden zum Stichtag, Monatsübersicht mit Plan-Ist-Vergleich
- SECRET_KEY jetzt mindestens 32 Zeichen; Platzhalter in Produktion abgelehnt
- 61 neue Integrationstests, insgesamt 148 grün

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-09 13:40:37 +02:00

127 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()