Erste Produktivversion
This commit is contained in:
196
backend/app/config.py
Normal file
196
backend/app/config.py
Normal file
@@ -0,0 +1,196 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user