Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

196
backend/app/config.py Normal file
View 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()