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
|
# Spiele-Redaktion
|
||||||
|
|
||||||
**KI-Assistenz für Spielemagazin-Redaktionen** — Multi-User-Webanwendung mit
|
**KI-Assistenz für Spielemagazin-Redaktionen** — Multi-User-Webanwendung mit
|
||||||
modularer Plugin-Architektur. Phase 1: lauffähiger Kern mit Plugin-System,
|
modularer Plugin-Architektur. Lauffähiger Kern mit Plugin-System,
|
||||||
Authentifizierung/Rollen, Migrationen und Plugin-Stubs.
|
Authentifizierung/Rollen und Migrationen; als erstes Fachplugin ist das
|
||||||
|
**Audit-Log** vollständig implementiert, weitere Plugins folgen.
|
||||||
|
|
||||||
## Stack
|
## 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_SESSION_DAUER` | `43200` | Session-Dauer in Sekunden (12 h) |
|
||||||
| `SPIELE_INITIAL_ADMIN_PASSWORD` | `admin` | Passwort des initialen Admins |
|
| `SPIELE_INITIAL_ADMIN_PASSWORD` | `admin` | Passwort des initialen Admins |
|
||||||
| `SPIELE_PLUGINS_DIR` | `<Projekt>/plugins` | Pfad zum lokalen Plugin-Verzeichnis |
|
| `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
|
## Tests
|
||||||
|
|
||||||
@@ -46,9 +55,11 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt:
|
|||||||
uv run pytest
|
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,
|
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
|
## Architektur
|
||||||
|
|
||||||
@@ -62,10 +73,11 @@ src/redaktionskern/ schlanker Kern — KEINE Fachlogik
|
|||||||
├── contracts.py Plugin-Vertrag (BasePlugin, Migration, NavEntry, PluginContext)
|
├── contracts.py Plugin-Vertrag (BasePlugin, Migration, NavEntry, PluginContext)
|
||||||
└── auth/ Login/Logout, Rollen, Benutzerverwaltung
|
└── auth/ Login/Logout, Rollen, Benutzerverwaltung
|
||||||
|
|
||||||
plugins/ ein Ordner pro Funktion (Phase-1: Stubs, ladbar)
|
plugins/ ein Ordner pro Funktion, ladbar über den Plugin-Loader
|
||||||
├── neuheiten/ dedup/ planung/ archiv/
|
├── audit-log/ VOLL IMPLEMENTIERT: Model, Migration, API, Admin-Ansicht
|
||||||
├── erinnerung/ benachrichtigung/ audit-log/ export/
|
├── neuheiten/ benachrichtigung/ (in Arbeit)
|
||||||
└── … je __init__.py + templates/<name>/index.html
|
├── dedup/ planung/ archiv/ erinnerung/ export/ (Stubs, ladbar)
|
||||||
|
└── … je __init__.py + templates/<name>/
|
||||||
```
|
```
|
||||||
|
|
||||||
### Der Plugin-Vertrag
|
### 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
|
`schema_migrations` protokolliert; die `up`-Funktionen bekommen eine
|
||||||
SQLAlchemy-Connection und können bei Dialekt-Unterschieden verzweigen.
|
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)
|
## Deployment (später)
|
||||||
|
|
||||||
Docker/Podman Compose ist vorgesehen (Henry-Lab, danach Kundenhardware).
|
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) |
|
| 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 |
|
| 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 |
|
| 4 | Plugin `neuheiten` (BGG-Sync, APScheduler, Filter) | 🔄 in Arbeit |
|
||||||
| 5 | Plugin `dedup` (Verlags-Konflikt, deutsche Version, Vorgänger-/Planungs-Check) | ⏳ offen |
|
| 5 | Plugin `dedup` (Verlags-Konflikt, deutsche Version, Vorgänger-/Planungs-Check) | ⏳ offen |
|
||||||
| 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ⏳ 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}
|
- SPIELE_SESSION_SECRET=${SPIELE_SESSION_SECRET:-change-me-in-production}
|
||||||
# Vor dem ersten Start setzen, sonst wird der Admin mit "admin" angelegt:
|
# Vor dem ersten Start setzen, sonst wird der Admin mit "admin" angelegt:
|
||||||
- SPIELE_INITIAL_ADMIN_PASSWORD=${SPIELE_INITIAL_ADMIN_PASSWORD:-admin}
|
- 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:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
networks:
|
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:
|
Architektur (Adapter-Muster):
|
||||||
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
|
- `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 __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.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):
|
class BenachrichtigungPlugin(BasePlugin):
|
||||||
name = "benachrichtigung"
|
name = "benachrichtigung"
|
||||||
title = "Benachrichtigungen"
|
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:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
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")
|
@self.router.get("/benachrichtigung")
|
||||||
def seite(request: Request, user: User = Depends(require_user)):
|
def posteingang(
|
||||||
"""Platzhalterseite des Plugins."""
|
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(
|
return self.context.templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="benachrichtigung/index.html",
|
name="benachrichtigung/index.html",
|
||||||
context={
|
context={
|
||||||
"user": user,
|
"user": user,
|
||||||
"titel": self.title,
|
"titel": self.title,
|
||||||
"name": self.name,
|
"mitteilungen": mitteilungen,
|
||||||
"version": self.version,
|
"ungelesen": ungelesen or 0,
|
||||||
|
"gewaehlte_kanaelle": [
|
||||||
|
KANAELLE[k] for k in gueltige_reihenfolge(gewaehlte)
|
||||||
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def navigation(self) -> list[NavEntry]:
|
@self.router.post("/benachrichtigung/alle-gelesen")
|
||||||
return [NavEntry(label=self.title, url="/benachrichtigung")]
|
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()
|
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" %}
|
{% extends "base.html" %}
|
||||||
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
|
{% block titel %}{{ titel }} — {{ app_name }}{% endblock %}
|
||||||
{% block inhalt %}
|
{% block inhalt %}
|
||||||
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
<div class="flex flex-wrap items-center gap-3 mb-1">
|
||||||
<p class="text-slate-600 max-w-2xl">
|
<h1 class="text-2xl font-bold">{{ titel }}</h1>
|
||||||
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
|
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-medium
|
||||||
in Version {{ version }} ist geladen.
|
{% if ungelesen %}bg-red-100 text-red-700{% else %}bg-slate-200 text-slate-500{% endif %}">
|
||||||
Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert.
|
{{ 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>
|
</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 %}
|
{% 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