197 lines
7.6 KiB
Python
197 lines
7.6 KiB
Python
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
|
|
|
|
# ---------------- 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()
|