Merge wt-erinnerung: Erinnerungs-Plugin (Konflikte aufgelöst)

This commit is contained in:
Flo Hartmann
2026-08-21 20:37:20 +00:00
11 changed files with 1552 additions and 25 deletions

View File

@@ -1,41 +1,538 @@
"""Plugin „erinnerung“ — Platzhalter gemäß Plugin-Vertrag.
"""Plugin „erinnerung“ — Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung.
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
Umfang:
- **Ausgaben verwalten** (nur Rolle Admin): Name/Titel + Redaktionsschluss-Datum,
mehrere Ausgaben parallel, anlegen/bearbeiten/löschen.
- **Automatische Erinnerung:** vier Wochen vor dem Redaktionsschluss erhält
jeder aktive Rezensent eine Benachrichtigung, der noch offene
Planungseinträge hat (Status ≠ „abgeschlossen“) oder gar keine — über das
Plugin „benachrichtigung“ (`send_notification`), Kanäle je Benutzer-Präferenz.
- **Geplanter Job:** APScheduler, täglich einmal (Uhrzeit per Env
`SPIELE_ERINNERUNG_JOB_UHRZEIT`, abschaltbar per `SPIELE_ERINNERUNG_JOB_AKTIV`).
- **Keine Doppelerinnerung:** pro (Ausgabe, Benutzer) wird genau einmal
erinnert; der Versand wird in `erinnerung_protokoll` protokolliert.
- **Audit-Log:** Ausgaben-CRUD und jede Erinnerung werden über das
Plugin „audit-log“ protokolliert (Erinnerung als „System“/`benachrichtigt`).
Geschäftsregeln liegen in `dienst.py` (einfrierbare Uhr: `heute()`),
Partner-Plugins werden wie überall über die Registry geholt; fehlen sie,
wird der Durchlauf ohne Versand/Protokoll übersprungen bzw. ohne Audit
fortgesetzt — der Vorgang bricht nie.
"""
from __future__ import annotations
from fastapi import Depends, Request
import asyncio
import logging
import sys
import threading
from dataclasses import dataclass, field
from datetime import date
from urllib.parse import quote
from redaktionskern.auth.deps import require_user
from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, NavEntry
from fastapi import Depends, Form, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_roles, require_user
from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext
# Bewusst `from .dienst import …` (wie `from .models import …` überall sonst):
# `from . import dienst` funktioniert unter dem dateibasierten Plugin-Loader
# nicht zuverlässig (Python 3.14 versucht dort einen absoluten Import der
# Namensraum-Oberpakete).
from .dienst import (
ERINNERUNGSFENSTER_TAGE,
alle_ausgaben,
bereits_erinnerte_ids,
empfaenger,
faellige_ausgaben,
heute,
im_erinnerungsfenster,
nachricht_text,
parse_datum,
tage_bis,
)
from .models import Ausgabe, ErinnerungsProtokoll
_logger = logging.getLogger("plugins.erinnerung")
MODUL_PRAEFIX = "spiele_redaktion_plugins"
JOB_ID = "erinnerungs-check"
MAX_MELDUNGS_LAENGE = 300
@dataclass
class Pruefbericht:
"""Ergebnis eines `pruefe_und_erinnere`-Durchlaufs."""
gepruefte_ausgaben: int = 0
versendet: list[dict] = field(default_factory=list)
#: (Ausgabe, Benutzer)-Paare im Fenster, die wegen Protokolleintrag
#: übersprungen wurden (Doppelschutz gegriffen).
uebersprungen: int = 0
@property
def anzahl_versendet(self) -> int:
return len(self.versendet)
def als_text(self) -> str:
if self.gepruefte_ausgaben == 0:
return "Keine Ausgaben mit offenem Erinnerungsfenster."
return (
f"{self.gepruefte_ausgaben} Ausgabe(n) im Erinnerungsfenster geprüft, "
f"{self.anzahl_versendet} Erinnerung(en) versendet, "
f"{self.uebersprungen} bereits erinnert."
)
def _tabellen_erstellen(conn) -> None:
"""Eigene Migration des Plugins: legt beide Tabellen an (idempotent)."""
Ausgabe.__table__.create(conn, checkfirst=True)
ErinnerungsProtokoll.__table__.create(conn, checkfirst=True)
class ErinnerungPlugin(BasePlugin):
name = "erinnerung"
title = "Erinnerungen"
description = "Erinnerungen vor Redaktionsschluss pro Ausgabe (Platzhalter)."
description = (
"Vier Wochen vor dem Redaktionsschluss pro Ausgabe Erinnerung an alle "
"Rezensenten mit offener (oder fehlender) Planung."
)
def __init__(self) -> None:
super().__init__()
self._scheduler = None # BackgroundScheduler, falls aktiviert
self._lauf_sperre = threading.Lock()
self._registry = None # wird bei Requests gemerkt (Partner-Auflösung)
self._routen_registrieren()
# ---------- Plugin-Vertrag ----------
def migrations(self) -> list[Migration]:
return [Migration(version="0001_ausgaben_und_protokoll", up=_tabellen_erstellen)]
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/erinnerung")]
def on_load(self, context: PluginContext) -> None:
super().on_load(context)
import os
if os.environ.get("SPIELE_ERINNERUNG_JOB_AKTIV", "1").strip() == "1":
self._scheduler_starten(os.environ.get("SPIELE_ERINNERUNG_JOB_UHRZEIT"))
def on_unload(self) -> None:
if self._scheduler is not None:
self._scheduler.shutdown(wait=False)
self._scheduler = None
super().on_unload()
# ---------- Partner-Plugins (benachrichtigung, audit-log) ----------
def _merke_registry(self, registry) -> None:
"""Merk sich die Registry, damit auch der Hintergrund-Job Partner
auflösen kann (auch ohne vorherigen Request via sys.modules-Fallback)."""
self._registry = registry
def _partner(self, name: str):
"""Löst ein Partner-Plugin auf: Registry zuerst, sonst Modul-Lookup.
Der Fallback über `sys.modules` deckt den Fall „Job läuft, bevor der
erste Request die Registry gemerkt hat“ ab (Verzeichnis-Plugins).
"""
if self._registry is not None:
partner = self._registry.get(name)
if partner is not None:
return partner
modul = sys.modules.get(f"{MODUL_PRAEFIX}.{name}")
return getattr(modul, "plugin", None) if modul else None
# ---------- Öffentliche API für andere Plugins/Tests ----------
async def pruefe_und_erinnere(
self,
jetzt: date | None = None,
*,
benachrichtigung=None,
audit=None,
) -> Pruefbericht:
"""Prüft alle Ausgaben gegen die eingefrierbare Uhr und erinnert.
`jetzt` überschreibt die Uhr (Tests); ohne Angabe gilt `heute()`.
Partner-Plugins können übergeben werden, sonst werden sie über die
Registry aufgelöst. Der Durchlauf wirft nicht für einzelne Empfänger:
Fehlschläge werden protokolliert und im Bericht gezählt.
"""
assert self.context is not None
if benachrichtigung is None:
benachrichtigung = self._partner("benachrichtigung")
if audit is None:
audit = self._partner("audit-log")
pruef_tag = jetzt if jetzt is not None else heute()
bericht = Pruefbericht()
with self.context.session_factory() as db:
for ausgabe in faellige_ausgaben(db, pruef_tag):
bericht.gepruefte_ausgaben += 1
tage_rest = tage_bis(ausgabe.redaktionsschluss, pruef_tag)
schon_erinnert = bereits_erinnerte_ids(db, ausgabe.id)
text = nachricht_text(ausgabe, tage_rest)
for empfaenger_user in empfaenger(db):
if empfaenger_user.id in schon_erinnert:
bericht.uebersprungen += 1
continue
kanaele: list[str] = []
if benachrichtigung is not None:
try:
zustellbericht = await benachrichtigung.send_notification(
empfaenger_user,
f"Redaktionsschluss: {ausgabe.name}",
text,
"erinnerung",
)
kanaele = list(getattr(zustellbericht, "zugestellt", []))
except Exception:
# Ein Kanalfehler darf den Durchlauf nicht abbrechen;
# ohne Protokolleintrag würde der nächste Lauf es
# erneut (und hoffentlich erfolgreich) versuchen.
_logger.exception(
"Erinnerung an %s (Ausgabe %s) fehlgeschlagen.",
empfaenger_user.username,
ausgabe.name,
)
continue
db.add(
ErinnerungsProtokoll(
ausgabe_id=ausgabe.id,
user_id=empfaenger_user.id,
kanaele=",".join(kanaele),
)
)
db.commit()
bericht.versendet.append(
{
"ausgabe": ausgabe.name,
"ausgabe_id": ausgabe.id,
"benutzer": empfaenger_user.username,
"user_id": empfaenger_user.id,
"kanaele": kanaele,
"tage_rest": tage_rest,
}
)
if audit is not None:
try:
await audit.log(
None,
"benachrichtigt",
"erinnerung",
empfaenger_user.id,
{
"ausgabe": ausgabe.name,
"ausgabe_id": ausgabe.id,
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
"tage_rest": tage_rest,
"kanaele": kanaele,
},
)
except Exception:
_logger.exception(
"Audit-Log-Eintrag für Erinnerung fehlgeschlagen."
)
return bericht
def pruefe_und_erinnere_sync(
self,
jetzt: date | None = None,
*,
benachrichtigung=None,
audit=None,
) -> Pruefbericht:
"""Synchrone Variante von `pruefe_und_erinnere` (wie audit.log_sync).
Für Kontexte ohne laufende Event-Loop (APScheduler-Thread, Tests).
Darf nicht aus einer async-Funktion heraus aufgerufen werden.
"""
return asyncio.run(
self.pruefe_und_erinnere(
jetzt, benachrichtigung=benachrichtigung, audit=audit
)
)
# ---------- Hintergrund-Job ----------
def _scheduler_starten(self, uhrzeit_roh: str | None) -> None:
from apscheduler.schedulers.background import BackgroundScheduler
stunde, minute = 8, 0
roh = (uhrzeit_roh or "08:00").strip()
try:
teile = roh.split(":")
stunde = max(0, min(23, int(teile[0])))
minute = max(0, min(59, int(teile[1]))) if len(teile) > 1 else 0
except (ValueError, IndexError):
stunde, minute = 8, 0
scheduler = BackgroundScheduler()
scheduler.add_job(
self._job,
trigger="cron",
hour=stunde,
minute=minute,
id=JOB_ID,
replace_existing=True,
)
scheduler.start()
self._scheduler = scheduler
_logger.info(
"Erinnerungs-Check aktiv: täglich um %02d:%02d Uhr.", stunde, minute
)
def _job(self) -> None:
"""Hintergrund-Job: tägliche Prüfung (synchroner Kontext, eigener Thread)."""
if not self._lauf_sperre.acquire(blocking=False):
_logger.info("Erinnerungs-Check läuft bereits — Durchlauf übersprungen.")
return
try:
bericht = self.pruefe_und_erinnere_sync()
_logger.info("Erinnerungs-Check abgeschlossen: %s", bericht.als_text())
except Exception:
_logger.exception("Erinnerungs-Check fehlgeschlagen.")
finally:
self._lauf_sperre.release()
# ---------- Routen ----------
@staticmethod
def _meldung(url: str, text: str, art: str = "meldung") -> RedirectResponse:
return RedirectResponse(f"{url}?{art}={quote(text)}", status_code=303)
def _routen_registrieren(self) -> None:
@self.router.get("/erinnerung")
def seite(request: Request, user: User = Depends(require_user)):
"""Platzhalterseite des Plugins."""
def seite(
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
meldung: str = "",
fehler: str = "",
):
"""Übersicht: Ausgaben mit Fristen; Admins verwalten und sehen
den Erinnerungsstatus (wer wurde wann erinnert)."""
self._merke_registry(request.app.state.registry)
ist_admin = user.role == Role.ADMIN.value
pruef_tag = heute()
ausgaben = alle_ausgaben(db)
zeilen = []
for ausgabe in ausgaben:
tage_rest = tage_bis(ausgabe.redaktionsschluss, pruef_tag)
zeilen.append(
{
"ausgabe": ausgabe,
"tage_rest": tage_rest,
"im_fenster": im_erinnerungsfenster(
ausgabe.redaktionsschluss, pruef_tag
),
"erinnert_anzahl": db.scalar(
select(func.count())
.select_from(ErinnerungsProtokoll)
.where(ErinnerungsProtokoll.ausgabe_id == ausgabe.id)
)
or 0,
}
)
protokoll = []
if ist_admin:
namen = {
benutzer.id: (benutzer.display_name or benutzer.username)
for benutzer in db.scalars(select(User)).all()
}
eintraege = list(
db.scalars(
select(ErinnerungsProtokoll).order_by(
ErinnerungsProtokoll.erstellt_am.desc(),
ErinnerungsProtokoll.id.desc(),
)
).all()
)
ausgaben_namen = {a.id: a.name for a in ausgaben}
for eintrag in eintraege:
protokoll.append(
{
"eintrag": eintrag,
"ausgabe_name": ausgaben_namen.get(eintrag.ausgabe_id, ""),
"benutzer": namen.get(eintrag.user_id, ""),
}
)
return self.context.templates.TemplateResponse(
request=request,
name="erinnerung/index.html",
context={
"user": user,
"titel": self.title,
"name": self.name,
"version": self.version,
"zeilen": zeilen,
"ist_admin": ist_admin,
"protokoll": protokoll,
"fenster_tage": ERINNERUNGSFENSTER_TAGE,
"meldung": meldung[:MAX_MELDUNGS_LAENGE],
"fehler": fehler[:MAX_MELDUNGS_LAENGE],
},
)
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/erinnerung")]
@self.router.post("/erinnerung/ausgaben")
def ausgabe_anlegen(
request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_roles(Role.ADMIN.value)),
name: str = Form(""),
redaktionsschluss: str = Form(""),
):
self._merke_registry(request.app.state.registry)
roh_name = name.strip()
if not roh_name:
return self._meldung("/erinnerung", "Bitte einen Ausgaben-Namen angeben.", "fehler")
try:
schluss = parse_datum(redaktionsschluss)
except ValueError:
return self._meldung(
"/erinnerung",
"Ungültiges Datum — bitte als JJJJ-MM-TT angeben.",
"fehler",
)
if schluss is None:
return self._meldung("/erinnerung", "Bitte ein Redaktionsschluss-Datum angeben.", "fehler")
ausgabe = Ausgabe(name=roh_name, redaktionsschluss=schluss)
db.add(ausgabe)
db.commit()
self._audit_sync(request, user, "erstellt", ausgabe.id, {
"name": ausgabe.name,
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
})
return self._meldung(
"/erinnerung",
f"Ausgabe „{ausgabe.name}“ angelegt (Redaktionsschluss "
f"{ausgabe.redaktionsschluss.strftime('%d.%m.%Y')}).",
)
@self.router.get("/erinnerung/ausgaben/{ausgabe_id}/bearbeiten")
def bearbeiten_formular(
request: Request,
ausgabe_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_roles(Role.ADMIN.value)),
):
ausgabe = db.get(Ausgabe, ausgabe_id)
if ausgabe is None:
return self._meldung("/erinnerung", "Ausgabe nicht gefunden.", "fehler")
return self.context.templates.TemplateResponse(
request=request,
name="erinnerung/bearbeiten.html",
context={
"user": user,
"titel_headline": f"Ausgabe bearbeiten — {ausgabe.name}",
"ausgabe": ausgabe,
},
)
@self.router.post("/erinnerung/ausgaben/{ausgabe_id}/bearbeiten")
def bearbeiten_speichern(
request: Request,
ausgabe_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_roles(Role.ADMIN.value)),
name: str = Form(""),
redaktionsschluss: str = Form(""),
):
self._merke_registry(request.app.state.registry)
ausgabe = db.get(Ausgabe, ausgabe_id)
if ausgabe is None:
return self._meldung("/erinnerung", "Ausgabe nicht gefunden.", "fehler")
roh_name = name.strip()
if not roh_name:
return self._meldung("/erinnerung", "Bitte einen Ausgaben-Namen angeben.", "fehler")
try:
schluss = parse_datum(redaktionsschluss)
except ValueError:
return self._meldung(
"/erinnerung",
"Ungültiges Datum — bitte als JJJJ-MM-TT angeben.",
"fehler",
)
if schluss is None:
return self._meldung("/erinnerung", "Bitte ein Redaktionsschluss-Datum angeben.", "fehler")
alt = {
"name": ausgabe.name,
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
}
ausgabe.name = roh_name
ausgabe.redaktionsschluss = schluss
db.commit()
neu = {
"name": ausgabe.name,
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
}
geaendert = [k for k in alt if alt[k] != neu[k]]
if geaendert:
self._audit_sync(request, user, "geaendert", ausgabe.id, {
"alt": {k: alt[k] for k in geaendert},
"neu": {k: neu[k] for k in geaendert},
})
return self._meldung("/erinnerung", f"Ausgabe „{ausgabe.name}“ gespeichert.")
@self.router.post("/erinnerung/ausgaben/{ausgabe_id}/loeschen")
def ausgabe_loeschen(
request: Request,
ausgabe_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_roles(Role.ADMIN.value)),
):
self._merke_registry(request.app.state.registry)
ausgabe = db.get(Ausgabe, ausgabe_id)
if ausgabe is None:
return self._meldung("/erinnerung", "Ausgabe nicht gefunden.", "fehler")
snapshot = {
"name": ausgabe.name,
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
}
# Protokollzeilen dieser Ausgabe mitlöschen (FK ohne Cascade).
db.execute(
delete(ErinnerungsProtokoll).where(
ErinnerungsProtokoll.ausgabe_id == ausgabe_id
)
)
db.delete(ausgabe)
db.commit()
self._audit_sync(request, user, "geloescht", ausgabe_id, snapshot)
return self._meldung("/erinnerung", f"Ausgabe „{snapshot['name']}“ gelöscht.")
@self.router.post("/erinnerung/pruefen")
async def jetzt_pruefen(
request: Request,
user: User = Depends(require_roles(Role.ADMIN.value)),
):
"""Manueller Durchlauf (gleiche Logik wie der tägliche Job)."""
self._merke_registry(request.app.state.registry)
bericht = await self.pruefe_und_erinnere()
return self._meldung("/erinnerung", f"Prüfung abgeschlossen: {bericht.als_text()}")
# ---------- Audit (synchrone Routen → log_sync des audit-log-Plugins) ----------
def _audit_sync(self, request: Request, actor, action: str, objekt_id, details: dict) -> None:
audit = request.app.state.registry.get("audit-log")
if audit is None:
return
try:
audit.log_sync(actor, action, "ausgabe", objekt_id, details)
except Exception:
_logger.exception("Audit-Log-Eintrag für Ausgabe fehlgeschlagen.")
plugin = ErinnerungPlugin()

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

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}>"

View File

@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">Ausgabe bearbeiten</h1>
<p class="text-slate-600 mb-4">&bdquo;{{ ausgabe.name }}&ldquo; &middot; Redaktionsschluss:
{{ ausgabe.redaktionsschluss.strftime('%d.%m.%Y') }}
</p>
<form method="post" action="/erinnerung/ausgaben/{{ ausgabe.id }}/bearbeiten"
class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4">
<div>
<label for="name" class="block text-xs font-medium text-slate-500 mb-1">Name/Titel der Ausgabe *</label>
<input type="text" id="name" name="name" required value="{{ ausgabe.name }}"
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label for="redaktionsschluss" class="block text-xs font-medium text-slate-500 mb-1">Redaktionsschluss *</label>
<input type="date" id="redaktionsschluss" name="redaktionsschluss" required value="{{ ausgabe.redaktionsschluss.isoformat() }}"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-48 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div class="flex items-center gap-3">
<button type="submit"
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-lg text-sm">
Speichern
</button>
<a href="/erinnerung" class="text-sm text-slate-500 hover:text-slate-800 underline">Abbrechen</a>
</div>
</form>
<p class="text-xs text-slate-400 mt-3 max-w-2xl">
Hinweis: Bereits protokollierte Erinnerungen bleiben bestehen &mdash; pro Ausgabe
und Benutzer wird nur einmal erinnert.
</p>
{% endblock %}

View File

@@ -2,9 +2,143 @@
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-2xl">
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
in Version {{ version }} ist geladen.
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
<p class="text-slate-600 mb-4">
Redaktionsschl&uuml;sse der kommenden Ausgaben. Vier Wochen vor jedem
Redaktionsschluss erhalten alle Rezensenten eine Erinnerung, die noch offene
Planungseinträge haben &mdash; oder noch gar keine eingetragen haben.
</p>
{% if meldung %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div>
{% endif %}
{% if fehler %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div>
{% endif %}
{% if ist_admin %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<h2 class="font-semibold mb-3">Neue Ausgabe anlegen</h2>
<form method="post" action="/erinnerung/ausgaben" class="flex flex-wrap items-end gap-3">
<div>
<label for="name" class="block text-xs font-medium text-slate-500 mb-1">Name/Titel der Ausgabe *</label>
<input type="text" id="name" name="name" required placeholder="z. B. 3/2025"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label for="redaktionsschluss" class="block text-xs font-medium text-slate-500 mb-1">Redaktionsschluss *</label>
<input type="date" id="redaktionsschluss" name="redaktionsschluss" required
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-48 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<button type="submit"
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
Ausgabe anlegen
</button>
</form>
<p class="text-xs text-slate-400 mt-2">Mehrere Ausgaben k&ouml;nnen parallel laufen.</p>
</div>
{% endif %}
<div class="overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm mb-6">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Ausgabe</th>
<th class="px-4 py-3 font-medium">Redaktionsschluss</th>
<th class="px-4 py-3 font-medium">Restzeit</th>
<th class="px-4 py-3 font-medium">Erinnerungen</th>
{% if ist_admin %}<th class="px-4 py-3 font-medium">Aktionen</th>{% endif %}
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for zeile in zeilen %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-medium">{{ zeile.ausgabe.name }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ zeile.ausgabe.redaktionsschluss.strftime('%d.%m.%Y') }}</td>
<td class="px-4 py-3 whitespace-nowrap">
{% if zeile.tage_rest > fenster_tage %}
<span class="inline-block px-2 py-0.5 rounded-full bg-slate-100 text-slate-600 text-xs">
noch {{ zeile.tage_rest }} Tage &middot; Erinnerung in {{ zeile.tage_rest - fenster_tage }} Tagen
</span>
{% elif zeile.im_fenster %}
<span class="inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs">
Erinnerungsphase &mdash; {{ zeile.tage_rest }} Tag{{ 'e' if zeile.tage_rest != 1 }} bis zum Redaktionsschluss
</span>
{% else %}
<span class="inline-block px-2 py-0.5 rounded-full bg-slate-200 text-slate-500 text-xs">Redaktionsschluss vorbei</span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-slate-600">
{{ zeile.erinnert_anzahl }} erinnert
</td>
{% if ist_admin %}
<td class="px-4 py-3">
<div class="flex flex-wrap items-center gap-1.5">
<a href="/erinnerung/ausgaben/{{ zeile.ausgabe.id }}/bearbeiten"
class="text-xs border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-2 py-1 rounded-lg whitespace-nowrap">Bearbeiten</a>
<form method="post" action="/erinnerung/ausgaben/{{ zeile.ausgabe.id }}/loeschen">
<button type="submit" class="text-xs border border-red-200 text-red-600 hover:bg-red-50 px-2 py-1 rounded-lg whitespace-nowrap">L&ouml;schen</button>
</form>
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr>
<td colspan="{{ 5 if ist_admin else 4 }}" class="px-4 py-8 text-center text-slate-400">
Noch keine Ausgaben angelegt.
{% if ist_admin %}Lege oben die erste Ausgabe mit ihrem Redaktionsschluss an.{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if ist_admin %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6 flex flex-wrap items-center gap-3">
<div class="mr-auto">
<h2 class="font-semibold">Sofort pr&uuml;fen</h2>
<p class="text-xs text-slate-500">F&uuml;hrt denselben Check aus wie der t&auml;gliche Hintergrund-Job ({{ fenster_tage }} Tage vor dem Redaktionsschluss). Doppelerinnerungen sind ausgeschlossen.</p>
</div>
<form method="post" action="/erinnerung/pruefen">
<button type="submit"
class="bg-sky-600 hover:bg-sky-700 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
Jetzt pr&uuml;fen und erinnern
</button>
</form>
</div>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<h2 class="font-semibold mb-3">Erinnerungsstatus</h2>
<p class="text-xs text-slate-500 mb-3">Wer wurde f&uuml;r welche Ausgabe wann erinnert? Pro Ausgabe und Benutzer wird h&ouml;chstens einmal erinnert.</p>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Ausgabe</th>
<th class="px-4 py-3 font-medium">Rezensent</th>
<th class="px-4 py-3 font-medium">Kan&auml;le</th>
<th class="px-4 py-3 font-medium">Erinnert am</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for eintrag in protokoll %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-medium">{{ eintrag.ausgabe_name }}</td>
<td class="px-4 py-3">{{ eintrag.benutzer }}</td>
<td class="px-4 py-3 text-slate-600">
{% if eintrag.eintrag.kanaele %}{{ eintrag.eintrag.kanaele.replace(',', ', ') }}{% else %}&mdash;{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-slate-500">
{{ eintrag.eintrag.erstellt_am.strftime('%d.%m.%Y %H:%M') if eintrag.eintrag.erstellt_am else '&mdash;' }}
</td>
</tr>
{% else %}
<tr><td colspan="4" class="px-4 py-8 text-center text-slate-400">Noch keine Erinnerungen versendet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock %}