from email.utils import parseaddr from functools import lru_cache from typing import Literal from pydantic import field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=None, extra="ignore") # ---------------- Datenbank ---------------- db_host: str = "db" db_port: int = 3306 db_name: str = "einkaufsapp" db_user: str = "einkaufsapp" db_password: str = "" # ---------------- Anwendung ---------------- # Erscheint in der Oberflaeche, im Browsertitel, auf dem # Startbildschirm und in allen Mails. Eine Stelle fuer alles. app_name: str = "Einkaufsliste" # Kuerzere Fassung fuer das Symbol auf dem Startbildschirm - dort ist # nur wenig Platz. Leer = app_name. app_short_name: str = "" # Nachschlagen von Strichcodes in einer oeffentlichen Datenbank. # "off" aus - nur der eigene Artikelstamm # "openfoodfacts" Open Food Facts, ueber diesen Server abgefragt product_lookup: Literal["off", "openfoodfacts"] = "openfoodfacts" # Wie lange ein Treffer gilt, bevor erneut gefragt wird. product_cache_days: int = 180 # Wie lange ein Fehlschlag gilt. Kuerzer, weil ein Produkt spaeter # eingepflegt worden sein kann. product_miss_days: int = 14 product_lookup_timeout: int = 6 public_base_url: str = "http://localhost:8000" # Kein SECRET_KEY: Sitzungen und CSRF arbeiten mit serverseitig # gespeicherten Zufallstoken (32 Byte aus secrets.token_urlsafe), # nicht mit signierten Werten. Ein Signierschlüssel hätte hier keine # Aufgabe - und eine Einstellung, die Wichtigkeit vortäuscht, ist # schlechter als keine. Falls später etwas signiert werden soll, # gehört er wieder hinein. session_days: int = 30 cookie_secure: bool = False admin_email: str = "admin@example.com" # Nur beim allerersten Start verwendet - und auch dann nur, wenn noch # kein einziger Nutzer existiert. Das angelegte Konto muss das # Passwort bei der ersten Anmeldung wechseln, danach ist der Wert # hier wertlos und sollte entfernt werden. admin_initial_password: str = "" # Alternative fuer Docker Secrets: Pfad zu einer Datei, deren Inhalt # als Passwort dient. Hat Vorrang vor admin_initial_password. admin_initial_password_file: str = "" # "true" = Selbstregistrierung fest an, Admin kann nichts aendern # "false" = fest aus, Admin kann nichts aendern # "admin" = die Datenbankeinstellung entscheidet, Admin darf umschalten allow_self_registration: Literal["true", "false", "admin"] = "admin" # Startwert fuer den Modus "admin", greift nur beim allerersten Start. self_registration_default: bool = True # ---------------- Aufraeumen ---------------- # Wie lange weich geloeschte Daten aufbewahrt werden. Solange kann # ein Geraet offline bleiben und beim naechsten Abgleich noch # erfahren, dass etwas verschwunden ist. cleanup_deleted_days: int = 30 # Quittungen der Outbox. Kuerzer, weil ein Geraet seine Operationen # nach so langer Zeit ohnehin aufgegeben haette. cleanup_ops_days: int = 7 # Abstand zwischen zwei Durchlaeufen. cleanup_interval_hours: int = 24 # Startwerte fuer Fristen, die zur Laufzeit in der Verwaltung # geaendert werden koennen (siehe app/runtime_settings.py). invite_days: int = 14 welcome_days: int = 14 verify_hours: int = 24 reset_hours: int = 1 email_change_hours: int = 48 # ---------------- Push-Benachrichtigungen ---------------- # Erzeugen mit: python3 tools/vapid-keys.py # Leer = Push abgeschaltet. vapid_public_key: str = "" vapid_private_key: str = "" # Kontaktadresse fuer die Push-Dienste der Browserhersteller. vapid_subject: str = "" # Innerhalb dieser Spanne bekommt ein Geraet hoechstens eine # Benachrichtigung je Liste - nicht bei jeder einzelnen Aenderung. push_throttle_hours: int = 2 push_timeout: int = 10 # ---------------- SMTP-Relay ---------------- smtp_host: str = "mailpit" smtp_port: int = 1025 smtp_user: str = "" smtp_password: str = "" # "none" = unverschluesselt (nur fuer lokale Testrelays) # "starttls" = Klartextverbindung, dann Upgrade (typisch Port 587) # "ssl" = TLS von Anfang an, "implicit TLS" (typisch Port 465) smtp_security: Literal["none", "starttls", "ssl"] = "none" smtp_timeout: int = 20 # Hostname im EHLO. Viele Relays und Spamfilter erwarten hier einen # FQDN, der zur sendenden Domain passt - nicht die Container-ID. smtp_helo_hostname: str = "" # Absender in der Kopfzeile (das, was der Empfaenger sieht) smtp_from: str = "einkaufsapp@example.com" # Leer = app_name wird verwendet. smtp_from_name: str = "" # Envelope-Absender / Return-Path fuer Bounces. Leer = wie smtp_from. # SPF wird gegen DIESE Adresse geprueft, nicht gegen den From-Header. smtp_envelope_from: str = "" # Optional: Antworten sollen woanders hingehen als an den Absender. smtp_reply_to: str = "" smtp_max_retries: int = 3 @field_validator("allow_self_registration", mode="before") @classmethod def _normalize_registration(cls, v: object) -> str: s = str(v).strip().lower() if s in {"1", "yes", "on", "enabled"}: return "true" if s in {"0", "no", "off", "disabled"}: return "false" if s in {"", "db", "database", "runtime", "managed"}: return "admin" return s @model_validator(mode="after") def _check_mail(self) -> "Settings": if not parseaddr(self.smtp_from)[1]: raise ValueError("SMTP_FROM ist keine gueltige E-Mail-Adresse") if self.smtp_user and self.smtp_security == "none": raise ValueError( "SMTP_USER gesetzt, aber SMTP_SECURITY=none - " "Zugangsdaten duerfen nicht unverschluesselt uebertragen werden. " "Setze SMTP_SECURITY auf starttls oder ssl." ) return self @property def push_enabled(self) -> bool: return bool(self.vapid_public_key and self.vapid_private_key) @property def vapid_contact(self) -> str: # Die Push-Dienste verlangen eine erreichbare Kontaktangabe. # Ohne eigene Angabe die Absenderadresse verwenden. return self.vapid_subject or f"mailto:{self.envelope_from}" @property def from_name(self) -> str: return self.smtp_from_name or self.app_name @property def short_name(self) -> str: return self.app_short_name or self.app_name @property def initial_admin_password(self) -> str: """Passwort aus Datei (Docker Secret) oder Umgebungsvariable.""" if self.admin_initial_password_file: from pathlib import Path path = Path(self.admin_initial_password_file) if path.is_file(): return path.read_text(encoding="utf-8").strip() raise ValueError( f"ADMIN_INITIAL_PASSWORD_FILE zeigt auf {path}, " "die Datei existiert nicht." ) return self.admin_initial_password @property def envelope_from(self) -> str: return parseaddr(self.smtp_envelope_from or self.smtp_from)[1] @property def mail_domain(self) -> str: """Domain fuer die Message-ID. Sollte zur DKIM-signierten Domain passen, sonst werten manche Filter das ab.""" return self.envelope_from.rpartition("@")[2] or "localhost" @property def database_url(self) -> str: from urllib.parse import quote_plus return ( f"mysql+pymysql://{quote_plus(self.db_user)}:" f"{quote_plus(self.db_password)}@{self.db_host}:{self.db_port}/" f"{self.db_name}?charset=utf8mb4" ) @lru_cache def get_settings() -> Settings: return Settings() settings = get_settings()