Der Versionshinweis im README stand noch auf 0.1.0 und wird mitgezogen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fj7mB1PGA1aDHyfdSGHgzD
129 lines
4.6 KiB
Python
129 lines
4.6 KiB
Python
"""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.2"
|
||
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
|
||
# Steuert, ob nach dem Anlegen einer Firma automatisch im Hintergrund gesucht wird.
|
||
logo_auto_resolve: bool = True
|
||
|
||
# --- 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()
|