"""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." )