From f51732bff15c3c9c3d91de18d755e5cb5a1c7454 Mon Sep 17 00:00:00 2001 From: Flo Hartmann Date: Fri, 21 Aug 2026 19:00:06 +0000 Subject: [PATCH] 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 --- README.md | 108 ++++- docker-compose.yml | 7 + plugins/benachrichtigung/__init__.py | 278 ++++++++++- plugins/benachrichtigung/adapter.py | 216 +++++++++ plugins/benachrichtigung/konfiguration.py | 53 +++ plugins/benachrichtigung/models.py | 80 ++++ .../benachrichtigung/einstellungen.html | 75 +++ .../templates/benachrichtigung/index.html | 58 ++- tests/test_benachrichtigung.py | 439 ++++++++++++++++++ 9 files changed, 1286 insertions(+), 28 deletions(-) create mode 100644 plugins/benachrichtigung/adapter.py create mode 100644 plugins/benachrichtigung/konfiguration.py create mode 100644 plugins/benachrichtigung/models.py create mode 100644 plugins/benachrichtigung/templates/benachrichtigung/einstellungen.html create mode 100644 tests/test_benachrichtigung.py diff --git a/README.md b/README.md index d1f0b29..6483bab 100644 --- a/README.md +++ b/README.md @@ -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` | `/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//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// ``` ### 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 & 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 | diff --git a/docker-compose.yml b/docker-compose.yml index 0d292ab..49f89e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/plugins/benachrichtigung/__init__.py b/plugins/benachrichtigung/__init__.py index 819077e..32f6291 100644 --- a/plugins/benachrichtigung/__init__.py +++ b/plugins/benachrichtigung/__init__.py @@ -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() diff --git a/plugins/benachrichtigung/adapter.py b/plugins/benachrichtigung/adapter.py new file mode 100644 index 0000000..1d9a07c --- /dev/null +++ b/plugins/benachrichtigung/adapter.py @@ -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) diff --git a/plugins/benachrichtigung/konfiguration.py b/plugins/benachrichtigung/konfiguration.py new file mode 100644 index 0000000..22631d1 --- /dev/null +++ b/plugins/benachrichtigung/konfiguration.py @@ -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"), + ) diff --git a/plugins/benachrichtigung/models.py b/plugins/benachrichtigung/models.py new file mode 100644 index 0000000..876807f --- /dev/null +++ b/plugins/benachrichtigung/models.py @@ -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) diff --git a/plugins/benachrichtigung/templates/benachrichtigung/einstellungen.html b/plugins/benachrichtigung/templates/benachrichtigung/einstellungen.html new file mode 100644 index 0000000..ffd395e --- /dev/null +++ b/plugins/benachrichtigung/templates/benachrichtigung/einstellungen.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% block titel %}{{ titel }} — {{ app_name }}{% endblock %} +{% block inhalt %} +

Benachrichtigungen — Einstellungen

+

+ Wählen Sie, über welche Kanäle Sie Benachrichtigungen erhalten. + Mehrere Kanäle sind gleichzeitig möglich. +

+ +← Zurück zum Posteingang + +{% if gespeichert %} +
+ Einstellungen gespeichert. +
+{% endif %} +{% if fehler %} +
+ {{ fehler }} +
+{% endif %} + +
+ +
+ Kanäle +
+ {% for schluessel, status in kanal_status.items() %} + + {% endfor %} +
+
+ +
+ + +
+ +
+ + +
+ +
+ + Ohne ausgewählten Kanal erhalten Sie keine Benachrichtigungen. +
+
+{% endblock %} diff --git a/plugins/benachrichtigung/templates/benachrichtigung/index.html b/plugins/benachrichtigung/templates/benachrichtigung/index.html index cc09663..7a4f5f3 100644 --- a/plugins/benachrichtigung/templates/benachrichtigung/index.html +++ b/plugins/benachrichtigung/templates/benachrichtigung/index.html @@ -1,10 +1,56 @@ {% extends "base.html" %} -{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %} +{% block titel %}{{ titel }} — {{ app_name }}{% endblock %} {% block inhalt %} -

{{ titel }}

-

- Plugin {{ name }} - in Version {{ version }} ist geladen. - Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert. +

+

{{ titel }}

+ + {{ ungelesen }} ungelesen + +
+

+ Zustellung aktuell über: + {% if gewaehlte_kanaelle %}{{ gewaehlte_kanaelle|join(", ") }} + {% else %}kein Kanal ausgewählt{% endif %}

+ +
+
+ +
+ + Kanäle & Einstellungen + +
+ +{% if mitteilungen %} +
+ {% for m in mitteilungen %} +
+
+ {{ m.titel }} + {{ m.kategorie }} + {{ m.created_at.strftime('%d.%m.%Y %H:%M') }} +
+

{{ m.text }}

+
+ {% if m.gelesen %} + Gelesen + {% else %} +
+ +
+ {% endif %} +
+
+ {% endfor %} +
+{% else %} +

+ Keine Benachrichtigungen vorhanden. +

+{% endif %} {% endblock %} diff --git a/tests/test_benachrichtigung.py b/tests/test_benachrichtigung.py new file mode 100644 index 0000000..725aae1 --- /dev/null +++ b/tests/test_benachrichtigung.py @@ -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, + )