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

@@ -4,7 +4,8 @@
modularer Plugin-Architektur. Lauffähiger Kern mit Plugin-System, modularer Plugin-Architektur. Lauffähiger Kern mit Plugin-System,
Authentifizierung/Rollen und Migrationen; vollständig implementiert sind Authentifizierung/Rollen und Migrationen; vollständig implementiert sind
bisher **Audit-Log**, **Benachrichtigung**, **Neuheiten (BGG-Sync)**, bisher **Audit-Log**, **Benachrichtigung**, **Neuheiten (BGG-Sync)**,
**Dedup-Prüfung**, **Planungsliste** und **Archiv (12-Monats-Autopilot)**. **Dedup-Prüfung**, **Planungsliste**, **Archiv (12-Monats-Autopilot)** und
**Erinnerungen**.
## Stack ## Stack
@@ -56,6 +57,8 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt:
| `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG-Zusatzdaten für die Dedup-Prüfung an (`1`) oder aus (`0`): Alternate-Names und Erweiterungs-Relationen | | `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG-Zusatzdaten für die Dedup-Prüfung an (`1`) oder aus (`0`): Alternate-Names und Erweiterungs-Relationen |
| `SPIELE_ARCHIV_JOB_AKTIV` | `1` | Täglicher Archivierungs-Job an (`1`) oder aus (`0`) | | `SPIELE_ARCHIV_JOB_AKTIV` | `1` | Täglicher Archivierungs-Job an (`1`) oder aus (`0`) |
| `SPIELE_ARCHIV_JOB_UHRZEIT` | `03:00` | Tageszeit des täglichen Archiv-Laufs im Format `HH:MM` | | `SPIELE_ARCHIV_JOB_UHRZEIT` | `03:00` | Tageszeit des täglichen Archiv-Laufs im Format `HH:MM` |
| `SPIELE_ERINNERUNG_JOB_AKTIV` | `1` | Täglicher Erinnerungs-Check an (`1`) oder aus (`0`) |
| `SPIELE_ERINNERUNG_JOB_UHRZEIT` | `08:00` | Uhrzeit (HH:MM) des täglichen Erinnerungs-Checks |
## Tests ## Tests
@@ -83,6 +86,13 @@ Zeitzonen-Unterschieden), Job-Lauf mit Audit-Einträgen, Wiederherstellen
inkl. BGG-Konflikt und Ersatz-Rezensent, Rollen sowie Suche/Filter in der inkl. BGG-Konflikt und Ersatz-Rezensent, Rollen sowie Suche/Filter in der
Admin-Ansicht. Admin-Ansicht.
Das Plugin **erinnerung** ist abgedeckt mit: Ausgaben-CRUD (nur Admin, mit
Audit), Erinnerungslogik mit eingefrorener Uhr (freezegun) — 4-Wochen-Grenze
(28 Tage exakt), kein Versand vor der Frist und nach dem Redaktionsschluss,
Empfängerregeln (offene oder fehlende Planung, Rollen-Filter), Doppelschutz
pro Ausgabe+Benutzer, manuelle Prüfung, Scheduler-Lifecycle/Uhrzeit-Konfiguration
— wiederum plus ein Integrationstest mit den echten Plugins.
## Architektur ## Architektur
``` ```
@@ -102,7 +112,8 @@ plugins/ ein Ordner pro Funktion, ladbar über den Plugin-Load
├── dedup/ VOLL IMPLEMENTIERT: check_titel-API, Heuristik, Protokoll ├── dedup/ VOLL IMPLEMENTIERT: check_titel-API, Heuristik, Protokoll
├── planung/ VOLL IMPLEMENTIERT: Planungsliste, Verschiebung, Dialoge ├── planung/ VOLL IMPLEMENTIERT: Planungsliste, Verschiebung, Dialoge
├── archiv/ VOLL IMPLEMENTIERT: 12-Monats-Autopilot, Job, UI ├── archiv/ VOLL IMPLEMENTIERT: 12-Monats-Autopilot, Job, UI
├── erinnerung/ export/ (Stubs, ladbar) ├── erinnerung/ VOLL IMPLEMENTIERT: Ausgaben, 4-Wochen-Erinnerung, Tages-Job
├── export/ (Stub, ladbar)
└── … je __init__.py + templates/<name>/ └── … je __init__.py + templates/<name>/
``` ```
@@ -430,6 +441,57 @@ vereinigte Liste angezeigt (neueste Archivierung zuerst):
nicht wiederhergestellt; existiert der zugeordnete Rezensent nicht mehr, nicht wiederhergestellt; existiert der zugeordnete Rezensent nicht mehr,
übernimmt der wiederherstellende Admin die Zuordnung (gemeldet und übernimmt der wiederherstellende Admin die Zuordnung (gemeldet und
auditiert). Rezensenten und Redakteure erhalten 403. auditiert). Rezensenten und Redakteure erhalten 403.
## Plugin „erinnerung“ (implementiert)
Redaktionsschluss pro Ausgabe mit automatischer 4-Wochen-Erinnerung an alle
Rezensenten — mehrere Ausgaben laufen parallel.
### Datentabellen
Eigene Migration `0001_ausgaben_und_protokoll`, zwei Tabellen
(SQLite ↔ Postgres portabel):
| Tabelle | Spalten | Zweck |
|---------|---------|-------|
| `erinnerung_ausgabe` | `name` (z. B. „3/2025“), `redaktionsschluss` (Datum), Zeitstempel | Eine Magazin-Ausgabe; Anlegen/Bearbeiten/Löschen nur für Rolle **Admin** |
| `erinnerung_protokoll` | `ausgabe_id`, `user_id`, `kanaele`, `erstellt_am` | Jede versendete Erinnerung; **eindeutig** über (Ausgabe, Benutzer) → keine Doppelerinnerung |
### Erinnerungslogik
- **Fenster:** 28 Tage (4 Wochen) vor dem Redaktionsschluss bis zum
Redaktionsschluss selbst (jeweils inklusive). Davor und danach wird nicht
erinnert.
- **Empfänger:** alle aktiven Rezensenten, 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.
- **Versand:** über `send_notification` des benachrichtigung-Plugins
(Kategorie `erinnerung`) — die Kanäle richten sich nach der Präferenz des
Benutzers. Ohne Partner-Plugin wird kein Protokoll geschrieben, damit der
nächste Lauf es erneut versucht.
- **Doppelschutz:** pro (Ausgabe, Benutzer) wird genau einmal erinnert; der
Versand steht in `erinnerung_protokoll`. Auch ein späteres Bearbeiten des
Redaktionsschlusses löst keine zweite Erinnerung aus.
- **Audit:** jede Erinnerung als `System`/`benachrichtigt` (Objekttyp
`erinnerung`), jede Ausgaben-Änderung als `erstellt`/`geaendert`/
`geloescht` (Objekttyp `ausgabe`).
### Geplanter Job & manuelle Prüfung
APScheduler prüft **täglich** (Standard 08:00 Uhr, konfigurierbar über
`SPIELE_ERINNERUNG_JOB_UHRZEIT`, abschaltbar über
`SPIELE_ERINNERUNG_JOB_AKTIV=0`). Unter **Erinnerungen → „Jetzt prüfen und
erinnern“** führt ein Admin denselben Check sofort aus; das Ergebnis
erscheint als Banner. Die Uhr liegt zentral in `dienst.heute()` — Tests
frieren sie mit freezegun ein.
### UI
Unter **Erinnerungen** (`/erinnerung`) sehen alle angemeldeten Benutzer die
Ausgaben mit Redaktionsschluss und Restzeit-Badge. Admins verwalten zusätzlich
die Ausgaben (anlegen/bearbeiten/löschen), starten den Sofort-Check und
sehen den **Erinnerungsstatus**: wer wurde für welche Ausgabe wann über
welche Kanäle erinnert.
## Deployment (später) ## Deployment (später)
@@ -449,8 +511,8 @@ geschrieben.
| 4 | Plugin `neuheiten` (BGG-Sync, APScheduler, Filter) | ✅ fertig | | 4 | Plugin `neuheiten` (BGG-Sync, APScheduler, Filter) | ✅ fertig |
| 5 | Plugin `dedup` (Verlags-Konflikt, deutsche Version, Vorgänger-/Planungs-Check) | ✅ fertig | | 5 | Plugin `dedup` (Verlags-Konflikt, deutsche Version, Vorgänger-/Planungs-Check) | ✅ fertig |
| 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ✅ fertig | | 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ✅ fertig |
| 7 | Plugin `archiv` (12-Monats-Autopilot, Wiederherstellen, täglicher Job) | ✅ fertig (171 Tests grün) | | 7 | Plugin `archiv` (12-Monats-Autopilot, Wiederherstellen, täglicher Job) | ✅ fertig |
| 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung) | ⏳ offen | | 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung, Tages-Job) | ✅ fertig |
| 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ⏳ offen | | 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ⏳ offen |
| 10 | Integrationstests über alle Plugins | ⏳ offen | | 10 | Integrationstests über alle Plugins | ⏳ offen |
| 11 | Deployment auf swen.henry.insight-it.de (Compose + Traefik, Live-Check) | ⏳ offen | | 11 | Deployment auf swen.henry.insight-it.de (Compose + Traefik, Live-Check) | ⏳ offen |
@@ -460,6 +522,5 @@ Legende: ✅ fertig · 🔄 in Arbeit · ⏳ offen · ⚠️ fertig mit offenen
## Offene Punkte (nicht Teil von Phase 1) ## Offene Punkte (nicht Teil von Phase 1)
- CSRF-Schutz für Formulare (aktuell SameSite=Lax-Cookie als Basisschutz) - CSRF-Schutz für Formulare (aktuell SameSite=Lax-Cookie als Basisschutz)
- Hintergrund-Jobs (APScheduler) für Erinnerungen
- WeasyPrint für PDF-Export (Systemabhängigkeiten im Container) - WeasyPrint für PDF-Export (Systemabhängigkeiten im Container)
- Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets - Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets

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: Umfang:
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle. - **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 __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 fastapi import Depends, Form, Request
from redaktionskern.auth.models import User from fastapi.responses import RedirectResponse
from redaktionskern.contracts import BasePlugin, NavEntry 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): class ErinnerungPlugin(BasePlugin):
name = "erinnerung" name = "erinnerung"
title = "Erinnerungen" 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: def __init__(self) -> None:
super().__init__() 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") @self.router.get("/erinnerung")
def seite(request: Request, user: User = Depends(require_user)): def seite(
"""Platzhalterseite des Plugins.""" 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( return self.context.templates.TemplateResponse(
request=request, request=request,
name="erinnerung/index.html", name="erinnerung/index.html",
context={ context={
"user": user, "user": user,
"titel": self.title, "titel": self.title,
"name": self.name, "zeilen": zeilen,
"version": self.version, "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]: @self.router.post("/erinnerung/ausgaben")
return [NavEntry(label=self.title, url="/erinnerung")] 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() 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 titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1> <h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-2xl"> <p class="text-slate-600 mb-4">
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code> Redaktionsschl&uuml;sse der kommenden Ausgaben. Vier Wochen vor jedem
in Version {{ version }} ist geladen. Redaktionsschluss erhalten alle Rezensenten eine Erinnerung, die noch offene
Diese Seite ist ein Platzhalter &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert. Planungseinträge haben &mdash; oder noch gar keine eingetragen haben.
</p> </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 %} {% endblock %}

View File

@@ -21,6 +21,7 @@ dependencies = [
dev = [ dev = [
"pytest>=8.0", "pytest>=8.0",
"httpx>=0.27", "httpx>=0.27",
"freezegun>=1.5.5",
] ]
[build-system] [build-system]

View File

@@ -22,8 +22,9 @@ def _hintergrundjobs_deaktiviert(monkeypatch):
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "1"). monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "1").
""" """
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "0") monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "0")
# Auch der tägliche Archiv-Job bleibt in Tests aus. # Auch die täglichen Hintergrund-Jobs bleiben in Tests aus.
monkeypatch.setenv("SPIELE_ARCHIV_JOB_AKTIV", "0") monkeypatch.setenv("SPIELE_ARCHIV_JOB_AKTIV", "0")
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_AKTIV", "0")
# Auch der BGG-Hilfsclient des dedup-Plugins bleibt in Tests offline. # Auch der BGG-Hilfsclient des dedup-Plugins bleibt in Tests offline.
monkeypatch.setenv("SPIELE_DEDUP_BGG_AKTIV", "0") monkeypatch.setenv("SPIELE_DEDUP_BGG_AKTIV", "0")

550
tests/test_erinnerung.py Normal file
View File

@@ -0,0 +1,550 @@
"""Tests: Plugin „erinnerung“ — Fristen, Empfänger, Doppelschutz, Rollen.
Die Uhr wird mit freezegun eingefroren; alle datumsabhängigen Prüfungen
laufen damit deterministisch. benachrichtigung und audit-log werden durch
aufzeichnende Stubs ersetzt (wie in test_planung.py); ein Test beweist
zusätzlich die Integration mit den echten Plugins.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass, field
from datetime import date
from types import SimpleNamespace
import pytest
from freezegun import freeze_time
from fastapi.testclient import TestClient
from sqlalchemy import select, text
from redaktionskern.app import create_app
from redaktionskern.auth.models import User
from tests.conftest import lege_benutzer_an, melde_an
# ---------------- Hilfen ----------------
def plugin_modul(app, name: str):
plugin_instanz = app.state.registry.get(name)
return sys.modules[type(plugin_instanz).__module__]
def ausgabe_anlegen(app, name: str, redaktionsschluss: date) -> int:
Ausgabe = plugin_modul(app, "erinnerung").Ausgabe
with app.state.session_factory() as db:
ausgabe = Ausgabe(name=name, redaktionsschluss=redaktionsschluss)
db.add(ausgabe)
db.commit()
return ausgabe.id
def protokoll_zeilen(app) -> set[tuple[int, int]]:
ErinnerungsProtokoll = plugin_modul(app, "erinnerung").ErinnerungsProtokoll
with app.state.session_factory() as db:
return {
(z.ausgabe_id, z.user_id)
for z in db.scalars(select(ErinnerungsProtokoll)).all()
}
def planung_anlegen(app, titel: str, rezensent_id: int, status: str = "offen") -> int:
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
eintrag = Planungseintrag(
titel=titel, rezensent_id=rezensent_id, status=status, ausgabe=""
)
db.add(eintrag)
db.commit()
return eintrag.id
def benutzer_objekt(app, username: str):
with app.state.session_factory() as db:
return db.scalar(select(User).where(User.username == username))
def erinnerungs_plugin(app):
plugin = app.state.registry.get("erinnerung")
plugin._merke_registry(app.state.registry) # Partner sauber auflösen
return plugin
def sichtbar(antwort) -> str:
import html
return html.unescape(antwort.text)
# ---------------- Stubs (gemockte Abhängigkeiten) ----------------
@dataclass
class Aufgezeichnet:
daten: dict = field(default_factory=dict)
class StubBenachrichtigung:
name = "benachrichtigung"
def __init__(self):
self.gesendet: list[Aufgezeichnet] = []
async def send_notification(self, user, titel, text, kategorie="allgemein"):
self.gesendet.append(Aufgezeichnet({
"user_id": user.id, "titel": titel, "text": text, "kategorie": kategorie,
}))
return SimpleNamespace(zugestellt=["inapp"], fehlgeschlagen=[])
class StubAudit:
name = "audit-log"
def __init__(self):
self.eintraege: list[Aufgezeichnet] = []
async def log(self, actor, action, objekt_typ="", objekt_id=None,
details=None, **kwargs):
self.eintraege.append(Aufgezeichnet({
"actor": actor, "action": action, "objekt_typ": objekt_typ,
"objekt_id": objekt_id, "details": details,
}))
def log_sync(self, *args, **kwargs):
import asyncio
asyncio.run(self.log(*args, **kwargs))
@pytest.fixture
def stubs(app):
"""Ersetzt benachrichtigung + audit-log im Registry-Zustand der App."""
benachrichtigung = StubBenachrichtigung()
audit = StubAudit()
registry_inhalt = app.state.registry._plugins
registry_inhalt["benachrichtigung"] = benachrichtigung
registry_inhalt["audit-log"] = audit
erinnerungs_plugin(app) # Registry-Referenz auffrischen
return SimpleNamespace(benachrichtigung=benachrichtigung, audit=audit)
# ---------------- Migration & Zugriff ----------------
def test_migration_legt_beide_tabellen_an(app):
from sqlalchemy import inspect
inspektion = inspect(app.state.engine)
ausgaben_spalten = {c["name"] for c in inspektion.get_columns("erinnerung_ausgabe")}
protokoll_spalten = {c["name"] for c in inspektion.get_columns("erinnerung_protokoll")}
assert {"id", "name", "redaktionsschluss", "erstellt_am", "aktualisiert_am"} <= ausgaben_spalten
assert {"id", "ausgabe_id", "user_id", "kanaele", "erstellt_am"} <= protokoll_spalten
with app.state.engine.connect() as conn:
versionen = [
z[0] for z in conn.execute(
text("SELECT version FROM schema_migrations WHERE plugin='erinnerung'")
).fetchall()
]
assert versionen == ["0001_ausgaben_und_protokoll"]
def test_anonym_wird_zum_login_umgeleitet(client):
antwort = client.get("/erinnerung", follow_redirects=False)
assert antwort.status_code == 303
assert antwort.headers["location"].startswith("/login")
# ---------------- Rollen & UI ----------------
def test_rezensent_sieht_seite_ohne_verwaltung(app, client):
lege_benutzer_an(app, "rez1", "rezensent")
melde_an(client, "rez1", "test-12345678")
antwort = client.get("/erinnerung")
assert antwort.status_code == 200
inhalt = sichtbar(antwort)
assert "Redaktionsschluss" in inhalt
assert "Neue Ausgabe anlegen" not in inhalt # keine Admin-Formulare
assert "Jetzt prüfen und erinnern" not in inhalt
def test_admin_sieht_verwaltung_und_status(client):
melde_an(client)
inhalt = sichtbar(client.get("/erinnerung"))
assert "Neue Ausgabe anlegen" in inhalt
assert "Erinnerungsstatus" in inhalt
def test_nur_admin_darf_ausgaben_verwalten(app, client):
"""Anlegen/Bearbeiten/Löschen/Prüfen: Admin ja, Redakteur und Rezensent 403."""
for rolle in ("redakteur", "rezensent"):
lege_benutzer_an(app, f"user-{rolle}", rolle)
melde_an(client, f"user-{rolle}", "test-12345678")
for methode, url, daten in (
("post", "/erinnerung/ausgaben", {"name": "X", "redaktionsschluss": "2030-01-01"}),
("get", "/erinnerung/ausgaben/1/bearbeiten", None),
("post", "/erinnerung/ausgaben/1/bearbeiten",
{"name": "Y", "redaktionsschluss": "2030-01-01"}),
("post", "/erinnerung/ausgaben/1/loeschen", {}),
("post", "/erinnerung/pruefen", {}),
):
if methode == "get":
antwort = client.get(url)
else:
antwort = client.post(url, data=daten)
assert antwort.status_code == 403, f"{rolle} auf {url} muss 403 geben"
client.post("/logout")
# ---------------- Ausgaben-CRUD (Admin) ----------------
def test_admin_legt_ausgabe_an_mit_audit(client, app, stubs):
melde_an(client)
antwort = client.post(
"/erinnerung/ausgaben",
data={"name": "3/2025", "redaktionsschluss": "2030-06-15"},
follow_redirects=True,
)
assert antwort.status_code == 200
assert "3/2025" in sichtbar(antwort)
Ausgabe = plugin_modul(app, "erinnerung").Ausgabe
with app.state.session_factory() as db:
ausgabe = db.scalar(select(Ausgabe))
assert ausgabe.name == "3/2025"
assert ausgabe.redaktionsschluss == date(2030, 6, 15)
assert [e.daten["action"] for e in stubs.audit.eintraege] == ["erstellt"]
eintrag = stubs.audit.eintraege[0].daten
assert eintrag["objekt_typ"] == "ausgabe"
assert eintrag["details"]["name"] == "3/2025"
def test_mehrere_ausgaben_parallel(app, client):
melde_an(client)
for name, schluss in (("3/2025", "2030-06-15"), ("4/2025", "2030-09-01")):
client.post("/erinnerung/ausgaben", data={"name": name, "redaktionsschluss": schluss})
inhalt = sichtbar(client.get("/erinnerung"))
assert "3/2025" in inhalt and "4/2025" in inhalt
def test_ausgabe_ohne_name_wird_abgewiesen(client, app):
melde_an(client)
antwort = client.post(
"/erinnerung/ausgaben",
data={"name": "", "redaktionsschluss": "2030-06-15"},
follow_redirects=True,
)
assert "Bitte einen Ausgaben-Namen angeben" in antwort.text
def test_ausgabe_mit_kaputtem_datum_wird_abgewiesen(client, app):
melde_an(client)
antwort = client.post(
"/erinnerung/ausgaben",
data={"name": "X", "redaktionsschluss": "15.06.2030"},
follow_redirects=True,
)
assert "Ungültiges Datum" in antwort.text
def test_admin_bearbeitet_ausgabe_mit_audit(client, app, stubs):
melde_an(client)
ausgabe_id = ausgabe_anlegen(app, "Alt", date(2030, 6, 15))
antwort = client.post(
f"/erinnerung/ausgaben/{ausgabe_id}/bearbeiten",
data={"name": "Neu", "redaktionsschluss": "2030-07-01"},
follow_redirects=True,
)
assert "gespeichert" in antwort.text
Ausgabe = plugin_modul(app, "erinnerung").Ausgabe
with app.state.session_factory() as db:
ausgabe = db.get(Ausgabe, ausgabe_id)
assert ausgabe.name == "Neu"
assert ausgabe.redaktionsschluss == date(2030, 7, 1)
aktionen = [e.daten["action"] for e in stubs.audit.eintraege]
assert aktionen == ["geaendert"] # Anlegen geschah direkt per Helper
geaendert = stubs.audit.eintraege[-1].daten
assert geaendert["details"]["alt"]["name"] == "Alt"
assert geaendert["details"]["neu"]["name"] == "Neu"
def test_admin_loescht_ausgabe_mit_audit(client, app, stubs):
melde_an(client)
ausgabe_id = ausgabe_anlegen(app, "Weg damit", date(2030, 6, 15))
antwort = client.post(f"/erinnerung/ausgaben/{ausgabe_id}/loeschen", follow_redirects=True)
assert "gelöscht" in antwort.text
Ausgabe = plugin_modul(app, "erinnerung").Ausgabe
with app.state.session_factory() as db:
assert db.get(Ausgabe, ausgabe_id) is None
assert [e.daten["action"] for e in stubs.audit.eintraege] == ["geloescht"]
# ---------------- Erinnerungslogik mit eingefrorener Uhr ----------------
def _drei_beteiligte(app):
"""Rezensent mit offenem Eintrag, Rezensent ohne Einträge; Admin existiert."""
lege_benutzer_an(app, "rez-offen", "rezensent")
lege_benutzer_an(app, "rez-leer", "rezensent")
rez_offen = benutzer_objekt(app, "rez-offen")
planung_anlegen(app, "Offenes Spiel", rez_offen.id, status="offen")
return rez_offen, benutzer_objekt(app, "rez-leer")
@freeze_time("2030-05-18") # genau 28 Tage vor dem 15.06.2030
def test_erinnerung_genau_beim_fensterstart(client, app, stubs):
rez_offen, rez_leer = _drei_beteiligte(app)
ausgabe_id = ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
melde_an(client)
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
assert bericht.gepruefte_ausgaben == 1
assert {v["benutzer"] for v in bericht.versendet} == {"rez-offen", "rez-leer"}
assert all(v["tage_rest"] == 28 for v in bericht.versendet)
assert protokoll_zeilen(app) == {(ausgabe_id, rez_offen.id), (ausgabe_id, rez_leer.id)}
# Benachrichtigungen über das Partner-Plugin, Kategorie „erinnerung“:
assert {g.daten["user_id"] for g in stubs.benachrichtigung.gesendet} == {
rez_offen.id, rez_leer.id,
}
assert all(g.daten["kategorie"] == "erinnerung" for g in stubs.benachrichtigung.gesendet)
assert "Redaktionsschluss: 3/2025" == stubs.benachrichtigung.gesendet[0].daten["titel"]
# Audit: jede Erinnerung als System-Ereignis protokolliert:
aktionen = [e.daten["action"] for e in stubs.audit.eintraege]
assert sorted(aktionen) == ["benachrichtigt", "benachrichtigt"]
assert all(e.daten["actor"] is None for e in stubs.audit.eintraege)
@freeze_time("2030-05-17") # 29 Tage vorher — Fenster noch zu
def test_kein_versand_vor_der_vier_wochen_frist(client, app, stubs):
_drei_beteiligte(app)
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
melde_an(client)
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
assert bericht.gepruefte_ausgaben == 0
assert bericht.versendet == []
assert stubs.benachrichtigung.gesendet == []
assert protokoll_zeilen(app) == set()
assert stubs.audit.eintraege == []
@freeze_time("2030-06-16") # einen Tag nach dem Redaktionsschluss
def test_kein_versand_nach_dem_redaktionsschluss(client, app, stubs):
_drei_beteiligte(app)
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
melde_an(client)
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
assert bericht.gepruefte_ausgaben == 0
assert stubs.benachrichtigung.gesendet == []
@freeze_time("2030-05-25") # 21 Tage Rest → im Fenster
def test_rezensent_mit_nur_abgeschlossenen_eintraegen_wird_nicht_erinnert(client, app, stubs):
for name, status in (
("rez-fertig", "abgeschlossen"),
("x-in_arbeit", "in_bearbeitung"),
("x-fertig", "abgeschlossen"),
):
lege_benutzer_an(app, name, "rezensent")
planung_anlegen(app, f"Spiel von {name}",
benutzer_objekt(app, name).id, status=status)
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15)) # 21 Tage Rest → im Fenster
melde_an(client)
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
# „offen“ heißt: nicht abgeschlossen (offen oder in Bearbeitung).
erinnerte = {v["benutzer"] for v in bericht.versendet}
assert erinnerte == {"x-in_arbeit"}
@freeze_time("2030-05-20")
def test_keine_doppelerinnerung_pro_ausgabe_und_benutzer(client, app, stubs):
_drei_beteiligte(app)
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
melde_an(client)
plugin = erinnerungs_plugin(app)
erster = plugin.pruefe_und_erinnere_sync()
zweiter = plugin.pruefe_und_erinnere_sync()
dritter = plugin.pruefe_und_erinnere_sync(jetzt=date(2030, 6, 14)) # später, noch im Fenster
assert erster.anzahl_versendet > 0
assert zweiter.anzahl_versendet == 0
assert zweiter.uebersprungen == erster.anzahl_versendet
assert dritter.anzahl_versendet == 0
assert len(stubs.benachrichtigung.gesendet) == erster.anzahl_versendet
assert len(protokoll_zeilen(app)) == erster.anzahl_versendet
@freeze_time("2030-05-20")
def test_zweite_ausgabe_wird_unabhaengig_erinnert(client, app, stubs):
"""Doppelschutz gilt pro Ausgabe — eine zweite Ausgabe erinnert erneut."""
_drei_beteiligte(app)
erste = ausgabe_anlegen(app, "3/2025", date(2030, 6, 10)) # im Fenster
zweite = ausgabe_anlegen(app, "4/2025", date(2030, 6, 12)) # auch im Fenster
melde_an(client)
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
assert bericht.gepruefte_ausgaben == 2
assert {v["ausgabe"] for v in bericht.versendet} == {"3/2025", "4/2025"}
ids = protokoll_zeilen(app)
rez_offen = benutzer_objekt(app, "rez-offen")
assert (erste, rez_offen.id) in ids
assert (zweite, rez_offen.id) in ids
@freeze_time("2030-05-20")
def test_rollenfilter_admin_und_redakteur_werden_nie_erinnert(client, app, stubs):
lege_benutzer_an(app, "chef-redakteur", "redakteur")
redakteur = benutzer_objekt(app, "chef-redakteur")
# Redakteur mit offenen Planungseinträgen bleibt trotzdem außen vor:
planung_anlegen(app, "Vom Redakteur übernommen", redakteur.id, status="offen")
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
melde_an(client)
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
erinnerte = {v["benutzer"] for v in bericht.versendet}
assert "chef-redakteur" not in erinnerte
assert "admin" not in erinnerte
@freeze_time("2030-05-20")
def test_inaktiver_rezensent_wird_nicht_erinnert(app, client, stubs):
lege_benutzer_an(app, "ex-rez", "rezensent")
with app.state.session_factory() as db:
ex_rez = db.scalar(select(User).where(User.username == "ex-rez"))
ex_rez.active = False
db.commit()
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
assert bericht.versendet == []
@freeze_time("2030-05-20")
def test_monkeypatched_uhr_steuert_job_pfad(client, app, stubs, monkeypatch):
"""Auch ohne `jetzt`-Argument nutzt der Durchlauf die eingefrierbare Uhr."""
dienst = plugin_modul(app, "erinnerung").dienst
monkeypatch.setattr(dienst, "heute", lambda: date(2030, 5, 20))
_drei_beteiligte(app)
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
bericht = erinnerungs_plugin(app).pruefe_und_erinnere_sync()
assert bericht.anzahl_versendet == 2
@freeze_time("2030-05-20")
def test_manuelle_pruefung_ueber_route(client, app, stubs):
_drei_beteiligte(app)
ausgabe_anlegen(app, "3/2025", date(2030, 6, 15))
melde_an(client)
antwort = client.post("/erinnerung/pruefen", follow_redirects=True)
assert "2 Erinnerung(en) versendet" in sichtbar(antwort)
@freeze_time("2030-05-20")
def test_integration_mit_echten_plugins(settings):
"""Echte Plugins statt Stubs: In-App-Nachricht + Audit-Eintrag in der DB."""
from tests.conftest import ADMIN_PASSWORD
from redaktionskern.auth.security import hash_password
with freeze_time("2030-05-20"):
app2 = create_app(settings)
with TestClient(app2):
# Rezensenten anlegen (Admin existiert bereits als Initial-Admin):
with app2.state.session_factory() as db:
db.add(User(username="rez-integration",
display_name="Rez Integration",
password_hash=hash_password(ADMIN_PASSWORD),
role="rezensent", active=True))
db.commit()
rez = None
with app2.state.session_factory() as db:
rez = db.scalar(select(User).where(User.username == "rez-integration"))
ausgabe_anlegen(app2, "3/2025", date(2030, 6, 15))
bericht = erinnerungs_plugin(app2).pruefe_und_erinnere_sync()
assert bericht.anzahl_versendet == 1
assert bericht.versendet[0]["kanaele"] == ["inapp"]
Benachrichtigung = sys.modules[
type(app2.state.registry.get("benachrichtigung")).__module__
].Benachrichtigung
AuditEintrag = sys.modules[
type(app2.state.registry.get("audit-log")).__module__
].AuditEintrag
ErinnerungsProtokoll = sys.modules[
type(app2.state.registry.get("erinnerung")).__module__
].ErinnerungsProtokoll
with app2.state.session_factory() as db:
nachricht = db.scalar(select(Benachrichtigung))
assert nachricht is not None
assert nachricht.user_id == rez.id
assert nachricht.titel == "Redaktionsschluss: 3/2025"
assert "Redaktionsschluss" in nachricht.text
eintraege = list(db.scalars(
select(AuditEintrag).where(AuditEintrag.action == "benachrichtigt")
).all())
assert len(eintraege) == 1
assert eintraege[0].actor_name == "System"
assert eintraege[0].objekt_typ == "erinnerung"
assert eintraege[0].details["ausgabe"] == "3/2025"
zeile = db.scalar(select(ErinnerungsProtokoll))
assert zeile.user_id == rez.id
assert zeile.kanaele == "inapp"
# ---------------- Scheduler-Lifecycle & Konfiguration ----------------
def test_scheduler_standardmaessig_deaktiviert_in_tests(settings):
app = create_app(settings)
assert app.state.registry.get("erinnerung")._scheduler is None
def test_scheduler_laesst_sich_aktivieren_und_konfigurieren(monkeypatch, settings):
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_AKTIV", "1")
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_UHRZEIT", "7:30")
app = create_app(settings)
plugin = app.state.registry.get("erinnerung")
with TestClient(app):
assert plugin._scheduler is not None
job = plugin._scheduler.get_job("erinnerungs-check")
assert job is not None
felder = {feld.name: str(feld) for feld in job.trigger.fields}
assert felder["hour"] == "7"
assert felder["minute"] == "30"
assert plugin._scheduler is None # on_unload hat ihn gestoppt
def test_scheduler_kaputte_uhrzeit_faellt_auf_standard(monkeypatch, settings):
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_AKTIV", "1")
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_UHRZEIT", "garstin:keinzahl")
app = create_app(settings)
plugin = app.state.registry.get("erinnerung")
with TestClient(app):
job = plugin._scheduler.get_job("erinnerungs-check")
felder = {feld.name: str(feld) for feld in job.trigger.fields}
assert felder["hour"] == "8"
assert felder["minute"] == "0"
# ---------------- Fenster-Grenzlogik (Unit) ----------------
def test_fenster_grenzen():
from plugins.erinnerung.dienst import (
ERINNERUNGSFENSTER_TAGE,
im_erinnerungsfenster,
tage_bis,
)
schluss = date(2030, 6, 15)
assert tage_bis(schluss, date(2030, 5, 18)) == ERINNERUNGSFENSTER_TAGE
assert im_erinnerungsfenster(schluss, date(2030, 5, 18)) # genau 28 Tage
assert not im_erinnerungsfenster(schluss, date(2030, 5, 17)) # 29 Tage → zu früh
assert im_erinnerungsfenster(schluss, date(2030, 6, 15)) # am Schluss selbst
assert not im_erinnerungsfenster(schluss, date(2030, 6, 16)) # vorbei

View File

@@ -41,7 +41,9 @@ def test_plugins_haben_navigation(app):
# Voll implementierte Plugins tragen keinen Platzhalter-Text mehr auf ihrer # Voll implementierte Plugins tragen keinen Platzhalter-Text mehr auf ihrer
# Seite; die Prüfung läuft weiter für alle übrigen Platzhalter. # Seite; die Prüfung läuft weiter für alle übrigen Platzhalter.
VOLL_IMPLEMENTIERT = {"audit-log", "neuheiten", "benachrichtigung", "dedup", "planung", "archiv"} VOLL_IMPLEMENTIERT = {
"audit-log", "neuheiten", "benachrichtigung", "dedup", "planung", "archiv", "erinnerung",
}
PLATZHALTER_STUBS = ERWARTETE_STUBS - VOLL_IMPLEMENTIERT PLATZHALTER_STUBS = ERWARTETE_STUBS - VOLL_IMPLEMENTIERT

35
uv.lock generated
View File

@@ -252,6 +252,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
] ]
[[package]]
name = "freezegun"
version = "1.5.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
]
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.5.5" version = "3.5.5"
@@ -677,6 +689,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
] ]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.3" version = "1.2.3"
@@ -829,6 +853,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" }, { url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" },
] ]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "spiele-redaktion" name = "spiele-redaktion"
version = "0.1.0" version = "0.1.0"
@@ -848,6 +881,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "freezegun" },
{ name = "httpx" }, { name = "httpx" },
{ name = "pytest" }, { name = "pytest" },
] ]
@@ -868,6 +902,7 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "freezegun", specifier = ">=1.5.5" },
{ name = "httpx", specifier = ">=0.27" }, { name = "httpx", specifier = ">=0.27" },
{ name = "pytest", specifier = ">=8.0" }, { name = "pytest", specifier = ">=8.0" },
] ]