Plugin benachrichtigung: E-Mail, Telegram, In-App per Adapter-Muster

- Adapter-Pattern mit drei Kanaelen: E-Mail (SMTP per Env, TLS starttls/ssl,
  Dev-Fallback: Protokoll), Telegram (Bot-API, Token per Env, Chat-ID pro
  Benutzer), In-App (persistente Nachrichten mit Unread-Counter und
  'Alle als gelesen markieren')
- Pro Benutzer Kanal-Praeferenzen (Einstellungsseite, mehrere Kanaele
  gleichzeitig) plus eigene Kontakt-Tabelle (E-Mail-Adresse, Chat-ID);
  Kern und users-Tabelle unveraendert
- Oeffentliche Plugin-API: await send_notification(user, titel, text,
  kategorie) - andere Plugins holen das Plugin ueber app.state.registry
- Eigene Migration (0001_tabellen), eigene Routen/Templates, deutsche UI
- Tests: Adapter-Auswahl nach Praeferenz, In-App-Persistenz, Dev-Log-
  Adapter, SMTP-Versand (gemockt), Telegram-API-Aufruf, Einstellungsseite
- README: Plugin-Doku + neue Env-Variablen; docker-compose: Platzhalter
This commit is contained in:
Flo Hartmann
2026-08-21 19:00:06 +00:00
parent 7edf80d01c
commit f51732bff1
9 changed files with 1286 additions and 28 deletions

View File

@@ -1,41 +1,293 @@
"""Plugin „benachrichtigung“ — Platzhalter gemäß Plugin-Vertrag.
"""Plugin „benachrichtigung“ — Kanäle E-Mail, Telegram und In-App.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
Architektur (Adapter-Muster):
- `adapter.py` kapselt die Zustellkanäle. Fehlt die Konfiguration eines
Kanals (z. B. kein SMTP-Host in der Entwicklung), protokolliert ein
Dev-Log-Adapter die Nachricht, statt sie zu versenden.
- Pro Benutzer speichern `models.Praeferenz` (gewählte Kanäle) und
`models.Kontakt` (E-Mail-Adresse, Telegram-Chat-ID) die Einstellungen.
- Andere Plugins rufen die öffentliche API auf:
plugin = request.app.state.registry.get("benachrichtigung")
bericht = await plugin.send_notification(user, "Titel", "Text", "kategorie")
Der Kern bleibt unverändert; alle Logik liegt in diesem Plugin.
"""
from __future__ import annotations
from fastapi import Depends, Request
import logging
from dataclasses import dataclass
from redaktionskern.auth.deps import require_user
from fastapi import Depends, Form, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_user
from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, NavEntry
from redaktionskern.contracts import BasePlugin, Migration, NavEntry
from .adapter import BenachrichtigungsAdapter, adapter_aus_konfiguration
from .konfiguration import aus_umgebung
from .models import KANAELLE, STANDARD_KANAELLE, Benachrichtigung, Kontakt, Praeferenz, gueltige_reihenfolge
logger = logging.getLogger("redaktion.benachrichtigung")
@dataclass(frozen=True)
class Zustellbericht:
"""Ergebnis eines send_notification-Aufrufs."""
zugestellt: list[str]
fehlgeschlagen: list[str]
def _tabellen_erstellen(conn) -> None:
"""Eigene Migration des Plugins: legt alle drei Tabellen an."""
Benachrichtigung.__table__.create(conn, checkfirst=True)
Praeferenz.__table__.create(conn, checkfirst=True)
Kontakt.__table__.create(conn, checkfirst=True)
class BenachrichtigungPlugin(BasePlugin):
name = "benachrichtigung"
title = "Benachrichtigungen"
description = "E-Mail, Telegram und In-App per Adapter-Muster (Platzhalter)."
description = "E-Mail, Telegram und In-App — Kanal je Benutzer einstellbar."
def __init__(self) -> None:
super().__init__()
self._adapters: dict[str, BenachrichtigungsAdapter] | None = None
self._routen_registrieren()
# ---------------- Lifecycle ----------------
def on_load(self, context) -> None:
super().on_load(context)
self._adapters = adapter_aus_konfiguration(
aus_umgebung(), context.session_factory
)
@property
def adapters(self) -> dict[str, BenachrichtigungsAdapter]:
"""Kanal-Adapter der Installation (nach on_load verfügbar)."""
if self._adapters is None:
raise RuntimeError("Plugin ist nicht geladen (on_load fehlt).")
return self._adapters
def migrations(self) -> list[Migration]:
return [Migration(version="0001_tabellen", up=_tabellen_erstellen)]
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/benachrichtigung")]
# ---------------- Öffentliche Plugin-API ----------------
async def send_notification(
self,
user: User,
titel: str,
text: str,
kategorie: str = "allgemein",
) -> Zustellbericht:
"""Stellt eine Benachrichtigung über alle gewählten Kanäle zu.
Andere Plugins holen sich dieses Plugin über die Registry und rufen
diese Methode. Die In-App-Nachricht wird persistiert; E-Mail/Telegram
gehen an die im Profil hinterlegten Kontaktdaten. Fehlt einem Kanal
die Empfängeradresse oder schlägt die Zustellung fehl, wird das
protokolliert und im Bericht gemeldet — send_notification wirft nicht.
"""
assert self.context is not None
with self.context.session_factory() as db:
praeferenz = db.get(Praeferenz, user.id)
kontakt = db.get(Kontakt, user.id)
kanaele = (
praeferenz.kanaele_als_liste()
if praeferenz is not None
else [STANDARD_KANAELLE]
)
zugestellt: list[str] = []
fehlgeschlagen: list[str] = []
for schluessel in kanaele:
adapter = self.adapters.get(schluessel)
if adapter is None:
continue
empfaenger: str | None = None
if schluessel == "email" and kontakt is not None:
empfaenger = kontakt.email
elif schluessel == "telegram" and kontakt is not None:
empfaenger = kontakt.telegram_chat_id
try:
if schluessel != "inapp" and not empfaenger:
raise ValueError(
"Keine Empfängeradresse für diesen Kanal hinterlegt."
)
await adapter.senden(user.id, empfaenger, titel, text, kategorie)
if adapter.dev_fallback:
logger.info(
"Kanal '%s' nur protokolliert (nicht konfiguriert): '%s'",
schluessel,
titel,
)
zugestellt.append(schluessel)
except Exception as exc:
logger.exception(
"Zustellung über Kanal '%s' fehlgeschlagen: %s", schluessel, exc
)
fehlgeschlagen.append(schluessel)
return Zustellbericht(zugestellt=zugestellt, fehlgeschlagen=fehlgeschlagen)
# ---------------- Routen ----------------
def _routen_registrieren(self) -> None:
@self.router.get("/benachrichtigung")
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
def posteingang(
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
mitteilungen = db.scalars(
select(Benachrichtigung)
.where(Benachrichtigung.user_id == user.id)
.order_by(Benachrichtigung.created_at.desc(), Benachrichtigung.id.desc())
.limit(200)
).all()
ungelesen = db.scalar(
select(func.count())
.select_from(Benachrichtigung)
.where(
Benachrichtigung.user_id == user.id,
Benachrichtigung.gelesen.is_(False),
)
)
praef = db.get(Praeferenz, user.id)
gewaehlte = (
praeferenz.kanaele_als_liste()
if praef is not None
else [STANDARD_KANAELLE]
)
return self.context.templates.TemplateResponse(
request=request,
name="benachrichtigung/index.html",
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
"mitteilungen": mitteilungen,
"ungelesen": ungelesen or 0,
"gewaehlte_kanaelle": [
KANAELLE[k] for k in gueltige_reihenfolge(gewaehlte)
],
},
)
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/benachrichtigung")]
@self.router.post("/benachrichtigung/alle-gelesen")
def alle_gelesen(
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
db.query(Benachrichtigung).filter(
Benachrichtigung.user_id == user.id,
Benachrichtigung.gelesen.is_(False),
).update({"gelesen": True}, synchronize_session=False)
db.commit()
return RedirectResponse("/benachrichtigung", status_code=303)
@self.router.post("/benachrichtigung/{id}/gelesen")
def einzel_gelesen(
id: int,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
nachricht = db.scalar(
select(Benachrichtigung).where(
Benachrichtigung.id == id,
Benachrichtigung.user_id == user.id,
)
)
if nachricht is not None and not nachricht.gelesen:
nachricht.gelesen = True
db.commit()
return RedirectResponse("/benachrichtigung", status_code=303)
@self.router.get("/benachrichtigung/einstellungen")
def einstellungen_formular(
request: Request,
gespeichert: bool = False,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
praef = db.get(Praeferenz, user.id)
kontakt = db.get(Kontakt, user.id)
gewaehlte = (
praef.kanaele_als_liste()
if praef is not None
else [STANDARD_KANAELLE]
)
kanal_status = {
schluessel: {
"label": label,
"aktiv": not self.adapters[schluessel].dev_fallback,
}
for schluessel, label in KANAELLE.items()
}
return self.context.templates.TemplateResponse(
request=request,
name="benachrichtigung/einstellungen.html",
context={
"user": user,
"titel": f"{self.title} — Einstellungen",
"kanal_status": kanal_status,
"gewaehlte": gewaehlte,
"emailadresse": kontakt.email if kontakt else "",
"telegram_chat_id": kontakt.telegram_chat_id if kontakt else "",
"gespeichert": gespeichert,
"fehler": request.session.pop("benachrichtigung_fehler", None),
},
)
@self.router.post("/benachrichtigung/einstellungen")
def einstellungen_speichern(
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
kanaele: list[str] = Form(default=[]),
emailadresse: str = Form(""),
telegram_chat_id: str = Form(""),
):
auswahl = gueltige_reihenfolge(
[k for k in kanaele if k in KANAELLE]
)
emailadresse = emailadresse.strip()
telegram_chat_id = telegram_chat_id.strip()
fehler: str | None = None
if emailadresse and ("@" not in emailadresse or " " in emailadresse):
fehler = "Die E-Mail-Adresse sieht nicht gültig aus."
if telegram_chat_id.lstrip("-").isdigit() is False and telegram_chat_id:
fehler = "Die Telegram-Chat-ID darf nur Ziffern enthalten (Gruppen mit vorangestelltem Minus)."
if fehler is not None:
request.session["benachrichtigung_fehler"] = fehler
return RedirectResponse(
"/benachrichtigung/einstellungen", status_code=303
)
kontakt = db.get(Kontakt, user.id)
if kontakt is None:
kontakt = Kontakt(user_id=user.id)
db.add(kontakt)
kontakt.email = emailadresse or None
kontakt.telegram_chat_id = telegram_chat_id or None
praef = db.get(Praeferenz, user.id)
if praef is None:
praef = Praeferenz(user_id=user.id)
db.add(praef)
praef.kanaele = ",".join(auswahl) if auswahl else ""
db.commit()
return RedirectResponse(
"/benachrichtigung/einstellungen?gespeichert=true", status_code=303
)
plugin = BenachrichtigungPlugin()

View File

@@ -0,0 +1,216 @@
"""Adapter-Muster des Plugins „benachrichtigung“.
Ein Adapter kapselt genau einen Zustellkanal und implementiert `senden()`.
Ist ein Kanal installationsweit nicht konfiguriert (z. B. kein SMTP-Host in
der Entwicklung), tritt an seine Stelle ein Dev-Log-Adapter, der die
Nachricht nur protokolliert — so geht in der Entwicklung keine
Benachrichtigung verloren.
"""
from __future__ import annotations
import asyncio
import json
import logging
import smtplib
import ssl
import urllib.request
from abc import ABC, abstractmethod
from email.message import EmailMessage
from typing import TYPE_CHECKING
from .konfiguration import Konfiguration
if TYPE_CHECKING:
from sqlalchemy.orm import sessionmaker
logger = logging.getLogger("redaktion.benachrichtigung")
class BenachrichtigungsAdapter(ABC):
"""Basis eines Kanal-Adapters.
`senden()` erhält alles Nötige vom Verteiler:
user_id Empfänger-Benutzer (für In-App-Persistenz)
empfaenger kanalspezifische Adresse (E-Mail bzw. Chat-ID), sonst None
titel/text Nachricht
kategorie freie Kategorie, z. B. "dedup" oder "erinnerung"
"""
schluessel: str
label: str
#: True, wenn der Adapter nur protokolliert (Entwicklungs-Fallback).
dev_fallback: bool = False
@abstractmethod
async def senden(
self,
user_id: int,
empfaenger: str | None,
titel: str,
text: str,
kategorie: str,
) -> None:
"""Stellt die Nachricht über diesen Kanal zu."""
class DevLogAdapter(BenachrichtigungsAdapter):
"""Protokolliert Benachrichtigungen statt sie zu versenden."""
def __init__(self, schluessel: str, label: str) -> None:
self.schluessel = schluessel
self.label = label
self.dev_fallback = True
async def senden(
self,
user_id: int,
empfaenger: str | None,
titel: str,
text: str,
kategorie: str,
) -> None:
logger.info(
"[DEV] %s an Benutzer %s (%s): %s%s",
self.label,
user_id,
empfaenger or "ohne Adresse",
titel,
text,
)
class EmailSmtpAdapter(BenachrichtigungsAdapter):
"""Versendet E-Mail über einen SMTP-Server (Konfiguration via Env)."""
schluessel = "email"
label = "E-Mail"
def __init__(self, konfiguration: Konfiguration) -> None:
self.konf = konfiguration
async def senden(
self,
user_id: int,
empfaenger: str | None,
titel: str,
text: str,
kategorie: str,
) -> None:
if not empfaenger:
raise ValueError("Keine E-Mail-Adresse für den Empfänger hinterlegt.")
await asyncio.to_thread(self._senden_sync, empfaenger, titel, text)
def _senden_sync(self, empfaenger: str, titel: str, text: str) -> None:
nachricht = EmailMessage()
nachricht["Subject"] = f"[Spiele-Redaktion] {titel}"
nachricht["From"] = self.konf.smtp_absender
nachricht["To"] = empfaenger
nachricht.set_content(text)
with _smtp_verbindung(self.konf) as server:
server.send_message(nachricht)
class TelegramAdapter(BenachrichtigungsAdapter):
"""Versendet Nachrichten über die Telegram Bot-API (sendMessage)."""
schluessel = "telegram"
label = "Telegram"
def __init__(self, bot_token: str) -> None:
self.bot_token = bot_token
async def senden(
self,
user_id: int,
empfaenger: str | None,
titel: str,
text: str,
kategorie: str,
) -> None:
if not empfaenger:
raise ValueError("Keine Telegram-Chat-ID für den Empfänger hinterlegt.")
nachricht = f"{titel}\n\n{text}"
await asyncio.to_thread(self._api_senden, empfaenger, nachricht)
def _api_senden(self, chat_id: str, nachricht: str) -> dict:
"""Ein Aufruf der Bot-API; Test-Seam (wird in Tests überschrieben)."""
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
daten = json.dumps({"chat_id": chat_id, "text": nachricht}).encode("utf-8")
anfrage = urllib.request.Request(
url, data=daten, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(anfrage, timeout=15) as antwort:
ergebnis = json.load(antwort)
if not ergebnis.get("ok"):
raise RuntimeError(f"Telegram-API-Fehler: {ergebnis}")
return ergebnis
class InAppAdapter(BenachrichtigungsAdapter):
"""Persistiert die Nachricht als In-App-Nachricht im Portal."""
schluessel = "inapp"
label = "In-App"
def __init__(self, session_factory: "sessionmaker") -> None:
self._session_factory = session_factory
async def senden(
self,
user_id: int,
empfaenger: str | None,
titel: str,
text: str,
kategorie: str,
) -> None:
from .models import Benachrichtigung
def _schreiben() -> None:
with self._session_factory() as db:
db.add(
Benachrichtigung(
user_id=user_id, titel=titel, text=text, kategorie=kategorie
)
)
db.commit()
await asyncio.to_thread(_schreiben)
def adapter_aus_konfiguration(
konf: Konfiguration, session_factory: "sessionmaker"
) -> dict[str, BenachrichtigungsAdapter]:
"""Baut den Adapter-Satz der Installation; fehlende Konfiguration → Dev-Log."""
email_adapter: BenachrichtigungsAdapter
telegram_adapter: BenachrichtigungsAdapter
if konf.smtp_konfiguriert:
email_adapter = EmailSmtpAdapter(konf)
else:
email_adapter = DevLogAdapter("email", "E-Mail (nur Protokoll)")
if konf.telegram_konfiguriert:
telegram_adapter = TelegramAdapter(konf.telegram_bot_token)
else:
telegram_adapter = DevLogAdapter("telegram", "Telegram (nur Protokoll)")
return {
"email": email_adapter,
"telegram": telegram_adapter,
"inapp": InAppAdapter(session_factory),
}
def _smtp_verbindung(konf: Konfiguration):
"""Öffnet eine SMTP-Verbindung gemäß TLS-Einstellung (Kontext-Manager)."""
if konf.smtp_tls == "ssl":
server = smtplib.SMTP_SSL(konf.smtp_host, konf.smtp_port, timeout=15)
_anmelden(server, konf)
return server
verbindung = smtplib.SMTP(konf.smtp_host, konf.smtp_port, timeout=15)
if konf.smtp_tls == "starttls":
verbindung.starttls(context=ssl.create_default_context())
_anmelden(verbindung, konf)
return verbindung
def _anmelden(server, konf: Konfiguration) -> None:
if konf.smtp_benutzer and konf.smtp_passwort:
server.login(konf.smtp_benutzer, konf.smtp_passwort)

View File

@@ -0,0 +1,53 @@
"""Plugin-Konfiguration über Umgebungsvariablen.
Der Plugin-Kontrakt verbietet Fachlogik im Kern — deshalb liest dieses
Plugin seine Kanal-Konfiguration selbst aus der Umgebung, statt Felder in
`redaktionskern.config.Settings` zu ergänzen.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
def _env(name: str, standard: str = "") -> str:
wert = os.environ.get(name)
return wert.strip() if wert else standard
@dataclass(frozen=True)
class Konfiguration:
"""Zustellkanal-Einstellungen der Installation (nicht pro Benutzer)."""
smtp_host: str
smtp_port: int
smtp_benutzer: str
smtp_passwort: str
smtp_absender: str
#: "starttls" (Standard), "ssl" oder "keine"
smtp_tls: str
telegram_bot_token: str
@property
def smtp_konfiguriert(self) -> bool:
return bool(self.smtp_host)
@property
def telegram_konfiguriert(self) -> bool:
return bool(self.telegram_bot_token)
def aus_umgebung() -> Konfiguration:
benutzer = _env("SPIELE_SMTP_BENUTZER")
tls = _env("SPIELE_SMTP_TLS", "starttls").lower()
if tls not in ("starttls", "ssl", "keine"):
tls = "starttls"
return Konfiguration(
smtp_host=_env("SPIELE_SMTP_HOST"),
smtp_port=int(_env("SPIELE_SMTP_PORT", "587")),
smtp_benutzer=benutzer,
smtp_passwort=_env("SPIELE_SMTP_PASSWORT"),
smtp_absender=_env("SPIELE_SMTP_ABSENDER") or benutzer or "spiele-redaktion@localhost",
smtp_tls=tls,
telegram_bot_token=_env("SPIELE_TELEGRAM_BOT_TOKEN"),
)

View File

@@ -0,0 +1,80 @@
"""Datenmodelle des Plugins „benachrichtigung“.
Alle Tabellen tragen den Präfix `benachrichtigung_` und nutzen portable
Spaltentypen (SQLite ↔ Postgres). Der Kern bleibt unverändert — auch die
Benutzer-Tabelle wird nicht angetastet; Kanal-Kontaktdaten (E-Mail-Adresse,
Telegram-Chat-ID) liegen in der eigenen Kontakt-Tabelle.
`extend_existing` erlaubt das erneute Ausführen des Moduls durch den
Plugin-Loader (jeder App-Start lädt das Plugin frisch bzw. Tests können es
über einen zweiten Pfad importieren), ohne dass die gemeinsame Metadata
doppelt definierte Tabellen meldet. Die Modelle definieren bewusst keine
ORM-Indizes: Bei erneuter Ausführung würde ein deklarativ definierter Index
doppelt im Table-Objekt landen und die Migration würde ihn zweimal anlegen.
Die Datenmengen sind klein — auf Indizes wird verzichtet.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from redaktionskern.db import Base
#: Gültige Kanal-Schlüssel (Reihenfolge = Anzeigereihenfolge im Formular).
KANAELLE: dict[str, str] = {
"email": "E-Mail",
"telegram": "Telegram",
"inapp": "In-App",
}
STANDARD_KANAELLE = "inapp"
class Benachrichtigung(Base):
"""Eine In-App-Nachricht im Portal (Posteingang des Benutzers)."""
__tablename__ = "benachrichtigung"
__table_args__ = {"extend_existing": True}
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
titel: Mapped[str] = mapped_column(String(300))
text: Mapped[str] = mapped_column(Text)
kategorie: Mapped[str] = mapped_column(String(100), default="allgemein")
gelesen: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
class Praeferenz(Base):
"""Kanal-Präferenzen eines Benutzers (mehrere Kanäle gleichzeitig möglich)."""
__tablename__ = "benachrichtigung_praeferenz"
__table_args__ = {"extend_existing": True}
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True)
#: Komma-separierte Kanal-Schlüssel, z. B. "email,inapp".
kanaele: Mapped[str] = mapped_column(String(200), default=STANDARD_KANAELLE)
aktualisiert_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)
def kanaele_als_liste(self) -> list[str]:
gueltig = [k for k in self.kanaele.split(",") if k in KANAELLE]
return gueltige_reihenfolge(gueltig)
def gueltige_reihenfolge(auswahl: list[str]) -> list[str]:
"""Sortiert eine Kanal-Auswahl in die definierte Anzeigereihenfolge."""
return [k for k in KANAELLE if k in auswahl]
class Kontakt(Base):
"""Kanal-spezifische Kontaktdaten eines Benutzers (pro Benutzer höchstens eine Zeile)."""
__tablename__ = "benachrichtigung_kontakt"
__table_args__ = {"extend_existing": True}
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True)
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
telegram_chat_id: Mapped[str | None] = mapped_column(String(50), nullable=True)

View File

@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block titel %}{{ titel }} — {{ app_name }}{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-1">Benachrichtigungen &mdash; Einstellungen</h1>
<p class="text-slate-600 mb-4">
W&auml;hlen Sie, &uuml;ber welche Kan&auml;le Sie Benachrichtigungen erhalten.
Mehrere Kan&auml;le sind gleichzeitig m&ouml;glich.
</p>
<a href="/benachrichtigung" class="text-sm text-emerald-700 hover:underline">&larr; Zur&uuml;ck zum Posteingang</a>
{% if gespeichert %}
<div class="mt-4 mb-2 rounded-lg bg-emerald-50 border border-emerald-200 text-emerald-800 px-4 py-2 text-sm">
Einstellungen gespeichert.
</div>
{% endif %}
{% if fehler %}
<div class="mt-4 mb-2 rounded-lg bg-red-50 border border-red-200 text-red-800 px-4 py-2 text-sm">
{{ fehler }}
</div>
{% endif %}
<form method="post" action="/benachrichtigung/einstellungen"
class="mt-4 bg-white rounded-xl border border-slate-200 p-5 max-w-2xl space-y-5">
<fieldset>
<legend class="text-sm font-semibold mb-2">Kan&auml;le</legend>
<div class="space-y-2">
{% for schluessel, status in kanal_status.items() %}
<label class="flex items-start gap-3 p-2 rounded hover:bg-slate-50 cursor-pointer">
<input type="checkbox" name="kanaele" value="{{ schluessel }}"
{% if schluessel in gewaehlte %}checked{% endif %}
class="mt-1 h-4 w-4 accent-emerald-600">
<span>
<span class="font-medium">{{ status.label }}</span>
{% if status.aktiv %}
<span class="ml-1 text-xs bg-emerald-100 text-emerald-700 rounded px-1.5 py-0.5">konfiguriert</span>
{% else %}
<span class="ml-1 text-xs bg-amber-100 text-amber-700 rounded px-1.5 py-0.5">nicht konfiguriert &mdash; nur Serverprotokoll</span>
{% endif %}
{% if schluessel == 'email' %}
<span class="block text-sm text-slate-500">Versand &uuml;ber SMTP; ohne Mailserver erscheint die Nachricht im Protokoll.</span>
{% elif schluessel == 'telegram' %}
<span class="block text-sm text-slate-500">Bot-Token wird zentral gesetzt (Umgebungsvariable), die Chat-ID hinterlegen Sie hier.</span>
{% elif schluessel == 'inapp' %}
<span class="block text-sm text-slate-500">Nachricht erscheint im Posteingang des Portals.</span>
{% endif %}
</span>
</label>
{% endfor %}
</div>
</fieldset>
<div>
<label for="emailadresse" class="block text-sm font-semibold mb-1">E-Mail-Adresse</label>
<input id="emailadresse" name="emailadresse" type="email" value="{{ emailadresse }}"
placeholder="name@example.org"
class="w-full rounded-lg border border-slate-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label for="telegram_chat_id" class="block text-sm font-semibold mb-1">Telegram-Chat-ID</label>
<input id="telegram_chat_id" name="telegram_chat_id" value="{{ telegram_chat_id }}"
placeholder="z. B. 123456789 (Gruppen mit Minus)"
class="w-full rounded-lg border border-slate-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div class="flex items-center gap-3">
<button type="submit" class="px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700">
Speichern
</button>
<span class="text-xs text-slate-400">Ohne ausgew&auml;hlten Kanal erhalten Sie keine Benachrichtigungen.</span>
</div>
</form>
{% endblock %}

View File

@@ -1,10 +1,56 @@
{% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block titel %}{{ titel }} &mdash; {{ app_name }}{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-2xl">
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
in Version {{ version }} ist geladen.
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
<div class="flex flex-wrap items-center gap-3 mb-1">
<h1 class="text-2xl font-bold">{{ titel }}</h1>
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-medium
{% if ungelesen %}bg-red-100 text-red-700{% else %}bg-slate-200 text-slate-500{% endif %}">
{{ ungelesen }} ungelesen
</span>
</div>
<p class="text-slate-600 mb-4">
Zustellung aktuell &uuml;ber:
{% if gewaehlte_kanaelle %}<span class="font-medium">{{ gewaehlte_kanaelle|join(", ") }}</span>
{% else %}<span class="text-red-600">kein Kanal ausgew&auml;hlt</span>{% endif %}
</p>
<div class="flex flex-wrap gap-2 mb-6">
<form method="post" action="/benachrichtigung/alle-gelesen">
<button type="submit" class="px-3 py-1.5 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700">
Alle als gelesen markieren
</button>
</form>
<a href="/benachrichtigung/einstellungen"
class="px-3 py-1.5 rounded-lg border border-slate-300 bg-white text-sm font-medium hover:bg-slate-50">
Kan&auml;le &amp; Einstellungen
</a>
</div>
{% if mitteilungen %}
<div class="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
{% for m in mitteilungen %}
<div class="px-4 py-3 flex flex-col gap-1 {% if not m.gelesen %}border-l-4 border-l-emerald-500{% endif %}">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span class="{% if m.gelesen %}text-slate-600{% else %}font-semibold text-slate-900{% endif %}">{{ m.titel }}</span>
<span class="text-xs uppercase tracking-wide bg-slate-100 text-slate-500 rounded px-1.5 py-0.5">{{ m.kategorie }}</span>
<span class="text-xs text-slate-400 ml-auto">{{ m.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
</div>
<p class="text-sm text-slate-600 whitespace-pre-line">{{ m.text }}</p>
<div class="text-xs">
{% if m.gelesen %}
<span class="text-slate-400">Gelesen</span>
{% else %}
<form method="post" action="/benachrichtigung/{{ m.id }}/gelesen">
<button type="submit" class="text-emerald-700 hover:underline">Als gelesen markieren</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-slate-500 bg-white rounded-xl border border-slate-200 px-4 py-8 text-center">
Keine Benachrichtigungen vorhanden.
</p>
{% endif %}
{% endblock %}