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:
108
README.md
108
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` | `<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 & 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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,41 +1,293 @@
|
||||
"""Plugin „benachrichtigung“ — Platzhalter gemäß Plugin-Vertrag.
|
||||
"""Plugin „benachrichtigung“ — Kanäle E-Mail, Telegram und In-App.
|
||||
|
||||
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
|
||||
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
|
||||
Architektur (Adapter-Muster):
|
||||
- `adapter.py` kapselt die Zustellkanäle. Fehlt die Konfiguration eines
|
||||
Kanals (z. B. kein SMTP-Host in der Entwicklung), protokolliert ein
|
||||
Dev-Log-Adapter die Nachricht, statt sie zu versenden.
|
||||
- Pro Benutzer speichern `models.Praeferenz` (gewählte Kanäle) und
|
||||
`models.Kontakt` (E-Mail-Adresse, Telegram-Chat-ID) die Einstellungen.
|
||||
- Andere Plugins rufen die öffentliche API auf:
|
||||
|
||||
plugin = request.app.state.registry.get("benachrichtigung")
|
||||
bericht = await plugin.send_notification(user, "Titel", "Text", "kategorie")
|
||||
|
||||
Der Kern bleibt unverändert; alle Logik liegt in diesem Plugin.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, Request
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from redaktionskern.auth.deps import require_user
|
||||
from fastapi import Depends, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from redaktionskern.auth.deps import get_db, require_user
|
||||
from redaktionskern.auth.models import User
|
||||
from redaktionskern.contracts import BasePlugin, NavEntry
|
||||
from redaktionskern.contracts import BasePlugin, Migration, NavEntry
|
||||
|
||||
from .adapter import BenachrichtigungsAdapter, adapter_aus_konfiguration
|
||||
from .konfiguration import aus_umgebung
|
||||
from .models import KANAELLE, STANDARD_KANAELLE, Benachrichtigung, Kontakt, Praeferenz, gueltige_reihenfolge
|
||||
|
||||
logger = logging.getLogger("redaktion.benachrichtigung")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Zustellbericht:
|
||||
"""Ergebnis eines send_notification-Aufrufs."""
|
||||
|
||||
zugestellt: list[str]
|
||||
fehlgeschlagen: list[str]
|
||||
|
||||
|
||||
def _tabellen_erstellen(conn) -> None:
|
||||
"""Eigene Migration des Plugins: legt alle drei Tabellen an."""
|
||||
Benachrichtigung.__table__.create(conn, checkfirst=True)
|
||||
Praeferenz.__table__.create(conn, checkfirst=True)
|
||||
Kontakt.__table__.create(conn, checkfirst=True)
|
||||
|
||||
|
||||
class BenachrichtigungPlugin(BasePlugin):
|
||||
name = "benachrichtigung"
|
||||
title = "Benachrichtigungen"
|
||||
description = "E-Mail, Telegram und In-App per Adapter-Muster (Platzhalter)."
|
||||
description = "E-Mail, Telegram und In-App — Kanal je Benutzer einstellbar."
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._adapters: dict[str, BenachrichtigungsAdapter] | None = None
|
||||
self._routen_registrieren()
|
||||
|
||||
# ---------------- Lifecycle ----------------
|
||||
|
||||
def on_load(self, context) -> None:
|
||||
super().on_load(context)
|
||||
self._adapters = adapter_aus_konfiguration(
|
||||
aus_umgebung(), context.session_factory
|
||||
)
|
||||
|
||||
@property
|
||||
def adapters(self) -> dict[str, BenachrichtigungsAdapter]:
|
||||
"""Kanal-Adapter der Installation (nach on_load verfügbar)."""
|
||||
if self._adapters is None:
|
||||
raise RuntimeError("Plugin ist nicht geladen (on_load fehlt).")
|
||||
return self._adapters
|
||||
|
||||
def migrations(self) -> list[Migration]:
|
||||
return [Migration(version="0001_tabellen", up=_tabellen_erstellen)]
|
||||
|
||||
def navigation(self) -> list[NavEntry]:
|
||||
return [NavEntry(label=self.title, url="/benachrichtigung")]
|
||||
|
||||
# ---------------- Öffentliche Plugin-API ----------------
|
||||
|
||||
async def send_notification(
|
||||
self,
|
||||
user: User,
|
||||
titel: str,
|
||||
text: str,
|
||||
kategorie: str = "allgemein",
|
||||
) -> Zustellbericht:
|
||||
"""Stellt eine Benachrichtigung über alle gewählten Kanäle zu.
|
||||
|
||||
Andere Plugins holen sich dieses Plugin über die Registry und rufen
|
||||
diese Methode. Die In-App-Nachricht wird persistiert; E-Mail/Telegram
|
||||
gehen an die im Profil hinterlegten Kontaktdaten. Fehlt einem Kanal
|
||||
die Empfängeradresse oder schlägt die Zustellung fehl, wird das
|
||||
protokolliert und im Bericht gemeldet — send_notification wirft nicht.
|
||||
"""
|
||||
assert self.context is not None
|
||||
with self.context.session_factory() as db:
|
||||
praeferenz = db.get(Praeferenz, user.id)
|
||||
kontakt = db.get(Kontakt, user.id)
|
||||
kanaele = (
|
||||
praeferenz.kanaele_als_liste()
|
||||
if praeferenz is not None
|
||||
else [STANDARD_KANAELLE]
|
||||
)
|
||||
|
||||
zugestellt: list[str] = []
|
||||
fehlgeschlagen: list[str] = []
|
||||
for schluessel in kanaele:
|
||||
adapter = self.adapters.get(schluessel)
|
||||
if adapter is None:
|
||||
continue
|
||||
empfaenger: str | None = None
|
||||
if schluessel == "email" and kontakt is not None:
|
||||
empfaenger = kontakt.email
|
||||
elif schluessel == "telegram" and kontakt is not None:
|
||||
empfaenger = kontakt.telegram_chat_id
|
||||
try:
|
||||
if schluessel != "inapp" and not empfaenger:
|
||||
raise ValueError(
|
||||
"Keine Empfängeradresse für diesen Kanal hinterlegt."
|
||||
)
|
||||
await adapter.senden(user.id, empfaenger, titel, text, kategorie)
|
||||
if adapter.dev_fallback:
|
||||
logger.info(
|
||||
"Kanal '%s' nur protokolliert (nicht konfiguriert): '%s'",
|
||||
schluessel,
|
||||
titel,
|
||||
)
|
||||
zugestellt.append(schluessel)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Zustellung über Kanal '%s' fehlgeschlagen: %s", schluessel, exc
|
||||
)
|
||||
fehlgeschlagen.append(schluessel)
|
||||
return Zustellbericht(zugestellt=zugestellt, fehlgeschlagen=fehlgeschlagen)
|
||||
|
||||
# ---------------- Routen ----------------
|
||||
|
||||
def _routen_registrieren(self) -> None:
|
||||
|
||||
@self.router.get("/benachrichtigung")
|
||||
def seite(request: Request, user: User = Depends(require_user)):
|
||||
"""Platzhalterseite des Plugins."""
|
||||
def posteingang(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
mitteilungen = db.scalars(
|
||||
select(Benachrichtigung)
|
||||
.where(Benachrichtigung.user_id == user.id)
|
||||
.order_by(Benachrichtigung.created_at.desc(), Benachrichtigung.id.desc())
|
||||
.limit(200)
|
||||
).all()
|
||||
ungelesen = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Benachrichtigung)
|
||||
.where(
|
||||
Benachrichtigung.user_id == user.id,
|
||||
Benachrichtigung.gelesen.is_(False),
|
||||
)
|
||||
)
|
||||
praef = db.get(Praeferenz, user.id)
|
||||
gewaehlte = (
|
||||
praeferenz.kanaele_als_liste()
|
||||
if praef is not None
|
||||
else [STANDARD_KANAELLE]
|
||||
)
|
||||
return self.context.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="benachrichtigung/index.html",
|
||||
context={
|
||||
"user": user,
|
||||
"titel": self.title,
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"mitteilungen": mitteilungen,
|
||||
"ungelesen": ungelesen or 0,
|
||||
"gewaehlte_kanaelle": [
|
||||
KANAELLE[k] for k in gueltige_reihenfolge(gewaehlte)
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
def navigation(self) -> list[NavEntry]:
|
||||
return [NavEntry(label=self.title, url="/benachrichtigung")]
|
||||
@self.router.post("/benachrichtigung/alle-gelesen")
|
||||
def alle_gelesen(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
db.query(Benachrichtigung).filter(
|
||||
Benachrichtigung.user_id == user.id,
|
||||
Benachrichtigung.gelesen.is_(False),
|
||||
).update({"gelesen": True}, synchronize_session=False)
|
||||
db.commit()
|
||||
return RedirectResponse("/benachrichtigung", status_code=303)
|
||||
|
||||
@self.router.post("/benachrichtigung/{id}/gelesen")
|
||||
def einzel_gelesen(
|
||||
id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
nachricht = db.scalar(
|
||||
select(Benachrichtigung).where(
|
||||
Benachrichtigung.id == id,
|
||||
Benachrichtigung.user_id == user.id,
|
||||
)
|
||||
)
|
||||
if nachricht is not None and not nachricht.gelesen:
|
||||
nachricht.gelesen = True
|
||||
db.commit()
|
||||
return RedirectResponse("/benachrichtigung", status_code=303)
|
||||
|
||||
@self.router.get("/benachrichtigung/einstellungen")
|
||||
def einstellungen_formular(
|
||||
request: Request,
|
||||
gespeichert: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
):
|
||||
praef = db.get(Praeferenz, user.id)
|
||||
kontakt = db.get(Kontakt, user.id)
|
||||
gewaehlte = (
|
||||
praef.kanaele_als_liste()
|
||||
if praef is not None
|
||||
else [STANDARD_KANAELLE]
|
||||
)
|
||||
kanal_status = {
|
||||
schluessel: {
|
||||
"label": label,
|
||||
"aktiv": not self.adapters[schluessel].dev_fallback,
|
||||
}
|
||||
for schluessel, label in KANAELLE.items()
|
||||
}
|
||||
return self.context.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="benachrichtigung/einstellungen.html",
|
||||
context={
|
||||
"user": user,
|
||||
"titel": f"{self.title} — Einstellungen",
|
||||
"kanal_status": kanal_status,
|
||||
"gewaehlte": gewaehlte,
|
||||
"emailadresse": kontakt.email if kontakt else "",
|
||||
"telegram_chat_id": kontakt.telegram_chat_id if kontakt else "",
|
||||
"gespeichert": gespeichert,
|
||||
"fehler": request.session.pop("benachrichtigung_fehler", None),
|
||||
},
|
||||
)
|
||||
|
||||
@self.router.post("/benachrichtigung/einstellungen")
|
||||
def einstellungen_speichern(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
kanaele: list[str] = Form(default=[]),
|
||||
emailadresse: str = Form(""),
|
||||
telegram_chat_id: str = Form(""),
|
||||
):
|
||||
auswahl = gueltige_reihenfolge(
|
||||
[k for k in kanaele if k in KANAELLE]
|
||||
)
|
||||
emailadresse = emailadresse.strip()
|
||||
telegram_chat_id = telegram_chat_id.strip()
|
||||
fehler: str | None = None
|
||||
if emailadresse and ("@" not in emailadresse or " " in emailadresse):
|
||||
fehler = "Die E-Mail-Adresse sieht nicht gültig aus."
|
||||
if telegram_chat_id.lstrip("-").isdigit() is False and telegram_chat_id:
|
||||
fehler = "Die Telegram-Chat-ID darf nur Ziffern enthalten (Gruppen mit vorangestelltem Minus)."
|
||||
if fehler is not None:
|
||||
request.session["benachrichtigung_fehler"] = fehler
|
||||
return RedirectResponse(
|
||||
"/benachrichtigung/einstellungen", status_code=303
|
||||
)
|
||||
|
||||
kontakt = db.get(Kontakt, user.id)
|
||||
if kontakt is None:
|
||||
kontakt = Kontakt(user_id=user.id)
|
||||
db.add(kontakt)
|
||||
kontakt.email = emailadresse or None
|
||||
kontakt.telegram_chat_id = telegram_chat_id or None
|
||||
|
||||
praef = db.get(Praeferenz, user.id)
|
||||
if praef is None:
|
||||
praef = Praeferenz(user_id=user.id)
|
||||
db.add(praef)
|
||||
praef.kanaele = ",".join(auswahl) if auswahl else ""
|
||||
db.commit()
|
||||
return RedirectResponse(
|
||||
"/benachrichtigung/einstellungen?gespeichert=true", status_code=303
|
||||
)
|
||||
|
||||
|
||||
plugin = BenachrichtigungPlugin()
|
||||
|
||||
216
plugins/benachrichtigung/adapter.py
Normal file
216
plugins/benachrichtigung/adapter.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Adapter-Muster des Plugins „benachrichtigung“.
|
||||
|
||||
Ein Adapter kapselt genau einen Zustellkanal und implementiert `senden()`.
|
||||
Ist ein Kanal installationsweit nicht konfiguriert (z. B. kein SMTP-Host in
|
||||
der Entwicklung), tritt an seine Stelle ein Dev-Log-Adapter, der die
|
||||
Nachricht nur protokolliert — so geht in der Entwicklung keine
|
||||
Benachrichtigung verloren.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
import urllib.request
|
||||
from abc import ABC, abstractmethod
|
||||
from email.message import EmailMessage
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .konfiguration import Konfiguration
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
logger = logging.getLogger("redaktion.benachrichtigung")
|
||||
|
||||
|
||||
class BenachrichtigungsAdapter(ABC):
|
||||
"""Basis eines Kanal-Adapters.
|
||||
|
||||
`senden()` erhält alles Nötige vom Verteiler:
|
||||
user_id – Empfänger-Benutzer (für In-App-Persistenz)
|
||||
empfaenger – kanalspezifische Adresse (E-Mail bzw. Chat-ID), sonst None
|
||||
titel/text – Nachricht
|
||||
kategorie – freie Kategorie, z. B. "dedup" oder "erinnerung"
|
||||
"""
|
||||
|
||||
schluessel: str
|
||||
label: str
|
||||
#: True, wenn der Adapter nur protokolliert (Entwicklungs-Fallback).
|
||||
dev_fallback: bool = False
|
||||
|
||||
@abstractmethod
|
||||
async def senden(
|
||||
self,
|
||||
user_id: int,
|
||||
empfaenger: str | None,
|
||||
titel: str,
|
||||
text: str,
|
||||
kategorie: str,
|
||||
) -> None:
|
||||
"""Stellt die Nachricht über diesen Kanal zu."""
|
||||
|
||||
|
||||
class DevLogAdapter(BenachrichtigungsAdapter):
|
||||
"""Protokolliert Benachrichtigungen statt sie zu versenden."""
|
||||
|
||||
def __init__(self, schluessel: str, label: str) -> None:
|
||||
self.schluessel = schluessel
|
||||
self.label = label
|
||||
self.dev_fallback = True
|
||||
|
||||
async def senden(
|
||||
self,
|
||||
user_id: int,
|
||||
empfaenger: str | None,
|
||||
titel: str,
|
||||
text: str,
|
||||
kategorie: str,
|
||||
) -> None:
|
||||
logger.info(
|
||||
"[DEV] %s an Benutzer %s (%s): %s — %s",
|
||||
self.label,
|
||||
user_id,
|
||||
empfaenger or "ohne Adresse",
|
||||
titel,
|
||||
text,
|
||||
)
|
||||
|
||||
|
||||
class EmailSmtpAdapter(BenachrichtigungsAdapter):
|
||||
"""Versendet E-Mail über einen SMTP-Server (Konfiguration via Env)."""
|
||||
|
||||
schluessel = "email"
|
||||
label = "E-Mail"
|
||||
|
||||
def __init__(self, konfiguration: Konfiguration) -> None:
|
||||
self.konf = konfiguration
|
||||
|
||||
async def senden(
|
||||
self,
|
||||
user_id: int,
|
||||
empfaenger: str | None,
|
||||
titel: str,
|
||||
text: str,
|
||||
kategorie: str,
|
||||
) -> None:
|
||||
if not empfaenger:
|
||||
raise ValueError("Keine E-Mail-Adresse für den Empfänger hinterlegt.")
|
||||
await asyncio.to_thread(self._senden_sync, empfaenger, titel, text)
|
||||
|
||||
def _senden_sync(self, empfaenger: str, titel: str, text: str) -> None:
|
||||
nachricht = EmailMessage()
|
||||
nachricht["Subject"] = f"[Spiele-Redaktion] {titel}"
|
||||
nachricht["From"] = self.konf.smtp_absender
|
||||
nachricht["To"] = empfaenger
|
||||
nachricht.set_content(text)
|
||||
with _smtp_verbindung(self.konf) as server:
|
||||
server.send_message(nachricht)
|
||||
|
||||
|
||||
class TelegramAdapter(BenachrichtigungsAdapter):
|
||||
"""Versendet Nachrichten über die Telegram Bot-API (sendMessage)."""
|
||||
|
||||
schluessel = "telegram"
|
||||
label = "Telegram"
|
||||
|
||||
def __init__(self, bot_token: str) -> None:
|
||||
self.bot_token = bot_token
|
||||
|
||||
async def senden(
|
||||
self,
|
||||
user_id: int,
|
||||
empfaenger: str | None,
|
||||
titel: str,
|
||||
text: str,
|
||||
kategorie: str,
|
||||
) -> None:
|
||||
if not empfaenger:
|
||||
raise ValueError("Keine Telegram-Chat-ID für den Empfänger hinterlegt.")
|
||||
nachricht = f"{titel}\n\n{text}"
|
||||
await asyncio.to_thread(self._api_senden, empfaenger, nachricht)
|
||||
|
||||
def _api_senden(self, chat_id: str, nachricht: str) -> dict:
|
||||
"""Ein Aufruf der Bot-API; Test-Seam (wird in Tests überschrieben)."""
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
||||
daten = json.dumps({"chat_id": chat_id, "text": nachricht}).encode("utf-8")
|
||||
anfrage = urllib.request.Request(
|
||||
url, data=daten, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(anfrage, timeout=15) as antwort:
|
||||
ergebnis = json.load(antwort)
|
||||
if not ergebnis.get("ok"):
|
||||
raise RuntimeError(f"Telegram-API-Fehler: {ergebnis}")
|
||||
return ergebnis
|
||||
|
||||
|
||||
class InAppAdapter(BenachrichtigungsAdapter):
|
||||
"""Persistiert die Nachricht als In-App-Nachricht im Portal."""
|
||||
|
||||
schluessel = "inapp"
|
||||
label = "In-App"
|
||||
|
||||
def __init__(self, session_factory: "sessionmaker") -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
async def senden(
|
||||
self,
|
||||
user_id: int,
|
||||
empfaenger: str | None,
|
||||
titel: str,
|
||||
text: str,
|
||||
kategorie: str,
|
||||
) -> None:
|
||||
from .models import Benachrichtigung
|
||||
|
||||
def _schreiben() -> None:
|
||||
with self._session_factory() as db:
|
||||
db.add(
|
||||
Benachrichtigung(
|
||||
user_id=user_id, titel=titel, text=text, kategorie=kategorie
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
await asyncio.to_thread(_schreiben)
|
||||
|
||||
|
||||
def adapter_aus_konfiguration(
|
||||
konf: Konfiguration, session_factory: "sessionmaker"
|
||||
) -> dict[str, BenachrichtigungsAdapter]:
|
||||
"""Baut den Adapter-Satz der Installation; fehlende Konfiguration → Dev-Log."""
|
||||
email_adapter: BenachrichtigungsAdapter
|
||||
telegram_adapter: BenachrichtigungsAdapter
|
||||
if konf.smtp_konfiguriert:
|
||||
email_adapter = EmailSmtpAdapter(konf)
|
||||
else:
|
||||
email_adapter = DevLogAdapter("email", "E-Mail (nur Protokoll)")
|
||||
if konf.telegram_konfiguriert:
|
||||
telegram_adapter = TelegramAdapter(konf.telegram_bot_token)
|
||||
else:
|
||||
telegram_adapter = DevLogAdapter("telegram", "Telegram (nur Protokoll)")
|
||||
return {
|
||||
"email": email_adapter,
|
||||
"telegram": telegram_adapter,
|
||||
"inapp": InAppAdapter(session_factory),
|
||||
}
|
||||
|
||||
|
||||
def _smtp_verbindung(konf: Konfiguration):
|
||||
"""Öffnet eine SMTP-Verbindung gemäß TLS-Einstellung (Kontext-Manager)."""
|
||||
if konf.smtp_tls == "ssl":
|
||||
server = smtplib.SMTP_SSL(konf.smtp_host, konf.smtp_port, timeout=15)
|
||||
_anmelden(server, konf)
|
||||
return server
|
||||
verbindung = smtplib.SMTP(konf.smtp_host, konf.smtp_port, timeout=15)
|
||||
if konf.smtp_tls == "starttls":
|
||||
verbindung.starttls(context=ssl.create_default_context())
|
||||
_anmelden(verbindung, konf)
|
||||
return verbindung
|
||||
|
||||
|
||||
def _anmelden(server, konf: Konfiguration) -> None:
|
||||
if konf.smtp_benutzer and konf.smtp_passwort:
|
||||
server.login(konf.smtp_benutzer, konf.smtp_passwort)
|
||||
53
plugins/benachrichtigung/konfiguration.py
Normal file
53
plugins/benachrichtigung/konfiguration.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Plugin-Konfiguration über Umgebungsvariablen.
|
||||
|
||||
Der Plugin-Kontrakt verbietet Fachlogik im Kern — deshalb liest dieses
|
||||
Plugin seine Kanal-Konfiguration selbst aus der Umgebung, statt Felder in
|
||||
`redaktionskern.config.Settings` zu ergänzen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
def _env(name: str, standard: str = "") -> str:
|
||||
wert = os.environ.get(name)
|
||||
return wert.strip() if wert else standard
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Konfiguration:
|
||||
"""Zustellkanal-Einstellungen der Installation (nicht pro Benutzer)."""
|
||||
|
||||
smtp_host: str
|
||||
smtp_port: int
|
||||
smtp_benutzer: str
|
||||
smtp_passwort: str
|
||||
smtp_absender: str
|
||||
#: "starttls" (Standard), "ssl" oder "keine"
|
||||
smtp_tls: str
|
||||
telegram_bot_token: str
|
||||
|
||||
@property
|
||||
def smtp_konfiguriert(self) -> bool:
|
||||
return bool(self.smtp_host)
|
||||
|
||||
@property
|
||||
def telegram_konfiguriert(self) -> bool:
|
||||
return bool(self.telegram_bot_token)
|
||||
|
||||
|
||||
def aus_umgebung() -> Konfiguration:
|
||||
benutzer = _env("SPIELE_SMTP_BENUTZER")
|
||||
tls = _env("SPIELE_SMTP_TLS", "starttls").lower()
|
||||
if tls not in ("starttls", "ssl", "keine"):
|
||||
tls = "starttls"
|
||||
return Konfiguration(
|
||||
smtp_host=_env("SPIELE_SMTP_HOST"),
|
||||
smtp_port=int(_env("SPIELE_SMTP_PORT", "587")),
|
||||
smtp_benutzer=benutzer,
|
||||
smtp_passwort=_env("SPIELE_SMTP_PASSWORT"),
|
||||
smtp_absender=_env("SPIELE_SMTP_ABSENDER") or benutzer or "spiele-redaktion@localhost",
|
||||
smtp_tls=tls,
|
||||
telegram_bot_token=_env("SPIELE_TELEGRAM_BOT_TOKEN"),
|
||||
)
|
||||
80
plugins/benachrichtigung/models.py
Normal file
80
plugins/benachrichtigung/models.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Datenmodelle des Plugins „benachrichtigung“.
|
||||
|
||||
Alle Tabellen tragen den Präfix `benachrichtigung_` und nutzen portable
|
||||
Spaltentypen (SQLite ↔ Postgres). Der Kern bleibt unverändert — auch die
|
||||
Benutzer-Tabelle wird nicht angetastet; Kanal-Kontaktdaten (E-Mail-Adresse,
|
||||
Telegram-Chat-ID) liegen in der eigenen Kontakt-Tabelle.
|
||||
|
||||
`extend_existing` erlaubt das erneute Ausführen des Moduls durch den
|
||||
Plugin-Loader (jeder App-Start lädt das Plugin frisch bzw. Tests können es
|
||||
über einen zweiten Pfad importieren), ohne dass die gemeinsame Metadata
|
||||
doppelt definierte Tabellen meldet. Die Modelle definieren bewusst keine
|
||||
ORM-Indizes: Bei erneuter Ausführung würde ein deklarativ definierter Index
|
||||
doppelt im Table-Objekt landen und die Migration würde ihn zweimal anlegen.
|
||||
Die Datenmengen sind klein — auf Indizes wird verzichtet.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from redaktionskern.db import Base
|
||||
|
||||
#: Gültige Kanal-Schlüssel (Reihenfolge = Anzeigereihenfolge im Formular).
|
||||
KANAELLE: dict[str, str] = {
|
||||
"email": "E-Mail",
|
||||
"telegram": "Telegram",
|
||||
"inapp": "In-App",
|
||||
}
|
||||
STANDARD_KANAELLE = "inapp"
|
||||
|
||||
|
||||
class Benachrichtigung(Base):
|
||||
"""Eine In-App-Nachricht im Portal (Posteingang des Benutzers)."""
|
||||
|
||||
__tablename__ = "benachrichtigung"
|
||||
__table_args__ = {"extend_existing": True}
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
titel: Mapped[str] = mapped_column(String(300))
|
||||
text: Mapped[str] = mapped_column(Text)
|
||||
kategorie: Mapped[str] = mapped_column(String(100), default="allgemein")
|
||||
gelesen: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class Praeferenz(Base):
|
||||
"""Kanal-Präferenzen eines Benutzers (mehrere Kanäle gleichzeitig möglich)."""
|
||||
|
||||
__tablename__ = "benachrichtigung_praeferenz"
|
||||
__table_args__ = {"extend_existing": True}
|
||||
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True)
|
||||
#: Komma-separierte Kanal-Schlüssel, z. B. "email,inapp".
|
||||
kanaele: Mapped[str] = mapped_column(String(200), default=STANDARD_KANAELLE)
|
||||
aktualisiert_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
|
||||
def kanaele_als_liste(self) -> list[str]:
|
||||
gueltig = [k for k in self.kanaele.split(",") if k in KANAELLE]
|
||||
return gueltige_reihenfolge(gueltig)
|
||||
|
||||
|
||||
def gueltige_reihenfolge(auswahl: list[str]) -> list[str]:
|
||||
"""Sortiert eine Kanal-Auswahl in die definierte Anzeigereihenfolge."""
|
||||
return [k for k in KANAELLE if k in auswahl]
|
||||
|
||||
|
||||
class Kontakt(Base):
|
||||
"""Kanal-spezifische Kontaktdaten eines Benutzers (pro Benutzer höchstens eine Zeile)."""
|
||||
|
||||
__tablename__ = "benachrichtigung_kontakt"
|
||||
__table_args__ = {"extend_existing": True}
|
||||
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True)
|
||||
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||
telegram_chat_id: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titel %}{{ titel }} — {{ app_name }}{% endblock %}
|
||||
{% block inhalt %}
|
||||
<h1 class="text-2xl font-bold mb-1">Benachrichtigungen — Einstellungen</h1>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Wählen Sie, über welche Kanäle Sie Benachrichtigungen erhalten.
|
||||
Mehrere Kanäle sind gleichzeitig möglich.
|
||||
</p>
|
||||
|
||||
<a href="/benachrichtigung" class="text-sm text-emerald-700 hover:underline">← Zurück zum Posteingang</a>
|
||||
|
||||
{% if gespeichert %}
|
||||
<div class="mt-4 mb-2 rounded-lg bg-emerald-50 border border-emerald-200 text-emerald-800 px-4 py-2 text-sm">
|
||||
Einstellungen gespeichert.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if fehler %}
|
||||
<div class="mt-4 mb-2 rounded-lg bg-red-50 border border-red-200 text-red-800 px-4 py-2 text-sm">
|
||||
{{ fehler }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/benachrichtigung/einstellungen"
|
||||
class="mt-4 bg-white rounded-xl border border-slate-200 p-5 max-w-2xl space-y-5">
|
||||
|
||||
<fieldset>
|
||||
<legend class="text-sm font-semibold mb-2">Kanäle</legend>
|
||||
<div class="space-y-2">
|
||||
{% for schluessel, status in kanal_status.items() %}
|
||||
<label class="flex items-start gap-3 p-2 rounded hover:bg-slate-50 cursor-pointer">
|
||||
<input type="checkbox" name="kanaele" value="{{ schluessel }}"
|
||||
{% if schluessel in gewaehlte %}checked{% endif %}
|
||||
class="mt-1 h-4 w-4 accent-emerald-600">
|
||||
<span>
|
||||
<span class="font-medium">{{ status.label }}</span>
|
||||
{% if status.aktiv %}
|
||||
<span class="ml-1 text-xs bg-emerald-100 text-emerald-700 rounded px-1.5 py-0.5">konfiguriert</span>
|
||||
{% else %}
|
||||
<span class="ml-1 text-xs bg-amber-100 text-amber-700 rounded px-1.5 py-0.5">nicht konfiguriert — nur Serverprotokoll</span>
|
||||
{% endif %}
|
||||
{% if schluessel == 'email' %}
|
||||
<span class="block text-sm text-slate-500">Versand über SMTP; ohne Mailserver erscheint die Nachricht im Protokoll.</span>
|
||||
{% elif schluessel == 'telegram' %}
|
||||
<span class="block text-sm text-slate-500">Bot-Token wird zentral gesetzt (Umgebungsvariable), die Chat-ID hinterlegen Sie hier.</span>
|
||||
{% elif schluessel == 'inapp' %}
|
||||
<span class="block text-sm text-slate-500">Nachricht erscheint im Posteingang des Portals.</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<label for="emailadresse" class="block text-sm font-semibold mb-1">E-Mail-Adresse</label>
|
||||
<input id="emailadresse" name="emailadresse" type="email" value="{{ emailadresse }}"
|
||||
placeholder="name@example.org"
|
||||
class="w-full rounded-lg border border-slate-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="telegram_chat_id" class="block text-sm font-semibold mb-1">Telegram-Chat-ID</label>
|
||||
<input id="telegram_chat_id" name="telegram_chat_id" value="{{ telegram_chat_id }}"
|
||||
placeholder="z. B. 123456789 (Gruppen mit Minus)"
|
||||
class="w-full rounded-lg border border-slate-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700">
|
||||
Speichern
|
||||
</button>
|
||||
<span class="text-xs text-slate-400">Ohne ausgewählten Kanal erhalten Sie keine Benachrichtigungen.</span>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,10 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
|
||||
{% block titel %}{{ titel }} — {{ app_name }}{% endblock %}
|
||||
{% block inhalt %}
|
||||
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
||||
<p class="text-slate-600 max-w-2xl">
|
||||
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
|
||||
in Version {{ version }} ist geladen.
|
||||
Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert.
|
||||
<div class="flex flex-wrap items-center gap-3 mb-1">
|
||||
<h1 class="text-2xl font-bold">{{ titel }}</h1>
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-medium
|
||||
{% if ungelesen %}bg-red-100 text-red-700{% else %}bg-slate-200 text-slate-500{% endif %}">
|
||||
{{ ungelesen }} ungelesen
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Zustellung aktuell über:
|
||||
{% if gewaehlte_kanaelle %}<span class="font-medium">{{ gewaehlte_kanaelle|join(", ") }}</span>
|
||||
{% else %}<span class="text-red-600">kein Kanal ausgewählt</span>{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
<form method="post" action="/benachrichtigung/alle-gelesen">
|
||||
<button type="submit" class="px-3 py-1.5 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700">
|
||||
Alle als gelesen markieren
|
||||
</button>
|
||||
</form>
|
||||
<a href="/benachrichtigung/einstellungen"
|
||||
class="px-3 py-1.5 rounded-lg border border-slate-300 bg-white text-sm font-medium hover:bg-slate-50">
|
||||
Kanäle & Einstellungen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if mitteilungen %}
|
||||
<div class="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
|
||||
{% for m in mitteilungen %}
|
||||
<div class="px-4 py-3 flex flex-col gap-1 {% if not m.gelesen %}border-l-4 border-l-emerald-500{% endif %}">
|
||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span class="{% if m.gelesen %}text-slate-600{% else %}font-semibold text-slate-900{% endif %}">{{ m.titel }}</span>
|
||||
<span class="text-xs uppercase tracking-wide bg-slate-100 text-slate-500 rounded px-1.5 py-0.5">{{ m.kategorie }}</span>
|
||||
<span class="text-xs text-slate-400 ml-auto">{{ m.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-600 whitespace-pre-line">{{ m.text }}</p>
|
||||
<div class="text-xs">
|
||||
{% if m.gelesen %}
|
||||
<span class="text-slate-400">Gelesen</span>
|
||||
{% else %}
|
||||
<form method="post" action="/benachrichtigung/{{ m.id }}/gelesen">
|
||||
<button type="submit" class="text-emerald-700 hover:underline">Als gelesen markieren</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-slate-500 bg-white rounded-xl border border-slate-200 px-4 py-8 text-center">
|
||||
Keine Benachrichtigungen vorhanden.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
439
tests/test_benachrichtigung.py
Normal file
439
tests/test_benachrichtigung.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user