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()
|
||||
|
||||
216
plugins/benachrichtigung/adapter.py
Normal file
216
plugins/benachrichtigung/adapter.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Adapter-Muster des Plugins „benachrichtigung“.
|
||||
|
||||
Ein Adapter kapselt genau einen Zustellkanal und implementiert `senden()`.
|
||||
Ist ein Kanal installationsweit nicht konfiguriert (z. B. kein SMTP-Host in
|
||||
der Entwicklung), tritt an seine Stelle ein Dev-Log-Adapter, der die
|
||||
Nachricht nur protokolliert — so geht in der Entwicklung keine
|
||||
Benachrichtigung verloren.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
import urllib.request
|
||||
from abc import ABC, abstractmethod
|
||||
from email.message import EmailMessage
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .konfiguration import Konfiguration
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
logger = logging.getLogger("redaktion.benachrichtigung")
|
||||
|
||||
|
||||
class BenachrichtigungsAdapter(ABC):
|
||||
"""Basis eines Kanal-Adapters.
|
||||
|
||||
`senden()` erhält alles Nötige vom Verteiler:
|
||||
user_id – Empfänger-Benutzer (für In-App-Persistenz)
|
||||
empfaenger – kanalspezifische Adresse (E-Mail bzw. Chat-ID), sonst None
|
||||
titel/text – Nachricht
|
||||
kategorie – freie Kategorie, z. B. "dedup" oder "erinnerung"
|
||||
"""
|
||||
|
||||
schluessel: str
|
||||
label: str
|
||||
#: True, wenn der Adapter nur protokolliert (Entwicklungs-Fallback).
|
||||
dev_fallback: bool = False
|
||||
|
||||
@abstractmethod
|
||||
async def senden(
|
||||
self,
|
||||
user_id: int,
|
||||
empfaenger: str | None,
|
||||
titel: str,
|
||||
text: str,
|
||||
kategorie: str,
|
||||
) -> None:
|
||||
"""Stellt die Nachricht über diesen Kanal zu."""
|
||||
|
||||
|
||||
class DevLogAdapter(BenachrichtigungsAdapter):
|
||||
"""Protokolliert Benachrichtigungen statt sie zu versenden."""
|
||||
|
||||
def __init__(self, schluessel: str, label: str) -> None:
|
||||
self.schluessel = schluessel
|
||||
self.label = label
|
||||
self.dev_fallback = True
|
||||
|
||||
async def senden(
|
||||
self,
|
||||
user_id: int,
|
||||
empfaenger: str | None,
|
||||
titel: str,
|
||||
text: str,
|
||||
kategorie: str,
|
||||
) -> None:
|
||||
logger.info(
|
||||
"[DEV] %s an Benutzer %s (%s): %s — %s",
|
||||
self.label,
|
||||
user_id,
|
||||
empfaenger or "ohne Adresse",
|
||||
titel,
|
||||
text,
|
||||
)
|
||||
|
||||
|
||||
class EmailSmtpAdapter(BenachrichtigungsAdapter):
|
||||
"""Versendet E-Mail über einen SMTP-Server (Konfiguration via Env)."""
|
||||
|
||||
schluessel = "email"
|
||||
label = "E-Mail"
|
||||
|
||||
def __init__(self, konfiguration: Konfiguration) -> None:
|
||||
self.konf = konfiguration
|
||||
|
||||
async def senden(
|
||||
self,
|
||||
user_id: int,
|
||||
empfaenger: str | None,
|
||||
titel: str,
|
||||
text: str,
|
||||
kategorie: str,
|
||||
) -> None:
|
||||
if not empfaenger:
|
||||
raise ValueError("Keine E-Mail-Adresse für den Empfänger hinterlegt.")
|
||||
await asyncio.to_thread(self._senden_sync, empfaenger, titel, text)
|
||||
|
||||
def _senden_sync(self, empfaenger: str, titel: str, text: str) -> None:
|
||||
nachricht = EmailMessage()
|
||||
nachricht["Subject"] = f"[Spiele-Redaktion] {titel}"
|
||||
nachricht["From"] = self.konf.smtp_absender
|
||||
nachricht["To"] = empfaenger
|
||||
nachricht.set_content(text)
|
||||
with _smtp_verbindung(self.konf) as server:
|
||||
server.send_message(nachricht)
|
||||
|
||||
|
||||
class TelegramAdapter(BenachrichtigungsAdapter):
|
||||
"""Versendet Nachrichten über die Telegram Bot-API (sendMessage)."""
|
||||
|
||||
schluessel = "telegram"
|
||||
label = "Telegram"
|
||||
|
||||
def __init__(self, bot_token: str) -> None:
|
||||
self.bot_token = bot_token
|
||||
|
||||
async def senden(
|
||||
self,
|
||||
user_id: int,
|
||||
empfaenger: str | None,
|
||||
titel: str,
|
||||
text: str,
|
||||
kategorie: str,
|
||||
) -> None:
|
||||
if not empfaenger:
|
||||
raise ValueError("Keine Telegram-Chat-ID für den Empfänger hinterlegt.")
|
||||
nachricht = f"{titel}\n\n{text}"
|
||||
await asyncio.to_thread(self._api_senden, empfaenger, nachricht)
|
||||
|
||||
def _api_senden(self, chat_id: str, nachricht: str) -> dict:
|
||||
"""Ein Aufruf der Bot-API; Test-Seam (wird in Tests überschrieben)."""
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
||||
daten = json.dumps({"chat_id": chat_id, "text": nachricht}).encode("utf-8")
|
||||
anfrage = urllib.request.Request(
|
||||
url, data=daten, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(anfrage, timeout=15) as antwort:
|
||||
ergebnis = json.load(antwort)
|
||||
if not ergebnis.get("ok"):
|
||||
raise RuntimeError(f"Telegram-API-Fehler: {ergebnis}")
|
||||
return ergebnis
|
||||
|
||||
|
||||
class InAppAdapter(BenachrichtigungsAdapter):
|
||||
"""Persistiert die Nachricht als In-App-Nachricht im Portal."""
|
||||
|
||||
schluessel = "inapp"
|
||||
label = "In-App"
|
||||
|
||||
def __init__(self, session_factory: "sessionmaker") -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
async def senden(
|
||||
self,
|
||||
user_id: int,
|
||||
empfaenger: str | None,
|
||||
titel: str,
|
||||
text: str,
|
||||
kategorie: str,
|
||||
) -> None:
|
||||
from .models import Benachrichtigung
|
||||
|
||||
def _schreiben() -> None:
|
||||
with self._session_factory() as db:
|
||||
db.add(
|
||||
Benachrichtigung(
|
||||
user_id=user_id, titel=titel, text=text, kategorie=kategorie
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
await asyncio.to_thread(_schreiben)
|
||||
|
||||
|
||||
def adapter_aus_konfiguration(
|
||||
konf: Konfiguration, session_factory: "sessionmaker"
|
||||
) -> dict[str, BenachrichtigungsAdapter]:
|
||||
"""Baut den Adapter-Satz der Installation; fehlende Konfiguration → Dev-Log."""
|
||||
email_adapter: BenachrichtigungsAdapter
|
||||
telegram_adapter: BenachrichtigungsAdapter
|
||||
if konf.smtp_konfiguriert:
|
||||
email_adapter = EmailSmtpAdapter(konf)
|
||||
else:
|
||||
email_adapter = DevLogAdapter("email", "E-Mail (nur Protokoll)")
|
||||
if konf.telegram_konfiguriert:
|
||||
telegram_adapter = TelegramAdapter(konf.telegram_bot_token)
|
||||
else:
|
||||
telegram_adapter = DevLogAdapter("telegram", "Telegram (nur Protokoll)")
|
||||
return {
|
||||
"email": email_adapter,
|
||||
"telegram": telegram_adapter,
|
||||
"inapp": InAppAdapter(session_factory),
|
||||
}
|
||||
|
||||
|
||||
def _smtp_verbindung(konf: Konfiguration):
|
||||
"""Öffnet eine SMTP-Verbindung gemäß TLS-Einstellung (Kontext-Manager)."""
|
||||
if konf.smtp_tls == "ssl":
|
||||
server = smtplib.SMTP_SSL(konf.smtp_host, konf.smtp_port, timeout=15)
|
||||
_anmelden(server, konf)
|
||||
return server
|
||||
verbindung = smtplib.SMTP(konf.smtp_host, konf.smtp_port, timeout=15)
|
||||
if konf.smtp_tls == "starttls":
|
||||
verbindung.starttls(context=ssl.create_default_context())
|
||||
_anmelden(verbindung, konf)
|
||||
return verbindung
|
||||
|
||||
|
||||
def _anmelden(server, konf: Konfiguration) -> None:
|
||||
if konf.smtp_benutzer and konf.smtp_passwort:
|
||||
server.login(konf.smtp_benutzer, konf.smtp_passwort)
|
||||
53
plugins/benachrichtigung/konfiguration.py
Normal file
53
plugins/benachrichtigung/konfiguration.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Plugin-Konfiguration über Umgebungsvariablen.
|
||||
|
||||
Der Plugin-Kontrakt verbietet Fachlogik im Kern — deshalb liest dieses
|
||||
Plugin seine Kanal-Konfiguration selbst aus der Umgebung, statt Felder in
|
||||
`redaktionskern.config.Settings` zu ergänzen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
def _env(name: str, standard: str = "") -> str:
|
||||
wert = os.environ.get(name)
|
||||
return wert.strip() if wert else standard
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Konfiguration:
|
||||
"""Zustellkanal-Einstellungen der Installation (nicht pro Benutzer)."""
|
||||
|
||||
smtp_host: str
|
||||
smtp_port: int
|
||||
smtp_benutzer: str
|
||||
smtp_passwort: str
|
||||
smtp_absender: str
|
||||
#: "starttls" (Standard), "ssl" oder "keine"
|
||||
smtp_tls: str
|
||||
telegram_bot_token: str
|
||||
|
||||
@property
|
||||
def smtp_konfiguriert(self) -> bool:
|
||||
return bool(self.smtp_host)
|
||||
|
||||
@property
|
||||
def telegram_konfiguriert(self) -> bool:
|
||||
return bool(self.telegram_bot_token)
|
||||
|
||||
|
||||
def aus_umgebung() -> Konfiguration:
|
||||
benutzer = _env("SPIELE_SMTP_BENUTZER")
|
||||
tls = _env("SPIELE_SMTP_TLS", "starttls").lower()
|
||||
if tls not in ("starttls", "ssl", "keine"):
|
||||
tls = "starttls"
|
||||
return Konfiguration(
|
||||
smtp_host=_env("SPIELE_SMTP_HOST"),
|
||||
smtp_port=int(_env("SPIELE_SMTP_PORT", "587")),
|
||||
smtp_benutzer=benutzer,
|
||||
smtp_passwort=_env("SPIELE_SMTP_PASSWORT"),
|
||||
smtp_absender=_env("SPIELE_SMTP_ABSENDER") or benutzer or "spiele-redaktion@localhost",
|
||||
smtp_tls=tls,
|
||||
telegram_bot_token=_env("SPIELE_TELEGRAM_BOT_TOKEN"),
|
||||
)
|
||||
80
plugins/benachrichtigung/models.py
Normal file
80
plugins/benachrichtigung/models.py
Normal 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)
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titel %}{{ titel }} — {{ app_name }}{% endblock %}
|
||||
{% block inhalt %}
|
||||
<h1 class="text-2xl font-bold mb-1">Benachrichtigungen — Einstellungen</h1>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Wählen Sie, über welche Kanäle Sie Benachrichtigungen erhalten.
|
||||
Mehrere Kanäle sind gleichzeitig möglich.
|
||||
</p>
|
||||
|
||||
<a href="/benachrichtigung" class="text-sm text-emerald-700 hover:underline">← Zurück zum Posteingang</a>
|
||||
|
||||
{% if gespeichert %}
|
||||
<div class="mt-4 mb-2 rounded-lg bg-emerald-50 border border-emerald-200 text-emerald-800 px-4 py-2 text-sm">
|
||||
Einstellungen gespeichert.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if fehler %}
|
||||
<div class="mt-4 mb-2 rounded-lg bg-red-50 border border-red-200 text-red-800 px-4 py-2 text-sm">
|
||||
{{ fehler }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/benachrichtigung/einstellungen"
|
||||
class="mt-4 bg-white rounded-xl border border-slate-200 p-5 max-w-2xl space-y-5">
|
||||
|
||||
<fieldset>
|
||||
<legend class="text-sm font-semibold mb-2">Kanäle</legend>
|
||||
<div class="space-y-2">
|
||||
{% for schluessel, status in kanal_status.items() %}
|
||||
<label class="flex items-start gap-3 p-2 rounded hover:bg-slate-50 cursor-pointer">
|
||||
<input type="checkbox" name="kanaele" value="{{ schluessel }}"
|
||||
{% if schluessel in gewaehlte %}checked{% endif %}
|
||||
class="mt-1 h-4 w-4 accent-emerald-600">
|
||||
<span>
|
||||
<span class="font-medium">{{ status.label }}</span>
|
||||
{% if status.aktiv %}
|
||||
<span class="ml-1 text-xs bg-emerald-100 text-emerald-700 rounded px-1.5 py-0.5">konfiguriert</span>
|
||||
{% else %}
|
||||
<span class="ml-1 text-xs bg-amber-100 text-amber-700 rounded px-1.5 py-0.5">nicht konfiguriert — nur Serverprotokoll</span>
|
||||
{% endif %}
|
||||
{% if schluessel == 'email' %}
|
||||
<span class="block text-sm text-slate-500">Versand über SMTP; ohne Mailserver erscheint die Nachricht im Protokoll.</span>
|
||||
{% elif schluessel == 'telegram' %}
|
||||
<span class="block text-sm text-slate-500">Bot-Token wird zentral gesetzt (Umgebungsvariable), die Chat-ID hinterlegen Sie hier.</span>
|
||||
{% elif schluessel == 'inapp' %}
|
||||
<span class="block text-sm text-slate-500">Nachricht erscheint im Posteingang des Portals.</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<label for="emailadresse" class="block text-sm font-semibold mb-1">E-Mail-Adresse</label>
|
||||
<input id="emailadresse" name="emailadresse" type="email" value="{{ emailadresse }}"
|
||||
placeholder="name@example.org"
|
||||
class="w-full rounded-lg border border-slate-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="telegram_chat_id" class="block text-sm font-semibold mb-1">Telegram-Chat-ID</label>
|
||||
<input id="telegram_chat_id" name="telegram_chat_id" value="{{ telegram_chat_id }}"
|
||||
placeholder="z. B. 123456789 (Gruppen mit Minus)"
|
||||
class="w-full rounded-lg border border-slate-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700">
|
||||
Speichern
|
||||
</button>
|
||||
<span class="text-xs text-slate-400">Ohne ausgewählten Kanal erhalten Sie keine Benachrichtigungen.</span>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,10 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
|
||||
{% block titel %}{{ titel }} — {{ app_name }}{% endblock %}
|
||||
{% block inhalt %}
|
||||
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
||||
<p class="text-slate-600 max-w-2xl">
|
||||
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
|
||||
in Version {{ version }} ist geladen.
|
||||
Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert.
|
||||
<div class="flex flex-wrap items-center gap-3 mb-1">
|
||||
<h1 class="text-2xl font-bold">{{ titel }}</h1>
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-medium
|
||||
{% if ungelesen %}bg-red-100 text-red-700{% else %}bg-slate-200 text-slate-500{% endif %}">
|
||||
{{ ungelesen }} ungelesen
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Zustellung aktuell über:
|
||||
{% if gewaehlte_kanaelle %}<span class="font-medium">{{ gewaehlte_kanaelle|join(", ") }}</span>
|
||||
{% else %}<span class="text-red-600">kein Kanal ausgewählt</span>{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
<form method="post" action="/benachrichtigung/alle-gelesen">
|
||||
<button type="submit" class="px-3 py-1.5 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700">
|
||||
Alle als gelesen markieren
|
||||
</button>
|
||||
</form>
|
||||
<a href="/benachrichtigung/einstellungen"
|
||||
class="px-3 py-1.5 rounded-lg border border-slate-300 bg-white text-sm font-medium hover:bg-slate-50">
|
||||
Kanäle & Einstellungen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if mitteilungen %}
|
||||
<div class="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
|
||||
{% for m in mitteilungen %}
|
||||
<div class="px-4 py-3 flex flex-col gap-1 {% if not m.gelesen %}border-l-4 border-l-emerald-500{% endif %}">
|
||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span class="{% if m.gelesen %}text-slate-600{% else %}font-semibold text-slate-900{% endif %}">{{ m.titel }}</span>
|
||||
<span class="text-xs uppercase tracking-wide bg-slate-100 text-slate-500 rounded px-1.5 py-0.5">{{ m.kategorie }}</span>
|
||||
<span class="text-xs text-slate-400 ml-auto">{{ m.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-600 whitespace-pre-line">{{ m.text }}</p>
|
||||
<div class="text-xs">
|
||||
{% if m.gelesen %}
|
||||
<span class="text-slate-400">Gelesen</span>
|
||||
{% else %}
|
||||
<form method="post" action="/benachrichtigung/{{ m.id }}/gelesen">
|
||||
<button type="submit" class="text-emerald-700 hover:underline">Als gelesen markieren</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-slate-500 bg-white rounded-xl border border-slate-200 px-4 py-8 text-center">
|
||||
Keine Benachrichtigungen vorhanden.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user