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:
148
plugins/erinnerung/dienst.py
Normal file
148
plugins/erinnerung/dienst.py
Normal 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."
|
||||
)
|
||||
Reference in New Issue
Block a user