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

108
README.md
View File

@@ -1,8 +1,9 @@
# Spiele-Redaktion
**KI-Assistenz für Spielemagazin-Redaktionen** — Multi-User-Webanwendung mit
modularer Plugin-Architektur. Phase 1: lauffähiger Kern mit Plugin-System,
Authentifizierung/Rollen, Migrationen und Plugin-Stubs.
modularer Plugin-Architektur. Lauffähiger Kern mit Plugin-System,
Authentifizierung/Rollen und Migrationen; als erstes Fachplugin ist das
**Audit-Log** vollständig implementiert, weitere Plugins folgen.
## Stack
@@ -39,6 +40,14 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt:
| `SPIELE_SESSION_DAUER` | `43200` | Session-Dauer in Sekunden (12 h) |
| `SPIELE_INITIAL_ADMIN_PASSWORD` | `admin` | Passwort des initialen Admins |
| `SPIELE_PLUGINS_DIR` | `<Projekt>/plugins` | Pfad zum lokalen Plugin-Verzeichnis |
| `SPIELE_SMTP_HOST` | *(leer)* | SMTP-Server für den E-Mail-Kanal; leer = Dev-Fallback (nur Protokoll) |
| `SPIELE_SMTP_PORT` | `587` | SMTP-Port |
| `SPIELE_SMTP_BENUTZER` | *(leer)* | SMTP-Login (optional) |
| `SPIELE_SMTP_PASSWORT` | *(leer)* | SMTP-Passwort (optional) |
| `SPIELE_SMTP_ABSENDER` | Benutzer bzw. `spiele-redaktion@localhost` | From-Adresse |
| `SPIELE_SMTP_TLS` | `starttls` | `starttls`, `ssl` oder `keine` |
| `SPIELE_TELEGRAM_BOT_TOKEN` | *(leer)* | Bot-Token für den Telegram-Kanal; leer = Dev-Fallback (nur Protokoll) |
## Tests
@@ -46,9 +55,11 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt:
uv run pytest
```
Abgedeckt: Plugin-Loader lädt alle Stubs (inkl. Lifecycle-Hooks und
Abgedeckt: Plugin-Loader lädt alle Plugins (inkl. Lifecycle-Hooks und
Entry-Point-Pfad), Migrations-Laufzeit inkl. Idempotenz, Login/Logout,
Rollen-Zugriff (admin/redakteur vs. rezensent), Benutzerverwaltung.
Rollen-Zugriff (admin/redakteur vs. rezensent), Benutzerverwaltung sowie das
Audit-Log-Plugin (Logging-Funktion, Migration, Filter, Paginierung,
Nur-Admin-Zugriff).
## Architektur
@@ -62,10 +73,11 @@ src/redaktionskern/ schlanker Kern — KEINE Fachlogik
├── contracts.py Plugin-Vertrag (BasePlugin, Migration, NavEntry, PluginContext)
└── auth/ Login/Logout, Rollen, Benutzerverwaltung
plugins/ ein Ordner pro Funktion (Phase-1: Stubs, ladbar)
├── neuheiten/ dedup/ planung/ archiv/
├── erinnerung/ benachrichtigung/ audit-log/ export/
── … je __init__.py + templates/<name>/index.html
plugins/ ein Ordner pro Funktion, ladbar über den Plugin-Loader
├── audit-log/ VOLL IMPLEMENTIERT: Model, Migration, API, Admin-Ansicht
├── neuheiten/ benachrichtigung/ (in Arbeit)
── dedup/ planung/ archiv/ erinnerung/ export/ (Stubs, ladbar)
└── … je __init__.py + templates/<name>/
```
### Der Plugin-Vertrag
@@ -106,6 +118,84 @@ Session-Fabrik. Migrationen laufen transaktional und werden pro Plugin in
`schema_migrations` protokolliert; die `up`-Funktionen bekommen eine
SQLAlchemy-Connection und können bei Dialekt-Unterschieden verzweigen.
## Plugin „Audit-Log“ (implementiert)
Protokolliert, wer was wann verschoben, eingetragen oder geändert hat.
### Datentabelle
Eigene Migration `0001_audit_eintraege`, Tabelle `audit_eintraege`
(SQLite ↔ Postgres portabel):
| Spalte | Inhalt |
|--------|--------|
| `actor_id` / `actor_name` | Wer (Benutzer-ID/-Name; `System` bei automatischen Ereignissen) |
| `action` | Was (`erstellt`, `verschoben`, `geaendert`, `geloescht`, `benachrichtigt`) |
| `objekt_typ` / `objekt_id` | Betroffenes Objekt (ID als Text) |
| `alt` / `neu` | Alt-/Neustand als JSON (optional) |
| `details` | Freie Zusatzinformationen als JSON (optional) |
| `ip_adresse` | Herkunfts-IP (optional) |
| `erstellt_am` | Zeitstempel |
### Öffentliche API für andere Plugins
Andere Plugins rufen das Audit-Log über die Plugin-Registry bei jedem
relevanten Ereignis auf:
```python
audit = request.app.state.registry.get("audit-log")
if audit is not None:
await audit.log( # async-Variante
user, # User-Objekt, Benutzername oder None (= System)
"geaendert", # Aktion (Konstanten: AKTIONEN_ANZEIGE im Plugin)
"planungseintrag", # Objekttyp
eintrag.id, # Objekt-ID
details={"alt": alt, "neu": neu}, # „alt“/„neu“ → JSON-Spalten, Rest → details
ip_adresse=request.client.host, # optional
)
```
In synchronen Routen (`def`, FastAPI-Threadpool) steht `audit.log_sync(...)`
mit denselben Argumenten bereit.
### Admin-Ansicht
Unter `/audit-log` (nur Rolle **Admin**; Rezensenten/Redakteure erhalten 403,
Anonyme werden zum Login umgeleitet): filterbar nach Benutzer, Aktionstyp und
Zeitraum (Von/Bis), paginiert (25 Einträge pro Seite), neueste zuerst.
## Plugin „benachrichtigung“
Erstes fachlich umgesetztes Plugin (Adapter-Muster, drei Kanäle):
| Kanal | Zustellung | Empfängerdaten |
|-------|------------|----------------|
| **E-Mail** | SMTP (`smtplib`, TLS: STARTTLS/SSL), konfigurierbar per Env | E-Mail-Adresse pro Benutzer |
| **Telegram** | Bot-API (`sendMessage`) | Chat-ID pro Benutzer, Token zentral per Env |
| **In-App** | Persistente Nachrichten im Portal mit Unread-Counter und „Alle als gelesen markieren“ | — |
Ist ein Kanal installationsweit nicht konfiguriert (kein SMTP-Host bzw.
kein Bot-Token — der Normalfall in der Entwicklung), protokolliert ein
**Dev-Log-Adapter** die Nachricht auf dem Server, statt sie zu versenden;
in der Entwicklung geht so keine Benachrichtigung verloren.
Jeder Benutzer wählt unter **Benachrichtigungen → Kanäle &amp; Einstellungen**
seine Kanäle (mehrere gleichzeitig möglich) und hinterlegt E-Mail-Adresse
bzw. Telegram-Chat-ID. Ohne gespeicherte Präferenz wird In-App zugestellt.
### Öffentliche Plugin-API für andere Plugins
```python
plugin = request.app.state.registry.get("benachrichtigung")
bericht = await plugin.send_notification(user, "Titel", "Nachrichtentext", "kategorie")
# bericht.zugestellt -> z. B. ["email", "inapp"]
# bericht.fehlgeschlagen -> Kanäle ohne Adresse/Fehler
```
`send_notification(user, titel, text, kategorie="allgemein")` wirft nicht;
einzelne Kanalausfälle werden protokolliert und im Bericht gemeldet. Die
In-App-Nachricht landet im Posteingang des Benutzers.
## Deployment (später)
Docker/Podman Compose ist vorgesehen (Henry-Lab, danach Kundenhardware).
@@ -120,7 +210,7 @@ geschrieben.
|---|----------|--------|
| 1 | Kern: FastAPI, Plugin-System, DB/Migrationen, Auth/Rollen, HTMX-Layout | ✅ fertig (28 Tests grün) |
| 2 | Plugin `benachrichtigung` (E-Mail/Telegram/In-App, User-Präferenzen) | 🔄 in Arbeit |
| 3 | Plugin `audit-log` (Wer/Was/Wann, Admin-Ansicht) | 🔄 in Arbeit |
| 3 | Plugin `audit-log` (Wer/Was/Wann, Admin-Ansicht, öffentliche API) | ✅ fertig |
| 4 | Plugin `neuheiten` (BGG-Sync, APScheduler, Filter) | 🔄 in Arbeit |
| 5 | Plugin `dedup` (Verlags-Konflikt, deutsche Version, Vorgänger-/Planungs-Check) | ⏳ offen |
| 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ⏳ offen |

View File

@@ -8,6 +8,13 @@ services:
- SPIELE_SESSION_SECRET=${SPIELE_SESSION_SECRET:-change-me-in-production}
# Vor dem ersten Start setzen, sonst wird der Admin mit "admin" angelegt:
- SPIELE_INITIAL_ADMIN_PASSWORD=${SPIELE_INITIAL_ADMIN_PASSWORD:-admin}
# Benachrichtigungen (optional): ohne SMTP-Host/Bot-Token wird nur protokolliert
#- SPIELE_SMTP_HOST=smtp.example.org
#- SPIELE_SMTP_PORT=587
#- SPIELE_SMTP_BENUTZER=bot@example.org
#- SPIELE_SMTP_PASSWORT=geheim
#- SPIELE_SMTP_ABSENDER=redaktion@example.org
#- SPIELE_TELEGRAM_BOT_TOKEN=123456:ABC-DEF
volumes:
- ./data:/app/data
networks:

View File

@@ -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()

View 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)

View 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"),
)

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)

View File

@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block titel %}{{ titel }} &mdash; {{ app_name }}{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-1">Benachrichtigungen &mdash; Einstellungen</h1>
<p class="text-slate-600 mb-4">
W&auml;hlen Sie, &uuml;ber welche Kan&auml;le Sie Benachrichtigungen erhalten.
Mehrere Kan&auml;le sind gleichzeitig m&ouml;glich.
</p>
<a href="/benachrichtigung" class="text-sm text-emerald-700 hover:underline">&larr; Zur&uuml;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&auml;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 &mdash; nur Serverprotokoll</span>
{% endif %}
{% if schluessel == 'email' %}
<span class="block text-sm text-slate-500">Versand &uuml;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&auml;hlten Kanal erhalten Sie keine Benachrichtigungen.</span>
</div>
</form>
{% endblock %}

View File

@@ -1,10 +1,56 @@
{% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block titel %}{{ titel }} &mdash; {{ 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 &mdash; die Funktion wird in einer sp&auml;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 &uuml;ber:
{% if gewaehlte_kanaelle %}<span class="font-medium">{{ gewaehlte_kanaelle|join(", ") }}</span>
{% else %}<span class="text-red-600">kein Kanal ausgew&auml;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&auml;le &amp; 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 %}

View File

@@ -0,0 +1,439 @@
"""Tests: Plugin „benachrichtigung“ — Adapter-Auswahl, In-App-Persistenz,
Dev-Log-Fallback, Einstellungsseite und öffentliche API."""
from __future__ import annotations
import asyncio
import logging
import smtplib
from sqlalchemy import select
from redaktionskern.auth.models import User
from redaktionskern.config import Settings
from plugins.benachrichtigung import BenachrichtigungPlugin
from plugins.benachrichtigung.adapter import DevLogAdapter, EmailSmtpAdapter, TelegramAdapter
from plugins.benachrichtigung.models import Benachrichtigung, Kontakt, Praeferenz
from tests.conftest import melde_an
def hole_plugin(app):
plugin = app.state.registry.get("benachrichtigung")
assert plugin is not None, "Plugin 'benachrichtigung' ist nicht geladen"
return plugin
def adapter_modul(plugin):
"""Adapter-Modul der geladenen Plugin-Instanz.
Der Loader führt Plugins unter dem Namensraum
`spiele_redaktion_plugins.…` aus; Klassen-Identität nur gegen dieses
Modul prüfen, nicht gegen den Importpfad `plugins.…`.
"""
import sys
return sys.modules[f"{type(plugin).__module__}.adapter"]
def hole_benutzer(app, username: str = "admin") -> User:
with app.state.session_factory() as db:
return db.scalar(select(User).where(User.username == username))
def praeferez_setzen(app, user_id: int, kanaele: str) -> None:
with app.state.session_factory() as db:
praef = db.get(Praeferenz, user_id)
if praef is None:
praef = Praeferenz(user_id=user_id)
db.add(praef)
praef.kanaele = kanaele
db.commit()
def kontakt_setzen(app, user_id: int, **felder) -> None:
with app.state.session_factory() as db:
kontakt = db.get(Kontakt, user_id)
if kontakt is None:
kontakt = Kontakt(user_id=user_id)
db.add(kontakt)
for feld, wert in felder.items():
setattr(kontakt, feld, wert)
db.commit()
class AufzeichnenderAdapter(DevLogAdapter):
"""Test-Adapter: zeichnet Aufrufe auf, stellt nichts zu."""
aufrufe: list[tuple]
def __init__(self, schluessel: str) -> None:
super().__init__(schluessel, schluessel)
self.aufrufe = []
async def senden(self, user_id, empfaenger, titel, text, kategorie):
self.aufrufe.append((self.schluessel, user_id, empfaenger, titel))
# ---------------- Laden & Migration ----------------
def test_plugin_geladen_mit_migration_und_navigation(app):
plugin = hole_plugin(app)
assert plugin.name == "benachrichtigung"
assert any(e.url == "/benachrichtigung" for e in plugin.navigation())
from sqlalchemy import text
with app.state.engine.connect() as conn:
zeilen = conn.execute(
text("SELECT version FROM schema_migrations WHERE plugin='benachrichtigung'")
).fetchall()
tabellen = {
z[0]
for z in conn.execute(text("SELECT name FROM sqlite_master WHERE type='table'"))
}
assert [z[0] for z in zeilen] == ["0001_tabellen"]
assert {
"benachrichtigung",
"benachrichtigung_praeferenz",
"benachrichtigung_kontakt",
} <= tabellen
def test_seiten_erfordern_anmeldung(client):
for pfad in ("/benachrichtigung", "/benachrichtigung/einstellungen"):
antwort = client.get(pfad, follow_redirects=False)
assert antwort.status_code == 303
assert antwort.headers["location"] == "/login"
# ---------------- Öffentliche API & Adapter-Auswahl ----------------
def test_in_app_persistenz_als_standard(client):
"""Ohne Präferenz wird In-App zugestellt (Standard-Kanal) und persistiert."""
app = client.app
plugin = hole_plugin(app)
benutzer = hole_benutzer(app)
bericht = asyncio.run(
plugin.send_notification(benutzer, "Doppelter Titel", "BGG-Treffer", "dedup")
)
assert bericht.zugestellt == ["inapp"]
assert bericht.fehlgeschlagen == []
with app.state.session_factory() as db:
nachrichten = db.scalars(select(Benachrichtigung)).all()
assert len(nachrichten) == 1
n = nachrichten[0]
assert n.user_id == benutzer.id
assert n.titel == "Doppelter Titel"
assert n.text == "BGG-Treffer"
assert n.kategorie == "dedup"
assert n.gelesen is False
def test_adapter_auswahl_nach_praeferenz(client):
"""Nur die gewählten Kanäle werden angesprochen — auch mehrere gleichzeitig."""
app = client.app
plugin = hole_plugin(app)
benutzer = hole_benutzer(app)
email_adapter = AufzeichnenderAdapter("email")
telegram_adapter = AufzeichnenderAdapter("telegram")
inapp_adapter = AufzeichnenderAdapter("inapp")
plugin._adapters = {
"email": email_adapter,
"telegram": telegram_adapter,
"inapp": inapp_adapter,
}
# Nur E-Mail gewählt → kein In-App-Eintrag.
praeferez_setzen(app, benutzer.id, "email")
kontakt_setzen(app, benutzer.id, email="redakteur@example.org")
bericht = asyncio.run(plugin.send_notification(benutzer, "T1", "Text", "test"))
assert bericht.zugestellt == ["email"]
assert email_adapter.aufrufe == [
("email", benutzer.id, "redakteur@example.org", "T1")
]
assert inapp_adapter.aufrufe == []
with app.state.session_factory() as db:
assert db.scalars(select(Benachrichtigung)).all() == []
# Telegram + In-App gleichzeitig → beide bedient.
praeferez_setzen(app, benutzer.id, "telegram,inapp")
kontakt_setzen(app, benutzer.id, telegram_chat_id="12345")
bericht = asyncio.run(plugin.send_notification(benutzer, "T2", "Text", "test"))
assert sorted(bericht.zugestellt) == ["inapp", "telegram"]
assert telegram_adapter.aufrufe == [("telegram", benutzer.id, "12345", "T2")]
assert len(inapp_adapter.aufrufe) == 1
def test_fehlende_empfaengeradresse_faellt_kanalweise_aus(client):
"""E-Mail gewählt, aber keine Adresse → Kanal fällt aus, In-App bleibt."""
app = client.app
plugin = hole_plugin(app)
benutzer = hole_benutzer(app)
praeferez_setzen(app, benutzer.id, "email,inapp")
bericht = asyncio.run(plugin.send_notification(benutzer, "T", "X", "test"))
assert bericht.zugestellt == ["inapp"]
assert bericht.fehlgeschlagen == ["email"]
def test_leere_kanal_auswahl_zustellt_nichts(client):
app = client.app
plugin = hole_plugin(app)
benutzer = hole_benutzer(app)
praeferez_setzen(app, benutzer.id, "")
bericht = asyncio.run(plugin.send_notification(benutzer, "T", "X", "test"))
assert bericht.zugestellt == []
with app.state.session_factory() as db:
assert db.scalars(select(Benachrichtigung)).all() == []
# ---------------- Dev-Log-Adapter ----------------
def test_dev_log_adapter_ohne_smtp_konfiguration(client, caplog):
"""Ohne SPIELE_SMTP_HOST protokolliert der E-Mail-Kanal nur (dev-Fallback)."""
app = client.app
plugin = hole_plugin(app)
benutzer = hole_benutzer(app)
kontakt_setzen(app, benutzer.id, email="chef@example.org")
praeferez_setzen(app, benutzer.id, "email")
DevLogAdapterGeladen = adapter_modul(plugin).DevLogAdapter
assert isinstance(plugin.adapters["email"], DevLogAdapterGeladen)
assert plugin.adapters["email"].dev_fallback is True
with caplog.at_level(logging.INFO, logger="redaktion.benachrichtigung"):
bericht = asyncio.run(
plugin.send_notification(
benutzer, "Erinnerung", "Deadline naht", "erinnerung"
)
)
assert bericht.zugestellt == ["email"]
texte = [eintrag.getMessage() for eintrag in caplog.records]
assert any("chef@example.org" in t and "Erinnerung" in t for t in texte)
def test_smtp_adapter_wird_bei_konfiguration_gewaehlt(tmp_path, monkeypatch):
"""Mit SPIELE_SMTP_HOST wird der echte SMTP-Adapter gebaut und versendet."""
monkeypatch.setenv("SPIELE_SMTP_HOST", "mail.example.org")
monkeypatch.setenv("SPIELE_SMTP_BENUTZER", "bot@example.org")
monkeypatch.setenv("SPIELE_SMTP_PASSWORT", "geheim")
gesendet: list = []
class FakeSMTP:
letzte_instanz: "FakeSMTP | None" = None
def __init__(self, host, port, timeout=None):
self.host = host
self.tls = False
FakeSMTP.letzte_instanz = self
def starttls(self, context=None):
self.tls = True
def login(self, user, passwort):
self.login_user = user
def send_message(self, nachricht):
gesendet.append(nachricht)
def __enter__(self):
return self
def __exit__(self, *args):
return False
monkeypatch.setattr(smtplib, "SMTP", FakeSMTP)
settings = Settings(
database_url=f"sqlite:///{tmp_path / 'smtp.db'}",
session_secret="s",
initial_admin_password="x",
plugins_dir=None,
)
plugin = BenachrichtigungPlugin()
plugin.on_load(_mini_kontext(settings))
assert isinstance(plugin.adapters["email"], EmailSmtpAdapter)
assert plugin.adapters["telegram"].dev_fallback is True # ohne Token
with plugin.context.session_factory() as db:
db.add(Praeferenz(user_id=1, kanaele="email"))
db.add(Kontakt(user_id=1, email="ziel@example.org"))
db.commit()
benutzer = User(id=1, username="x", display_name="x", password_hash="x")
bericht = asyncio.run(plugin.send_notification(benutzer, "Betreff", "Inhaltstext"))
assert bericht.zugestellt == ["email"]
assert len(gesendet) == 1
assert gesendet[0]["To"] == "ziel@example.org"
assert gesendet[0]["Subject"] == "[Spiele-Redaktion] Betreff"
server = FakeSMTP.letzte_instanz
assert server is not None
assert server.host == "mail.example.org"
assert server.tls is True
assert server.login_user == "bot@example.org"
def test_telegram_adapter_ruft_bot_api_mit_chat_id(client, monkeypatch):
"""Telegram-Adapter übergibt Chat-ID und Nachricht an die Bot-API."""
app = client.app
plugin = hole_plugin(app)
benutzer = hole_benutzer(app)
api_aufrufe: list[tuple[str, str]] = []
adapter = TelegramAdapter("123:testtoken")
def fake_api(chat_id: str, nachricht: str) -> dict:
api_aufrufe.append((chat_id, nachricht))
return {"ok": True}
monkeypatch.setattr(adapter, "_api_senden", fake_api)
plugin._adapters["telegram"] = adapter
praeferez_setzen(app, benutzer.id, "telegram")
kontakt_setzen(app, benutzer.id, telegram_chat_id="-100999")
bericht = asyncio.run(
plugin.send_notification(benutzer, "Titel TG", "Text TG", "test")
)
assert bericht.zugestellt == ["telegram"]
assert api_aufrufe == [("-100999", "Titel TG\n\nText TG")]
# ---------------- Posteingang: Unread-Counter & alle gelesen ----------------
def test_posteingang_unread_counter_und_alle_gelesen(client):
app = client.app
plugin = hole_plugin(app)
benutzer = hole_benutzer(app)
melde_an(client)
for i in range(3):
asyncio.run(plugin.send_notification(benutzer, f"M{i}", "Inhalt", "test"))
seite = client.get("/benachrichtigung")
assert seite.status_code == 200
assert "3 ungelesen" in seite.text
for i in range(3):
assert f"M{i}" in seite.text
antwort = client.post("/benachrichtigung/alle-gelesen", follow_redirects=False)
assert antwort.status_code == 303
with app.state.session_factory() as db:
rest = db.scalars(
select(Benachrichtigung).where(Benachrichtigung.gelesen.is_(False))
).all()
assert rest == []
assert "0 ungelesen" in client.get("/benachrichtigung").text
def test_einzelne_nachricht_als_gelesen_markieren(client):
app = client.app
plugin = hole_plugin(app)
benutzer = hole_benutzer(app)
melde_an(client)
asyncio.run(plugin.send_notification(benutzer, "Nur eine", "Inhalt", "test"))
with app.state.session_factory() as db:
id = db.scalar(select(Benachrichtigung.id).limit(1))
antwort = client.post(f"/benachrichtigung/{id}/gelesen", follow_redirects=False)
assert antwort.status_code == 303
with app.state.session_factory() as db:
assert db.get(Benachrichtigung, id).gelesen is True
# ---------------- Einstellungsseite ----------------
def test_einstellungen_speichern_und_laden(client):
app = client.app
benutzer = hole_benutzer(app)
melde_an(client)
formular = client.get("/benachrichtigung/einstellungen")
assert formular.status_code == 200
assert 'name="kanaele" value="email"' in formular.text
assert "Telegram-Chat-ID" in formular.text
antwort = client.post(
"/benachrichtigung/einstellungen",
data={
"kanaele": ["email", "inapp"],
"emailadresse": "redakteur@example.org",
"telegram_chat_id": "",
},
follow_redirects=False,
)
assert antwort.status_code == 303
assert antwort.headers["location"].startswith("/benachrichtigung/einstellungen")
with app.state.session_factory() as db:
praef = db.get(Praeferenz, benutzer.id)
kontakt = db.get(Kontakt, benutzer.id)
assert praef.kanaele == "email,inapp"
assert kontakt.email == "redakteur@example.org"
seite = client.get("/benachrichtigung/einstellungen?gespeichert=true")
assert "Einstellungen gespeichert." in seite.text
assert 'value="redakteur@example.org"' in seite.text
def test_einstellungen_validierung(client):
app = client.app
benutzer = hole_benutzer(app)
melde_an(client)
antwort = client.post(
"/benachrichtigung/einstellungen",
data={
"kanaele": ["email"],
"emailadresse": "kaputte adresse",
"telegram_chat_id": "",
},
follow_redirects=True,
)
assert "gültig" in antwort.text
antwort2 = client.post(
"/benachrichtigung/einstellungen",
data={"kanaele": ["telegram"], "emailadresse": "", "telegram_chat_id": "abc"},
follow_redirects=True,
)
assert "nur Ziffern" in antwort2.text
with app.state.session_factory() as db:
assert db.get(Kontakt, benutzer.id) is None # bei Fehlern nichts gespeichert
# ---------------- Helfer ----------------
def _mini_kontext(settings: Settings):
"""Minimaler PluginContext mit eigener DB für Adapter-Tests ohne App."""
from fastapi.templating import Jinja2Templates
from jinja2 import Environment, select_autoescape
from redaktionskern.contracts import PluginContext
from redaktionskern.db import make_engine, make_session_factory
from redaktionskern.migrationen import run_migrations
engine = make_engine(settings.database_url)
session_factory = make_session_factory(engine)
with engine.begin() as conn:
run_migrations(conn, "benachrichtigung", BenachrichtigungPlugin().migrations())
templates = Jinja2Templates(
env=Environment(autoescape=select_autoescape(default=True))
)
return PluginContext(
engine=engine,
session_factory=session_factory,
settings=settings,
templates=templates,
)