Plugin benachrichtigung: E-Mail, Telegram, In-App per Adapter-Muster

- Adapter-Pattern mit drei Kanaelen: E-Mail (SMTP per Env, TLS starttls/ssl,
  Dev-Fallback: Protokoll), Telegram (Bot-API, Token per Env, Chat-ID pro
  Benutzer), In-App (persistente Nachrichten mit Unread-Counter und
  'Alle als gelesen markieren')
- Pro Benutzer Kanal-Praeferenzen (Einstellungsseite, mehrere Kanaele
  gleichzeitig) plus eigene Kontakt-Tabelle (E-Mail-Adresse, Chat-ID);
  Kern und users-Tabelle unveraendert
- Oeffentliche Plugin-API: await send_notification(user, titel, text,
  kategorie) - andere Plugins holen das Plugin ueber app.state.registry
- Eigene Migration (0001_tabellen), eigene Routen/Templates, deutsche UI
- Tests: Adapter-Auswahl nach Praeferenz, In-App-Persistenz, Dev-Log-
  Adapter, SMTP-Versand (gemockt), Telegram-API-Aufruf, Einstellungsseite
- README: Plugin-Doku + neue Env-Variablen; docker-compose: Platzhalter
This commit is contained in:
Flo Hartmann
2026-08-21 19:00:06 +00:00
parent 7edf80d01c
commit f51732bff1
9 changed files with 1286 additions and 28 deletions

View File

@@ -0,0 +1,80 @@
"""Datenmodelle des Plugins „benachrichtigung“.
Alle Tabellen tragen den Präfix `benachrichtigung_` und nutzen portable
Spaltentypen (SQLite ↔ Postgres). Der Kern bleibt unverändert — auch die
Benutzer-Tabelle wird nicht angetastet; Kanal-Kontaktdaten (E-Mail-Adresse,
Telegram-Chat-ID) liegen in der eigenen Kontakt-Tabelle.
`extend_existing` erlaubt das erneute Ausführen des Moduls durch den
Plugin-Loader (jeder App-Start lädt das Plugin frisch bzw. Tests können es
über einen zweiten Pfad importieren), ohne dass die gemeinsame Metadata
doppelt definierte Tabellen meldet. Die Modelle definieren bewusst keine
ORM-Indizes: Bei erneuter Ausführung würde ein deklarativ definierter Index
doppelt im Table-Objekt landen und die Migration würde ihn zweimal anlegen.
Die Datenmengen sind klein — auf Indizes wird verzichtet.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from redaktionskern.db import Base
#: Gültige Kanal-Schlüssel (Reihenfolge = Anzeigereihenfolge im Formular).
KANAELLE: dict[str, str] = {
"email": "E-Mail",
"telegram": "Telegram",
"inapp": "In-App",
}
STANDARD_KANAELLE = "inapp"
class Benachrichtigung(Base):
"""Eine In-App-Nachricht im Portal (Posteingang des Benutzers)."""
__tablename__ = "benachrichtigung"
__table_args__ = {"extend_existing": True}
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
titel: Mapped[str] = mapped_column(String(300))
text: Mapped[str] = mapped_column(Text)
kategorie: Mapped[str] = mapped_column(String(100), default="allgemein")
gelesen: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
class Praeferenz(Base):
"""Kanal-Präferenzen eines Benutzers (mehrere Kanäle gleichzeitig möglich)."""
__tablename__ = "benachrichtigung_praeferenz"
__table_args__ = {"extend_existing": True}
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True)
#: Komma-separierte Kanal-Schlüssel, z. B. "email,inapp".
kanaele: Mapped[str] = mapped_column(String(200), default=STANDARD_KANAELLE)
aktualisiert_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)
def kanaele_als_liste(self) -> list[str]:
gueltig = [k for k in self.kanaele.split(",") if k in KANAELLE]
return gueltige_reihenfolge(gueltig)
def gueltige_reihenfolge(auswahl: list[str]) -> list[str]:
"""Sortiert eine Kanal-Auswahl in die definierte Anzeigereihenfolge."""
return [k for k in KANAELLE if k in auswahl]
class Kontakt(Base):
"""Kanal-spezifische Kontaktdaten eines Benutzers (pro Benutzer höchstens eine Zeile)."""
__tablename__ = "benachrichtigung_kontakt"
__table_args__ = {"extend_existing": True}
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True)
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
telegram_chat_id: Mapped[str | None] = mapped_column(String(50), nullable=True)