- 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)
149 lines
5.0 KiB
Python
149 lines
5.0 KiB
Python
"""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."
|
|
)
|