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,148 @@
"""Geschäftslogik des Plugins „erinnerung“.
Alle Regeln liegen bewusst hier (und nicht im Kern):
- **Fenster:** Vier Wochen (28 Tage) vor dem Redaktionsschluss öffnet das
Erinnerungsfenster; es gilt bis zum Redaktionsschluss (inklusive). Danach
wird nicht mehr erinnert — vor dem Fenster erst recht nicht.
- **Empfänger:** alle aktiven Rezensenten (Rolle „rezensent“), die noch
offene Planungseinträge haben (Status ≠ „abgeschlossen“, also „offen“
oder „in Bearbeitung“) oder gar keine Einträge. Wer alles abgeschlossen
hat, wird nicht erinnert; Admins und Redakteure nie.
- **Uhr:** `heute()` liefert das aktuelle Datum und ist die einzige
Zeitquelle der Prüfung — Tests frieren sie ein (freezegun oder
Monkeypatch), der geplante Job und die Routen nutzen sie unverändert.
Die Planungsliste wird nicht importiert, sondern über die gemeinsame
SQLAlchemy-Metadata angesprochen (wie im planung-Plugin mit der
Neuheiten-Tabelle): Fehlt das planung-Plugin, entfällt die Unterscheidung
„hat offene Einträge“ und alle Rezensenten werden erinnert.
"""
from __future__ import annotations
from datetime import date
from sqlalchemy import select
from sqlalchemy.orm import Session
from redaktionskern.auth.models import Role, User
from redaktionskern.db import Base
from .models import Ausgabe, ErinnerungsProtokoll
#: Länge des Erinnerungsfensters in Tagen (4 Wochen vor Redaktionsschluss).
ERINNERUNGSFENSTER_TAGE = 4 * 7
#: Statuswert des planung-Plugins für „fertig“ (Kopie ohne Import-Abhängigkeit).
STATUS_ABGESCHLOSSEN = "abgeschlossen"
def heute() -> date:
"""Aktuelles Datum — die eingefrierbare Uhr dieses Plugins."""
return date.today()
def tage_bis(schluss: date, tag: date) -> int:
"""Tage vom Prüfdatum bis zum Redaktionsschluss (negativ = vorbei)."""
return (schluss - tag).days
def im_erinnerungsfenster(schluss: date, tag: date) -> bool:
"""True, wenn am Prüfdatum erinnert werden darf.
Fenster: `schluss - 28 Tage` … `schluss` (jeweils inklusive).
"""
return 0 <= tage_bis(schluss, tag) <= ERINNERUNGSFENSTER_TAGE
def parse_datum(roh: str) -> date | None:
"""Parst ein ISO-Datum (JJJJ-MM-TT); None bei leer, ValueError bei Unsinn."""
roh = (roh or "").strip()
if not roh:
return None
return date.fromisoformat(roh)
def alle_ausgaben(db: Session) -> list[Ausgabe]:
"""Alle Ausgaben, frühester Redaktionsschluss zuerst."""
return list(
db.scalars(select(Ausgabe).order_by(Ausgabe.redaktionsschluss, Ausgabe.id)).all()
)
def faellige_ausgaben(db: Session, tag: date) -> list[Ausgabe]:
"""Ausgaben, deren Erinnerungsfenster am Prüfdatum offen ist."""
return [
ausgabe
for ausgabe in alle_ausgaben(db)
if im_erinnerungsfenster(ausgabe.redaktionsschluss, tag)
]
def bereits_erinnerte_ids(db: Session, ausgabe_id: int) -> set[int]:
"""Benutzer, die für diese Ausgabe schon erinnert wurden (Doppelschutz)."""
return set(
db.scalars(
select(ErinnerungsProtokoll.user_id).where(
ErinnerungsProtokoll.ausgabe_id == ausgabe_id
)
).all()
)
def planungs_statistik(db: Session) -> tuple[set[int], set[int]]:
"""Liefert (IDs mit offenen Einträgen, IDs mit Einträgen überhaupt)."""
tabelle = Base.metadata.tables.get("planungsliste")
if tabelle is None:
# planung-Plugin nicht geladen → keine Unterscheidung möglich.
return set(), set()
offene = {
zeile[0]
for zeile in db.execute(
select(tabelle.c.rezensent_id)
.where(tabelle.c.status != STATUS_ABGESCHLOSSEN)
.distinct()
).all()
}
alle = {
zeile[0]
for zeile in db.execute(select(tabelle.c.rezensent_id).distinct()).all()
}
return offene, alle
def empfaenger(db: Session) -> list[User]:
"""Die zu erinnierenden Rezensenten (siehe Moduldokumentation).
Rezensent mit offenen Einträgen → erinnert.
Rezensent ohne jeden Eintrag → erinnert.
Rezensent nur mit abgeschlossenen Einträgen → nicht erinnert.
"""
rezensenten = list(
db.scalars(
select(User)
.where(User.role == Role.REZENSENT.value, User.active.is_(True))
.order_by(User.username)
).all()
)
offene, alle = planungs_statistik(db)
return [
benutzer
for benutzer in rezensenten
if benutzer.id not in alle or benutzer.id in offene
]
def nachricht_text(ausgabe: Ausgabe, tage_rest: int) -> str:
"""Text der Erinnerungs-Benachrichtigung (deutsch)."""
if tage_rest == 0:
rest = "heute ist der Redaktionsschluss"
elif tage_rest == 1:
rest = "morgen ist der Redaktionsschluss"
else:
rest = f"der Redaktionsschluss ist in {tage_rest} Tagen"
return (
f"Ausgabe „{ausgabe.name}“: {rest} ({ausgabe.redaktionsschluss.strftime('%d.%m.%Y')}).\n"
"Bitte schließe deine Planungseinträge ab oder trage noch fehlende Titel "
"unter „Planung“ nach."
)