Plugin erinnerung: Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung

- Ausgaben (Name + Redaktionsschluss-Datum) verwalten, mehrere parallel,
  Anlegen/Bearbeiten/Löschen nur für Admin, mit Audit-Log-Einträgen
- Erinnerung 28 Tage vor dem Redaktionsschluss an alle aktiven Rezensenten
  mit offener (oder fehlender) Planung, Versand über das benachrichtigung-
  Plugin je Kanal-Präferenz, Audit als System/benachrichtigt
- Täglicher APScheduler-Job (SPIELE_ERINNERUNG_JOB_UHRZEIT, abschaltbar)
  plus manuelle Sofort-Prüfung in der Admin-Ansicht
- Doppelschutz: Protokolltabelle mit Unique (Ausgabe, Benutzer)
- Admin-UI: Ausgabenliste mit Restzeit-Badges, Erinnerungsstatus
  (wer wurde wann über welche Kanäle erinnert), deutsche Templates
- Tests mit eingefrorener Uhr (freezegun): Fenster-Grenzen, kein Versand
  vor Frist/nach Schluss, Empfänger-/Rollenregeln, keine Duplikate,
  Scheduler-Konfiguration; Integrationstest mit den echten Plugins
  (167 Tests grün)
This commit is contained in:
Flo Hartmann
2026-08-21 20:31:13 +00:00
parent 0f12cd952b
commit 469cf8826e
11 changed files with 1549 additions and 23 deletions

View File

@@ -0,0 +1,64 @@
"""Datenmodelle des Plugins „erinnerung“.
Zwei eigene Tabellen (SQLite ↔ Postgres portabel), der Kern bleibt unberührt:
- `erinnerung_ausgabe` — eine Magazin-Ausgabe mit Redaktionsschluss-Datum.
Mehrere Ausgaben laufen parallel; der Name ist frei wählbar (z. B. „3/2025“).
- `erinnerung_protokoll` — protokolliert jede versendete Erinnerung
(Ausgabe + Benutzer). Die Eindeutigkeit von (ausgabe_id, user_id) stellt
auf Datenbankebene sicher, dass niemand zweimal pro Ausgabe erinnert wird.
Keine ORM-Indizes: Bei erneuter Ausführung des Moduls würde ein deklarativ
definierter Index doppelt im Table-Objekt landen und die Migration würde ihn
zweimal anlegen (gleiche Begründung wie im benachrichtigung-Plugin).
"""
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from redaktionskern.db import Base
class Ausgabe(Base):
"""Eine Magazin-Ausgabe mit ihrem Redaktionsschluss-Datum."""
__tablename__ = "erinnerung_ausgabe"
__table_args__ = {"extend_existing": True}
id: Mapped[int] = mapped_column(primary_key=True)
#: Name/Titel der Ausgabe, z. B. „3/2025“ oder „Sommer-Sonderheft“.
name: Mapped[str] = mapped_column(String(200))
#: Redaktionsschluss (Datum, ohne Uhrzeit).
redaktionsschluss: Mapped[date] = mapped_column(Date)
erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
aktualisiert_am: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
return f"<Ausgabe {self.name!r} (Redaktionsschluss {self.redaktionsschluss})>"
class ErinnerungsProtokoll(Base):
"""Eine versendete Erinnerung: wer wurde zu welcher Ausgabe wann erinnert."""
__tablename__ = "erinnerung_protokoll"
__table_args__ = (
UniqueConstraint("ausgabe_id", "user_id", name="uq_erinnerung_ausgabe_user"),
{"extend_existing": True},
)
id: Mapped[int] = mapped_column(primary_key=True)
ausgabe_id: Mapped[int] = mapped_column(ForeignKey("erinnerung_ausgabe.id"))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
#: Zugestellte Kanäle (Komma-liste, z. B. „email,inapp“) — wie vom
#: benachrichtigung-Plugin gemeldet; leer, falls kein Kanal zustellte.
kanaele: Mapped[str] = mapped_column(String(200), default="")
#: Wann wurde die Erinnerung ausgelöst.
erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
return f"<ErinnerungsProtokoll Ausgabe={self.ausgabe_id} User={self.user_id}>"