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:
@@ -1,41 +1,293 @@
|
||||
"""Plugin „benachrichtigung“ — Platzhalter gemäß Plugin-Vertrag.
|
||||
"""Plugin „benachrichtigung“ — Kanäle E-Mail, Telegram und In-App.
|
||||
|
||||
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
|
||||
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
|
||||
Architektur (Adapter-Muster):
|
||||
- `adapter.py` kapselt die Zustellkanäle. Fehlt die Konfiguration eines
|
||||
Kanals (z. B. kein SMTP-Host in der Entwicklung), protokolliert ein
|
||||
Dev-Log-Adapter die Nachricht, statt sie zu versenden.
|
||||
- Pro Benutzer speichern `models.Praeferenz` (gewählte Kanäle) und
|
||||
`models.Kontakt` (E-Mail-Adresse, Telegram-Chat-ID) die Einstellungen.
|
||||
- Andere Plugins rufen die öffentliche API auf:
|
||||
|
||||
plugin = request.app.state.registry.get("benachrichtigung")
|
||||
bericht = await plugin.send_notification(user, "Titel", "Text", "kategorie")
|
||||
|
||||
Der Kern bleibt unverändert; alle Logik liegt in diesem Plugin.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, Request
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from redaktionskern.auth.deps import require_user
|
||||
from fastapi import Depends, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from redaktionskern.auth.deps import get_db, require_user
|
||||
from redaktionskern.auth.models import User
|
||||
from redaktionskern.contracts import BasePlugin, NavEntry
|
||||
from redaktionskern.contracts import BasePlugin, Migration, NavEntry
|
||||
|
||||
from .adapter import BenachrichtigungsAdapter, adapter_aus_konfiguration
|
||||
from .konfiguration import aus_umgebung
|
||||
from .models import KANAELLE, STANDARD_KANAELLE, Benachrichtigung, Kontakt, Praeferenz, gueltige_reihenfolge
|
||||
|
||||
logger = logging.getLogger("redaktion.benachrichtigung")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Zustellbericht:
|
||||
"""Ergebnis eines send_notification-Aufrufs."""
|
||||
|
||||
zugestellt: list[str]
|
||||
fehlgeschlagen: list[str]
|
||||
|
||||
|
||||
def _tabellen_erstellen(conn) -> None:
|
||||
"""Eigene Migration des Plugins: legt alle drei Tabellen an."""
|
||||
Benachrichtigung.__table__.create(conn, checkfirst=True)
|
||||
Praeferenz.__table__.create(conn, checkfirst=True)
|
||||
Kontakt.__table__.create(conn, checkfirst=True)
|
||||
|
||||
|
||||
class BenachrichtigungPlugin(BasePlugin):
|
||||
name = "benachrichtigung"
|
||||
title = "Benachrichtigungen"
|
||||
description = "E-Mail, Telegram und In-App per Adapter-Muster (Platzhalter)."
|
||||
description = "E-Mail, Telegram und In-App — Kanal je Benutzer einstellbar."
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._adapters: dict[str, BenachrichtigungsAdapter] | None = None
|
||||
self._routen_registrieren()
|
||||
|
||||
# ---------------- Lifecycle ----------------
|
||||
|
||||
def on_load(self, context) -> None:
|
||||
super().on_load(context)
|
||||
self._adapters = adapter_aus_konfiguration(
|
||||
aus_umgebung(), context.session_factory
|
||||
)
|
||||
|
||||
@property
|
||||
def adapters(self) -> dict[str, BenachrichtigungsAdapter]:
|
||||
"""Kanal-Adapter der Installation (nach on_load verfügbar)."""
|
||||
if self._adapters is None:
|
||||
raise RuntimeError("Plugin ist nicht geladen (on_load fehlt).")
|
||||
return self._adapters
|
||||
|
||||
def migrations(self) -> list[Migration]:
|
||||
return [Migration(version="0001_tabellen", up=_tabellen_erstellen)]
|
||||
|
||||
def navigation(self) -> list[NavEntry]:
|
||||
return [NavEntry(label=self.title, url="/benachrichtigung")]
|
||||
|
||||
# ---------------- Öffentliche Plugin-API ----------------
|
||||
|
||||
async def send_notification(
|
||||
self,
|
||||
user: User,
|
||||
titel: str,
|
||||
text: str,
|
||||
kategorie: str = "allgemein",
|
||||
) -> Zustellbericht:
|
||||
"""Stellt eine Benachrichtigung über alle gewählten Kanäle zu.
|
||||
|
||||
Andere Plugins holen sich dieses Plugin über die Registry und rufen
|
||||
diese Methode. Die In-App-Nachricht wird persistiert; E-Mail/Telegram
|
||||
gehen an die im Profil hinterlegten Kontaktdaten. Fehlt einem Kanal
|
||||
die Empfängeradresse oder schlägt die Zustellung fehl, wird das
|
||||
protokolliert und im Bericht gemeldet — send_notification wirft nicht.
|
||||
"""
|
||||
assert self.context is not None
|
||||
with self.context.session_factory() as db:
|
||||
praeferenz = db.get(Praeferenz, user.id)
|
||||
kontakt = db.get(Kontakt, user.id)
|
||||
kanaele = (
|
||||
praeferenz.kanaele_als_liste()
|
||||
if praeferenz is not None
|
||||
else [STANDARD_KANAELLE]
|
||||
)
|
||||
|
||||
zugestellt: list[str] = []
|
||||
fehlgeschlagen: list[str] = []
|
||||
for schluessel in kanaele:
|
||||
adapter = self.adapters.get(schluessel)
|
||||
if adapter is None:
|
||||
continue
|
||||
empfaenger: str | None = None
|
||||
if schluessel == "email" and kontakt is not None:
|
||||
empfaenger = kontakt.email
|
||||
elif schluessel == "telegram" and kontakt is not None:
|
||||
empfaenger = kontakt.telegram_chat_id
|
||||
try:
|
||||
if schluessel != "inapp" and not empfaenger:
|
||||
raise ValueError(
|
||||
"Keine Empfängeradresse für diesen Kanal hinterlegt."
|
||||
)
|
||||
await adapter.senden(user.id, empfaenger, titel, text, kategorie)
|
||||
if adapter.dev_fallback:
|
||||
logger.info(
|
||||
"Kanal '%s' nur protokolliert (nicht konfiguriert): '%s'",
|
||||
schluessel,
|
||||
titel,
|
||||
)
|
||||
zugestellt.append(schluessel)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Zustellung über Kanal '%s' fehlgeschlagen: %s", schluessel, exc
|
||||
)
|
||||
fehlgeschlagen.append(schluessel)
|
||||
return Zustellbericht(zugestellt=zugestellt, fehlgeschlagen=fehlgeschlagen)
|
||||
|
||||
# ---------------- Routen ----------------
|
||||
|
||||
def _routen_registrieren(self) -> None:
|
||||
|
||||
@self.router.get("/benachrichtigung")
|
||||
def seite(request: Request, user: User = Depends(require_user)):
|
||||
"""Platzhalterseite des Plugins."""
|
||||
def posteingang(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
mitteilungen = db.scalars(
|
||||
select(Benachrichtigung)
|
||||
.where(Benachrichtigung.user_id == user.id)
|
||||
.order_by(Benachrichtigung.created_at.desc(), Benachrichtigung.id.desc())
|
||||
.limit(200)
|
||||
).all()
|
||||
ungelesen = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Benachrichtigung)
|
||||
.where(
|
||||
Benachrichtigung.user_id == user.id,
|
||||
Benachrichtigung.gelesen.is_(False),
|
||||
)
|
||||
)
|
||||
praef = db.get(Praeferenz, user.id)
|
||||
gewaehlte = (
|
||||
praeferenz.kanaele_als_liste()
|
||||
if praef is not None
|
||||
else [STANDARD_KANAELLE]
|
||||
)
|
||||
return self.context.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="benachrichtigung/index.html",
|
||||
context={
|
||||
"user": user,
|
||||
"titel": self.title,
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"mitteilungen": mitteilungen,
|
||||
"ungelesen": ungelesen or 0,
|
||||
"gewaehlte_kanaelle": [
|
||||
KANAELLE[k] for k in gueltige_reihenfolge(gewaehlte)
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
def navigation(self) -> list[NavEntry]:
|
||||
return [NavEntry(label=self.title, url="/benachrichtigung")]
|
||||
@self.router.post("/benachrichtigung/alle-gelesen")
|
||||
def alle_gelesen(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
db.query(Benachrichtigung).filter(
|
||||
Benachrichtigung.user_id == user.id,
|
||||
Benachrichtigung.gelesen.is_(False),
|
||||
).update({"gelesen": True}, synchronize_session=False)
|
||||
db.commit()
|
||||
return RedirectResponse("/benachrichtigung", status_code=303)
|
||||
|
||||
@self.router.post("/benachrichtigung/{id}/gelesen")
|
||||
def einzel_gelesen(
|
||||
id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
nachricht = db.scalar(
|
||||
select(Benachrichtigung).where(
|
||||
Benachrichtigung.id == id,
|
||||
Benachrichtigung.user_id == user.id,
|
||||
)
|
||||
)
|
||||
if nachricht is not None and not nachricht.gelesen:
|
||||
nachricht.gelesen = True
|
||||
db.commit()
|
||||
return RedirectResponse("/benachrichtigung", status_code=303)
|
||||
|
||||
@self.router.get("/benachrichtigung/einstellungen")
|
||||
def einstellungen_formular(
|
||||
request: Request,
|
||||
gespeichert: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
praef = db.get(Praeferenz, user.id)
|
||||
kontakt = db.get(Kontakt, user.id)
|
||||
gewaehlte = (
|
||||
praef.kanaele_als_liste()
|
||||
if praef is not None
|
||||
else [STANDARD_KANAELLE]
|
||||
)
|
||||
kanal_status = {
|
||||
schluessel: {
|
||||
"label": label,
|
||||
"aktiv": not self.adapters[schluessel].dev_fallback,
|
||||
}
|
||||
for schluessel, label in KANAELLE.items()
|
||||
}
|
||||
return self.context.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="benachrichtigung/einstellungen.html",
|
||||
context={
|
||||
"user": user,
|
||||
"titel": f"{self.title} — Einstellungen",
|
||||
"kanal_status": kanal_status,
|
||||
"gewaehlte": gewaehlte,
|
||||
"emailadresse": kontakt.email if kontakt else "",
|
||||
"telegram_chat_id": kontakt.telegram_chat_id if kontakt else "",
|
||||
"gespeichert": gespeichert,
|
||||
"fehler": request.session.pop("benachrichtigung_fehler", None),
|
||||
},
|
||||
)
|
||||
|
||||
@self.router.post("/benachrichtigung/einstellungen")
|
||||
def einstellungen_speichern(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
kanaele: list[str] = Form(default=[]),
|
||||
emailadresse: str = Form(""),
|
||||
telegram_chat_id: str = Form(""),
|
||||
):
|
||||
auswahl = gueltige_reihenfolge(
|
||||
[k for k in kanaele if k in KANAELLE]
|
||||
)
|
||||
emailadresse = emailadresse.strip()
|
||||
telegram_chat_id = telegram_chat_id.strip()
|
||||
fehler: str | None = None
|
||||
if emailadresse and ("@" not in emailadresse or " " in emailadresse):
|
||||
fehler = "Die E-Mail-Adresse sieht nicht gültig aus."
|
||||
if telegram_chat_id.lstrip("-").isdigit() is False and telegram_chat_id:
|
||||
fehler = "Die Telegram-Chat-ID darf nur Ziffern enthalten (Gruppen mit vorangestelltem Minus)."
|
||||
if fehler is not None:
|
||||
request.session["benachrichtigung_fehler"] = fehler
|
||||
return RedirectResponse(
|
||||
"/benachrichtigung/einstellungen", status_code=303
|
||||
)
|
||||
|
||||
kontakt = db.get(Kontakt, user.id)
|
||||
if kontakt is None:
|
||||
kontakt = Kontakt(user_id=user.id)
|
||||
db.add(kontakt)
|
||||
kontakt.email = emailadresse or None
|
||||
kontakt.telegram_chat_id = telegram_chat_id or None
|
||||
|
||||
praef = db.get(Praeferenz, user.id)
|
||||
if praef is None:
|
||||
praef = Praeferenz(user_id=user.id)
|
||||
db.add(praef)
|
||||
praef.kanaele = ",".join(auswahl) if auswahl else ""
|
||||
db.commit()
|
||||
return RedirectResponse(
|
||||
"/benachrichtigung/einstellungen?gespeichert=true", status_code=303
|
||||
)
|
||||
|
||||
|
||||
plugin = BenachrichtigungPlugin()
|
||||
|
||||
Reference in New Issue
Block a user