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:
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)
|
||||
Reference in New Issue
Block a user