Merge wt-erinnerung: Erinnerungs-Plugin (Konflikte aufgelöst)
This commit is contained in:
71
README.md
71
README.md
@@ -4,7 +4,8 @@
|
||||
modularer Plugin-Architektur. Lauffähiger Kern mit Plugin-System,
|
||||
Authentifizierung/Rollen und Migrationen; vollständig implementiert sind
|
||||
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
|
||||
|
||||
@@ -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_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_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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
```
|
||||
@@ -102,7 +112,8 @@ plugins/ ein Ordner pro Funktion, ladbar über den Plugin-Load
|
||||
├── dedup/ VOLL IMPLEMENTIERT: check_titel-API, Heuristik, Protokoll
|
||||
├── planung/ VOLL IMPLEMENTIERT: Planungsliste, Verschiebung, Dialoge
|
||||
├── 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>/
|
||||
```
|
||||
|
||||
@@ -430,6 +441,57 @@ vereinigte Liste angezeigt (neueste Archivierung zuerst):
|
||||
nicht wiederhergestellt; existiert der zugeordnete Rezensent nicht mehr,
|
||||
übernimmt der wiederherstellende Admin die Zuordnung (gemeldet und
|
||||
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)
|
||||
|
||||
@@ -449,8 +511,8 @@ geschrieben.
|
||||
| 4 | Plugin `neuheiten` (BGG-Sync, APScheduler, Filter) | ✅ fertig |
|
||||
| 5 | Plugin `dedup` (Verlags-Konflikt, deutsche Version, Vorgänger-/Planungs-Check) | ✅ 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) |
|
||||
| 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung) | ⏳ offen |
|
||||
| 7 | Plugin `archiv` (12-Monats-Autopilot, Wiederherstellen, täglicher Job) | ✅ fertig |
|
||||
| 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung, Tages-Job) | ✅ fertig |
|
||||
| 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ⏳ offen |
|
||||
| 10 | Integrationstests über alle Plugins | ⏳ 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)
|
||||
|
||||
- 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)
|
||||
- Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets
|
||||
|
||||
@@ -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()
|
||||
|
||||
148
plugins/erinnerung/dienst.py
Normal file
148
plugins/erinnerung/dienst.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Geschäftslogik des Plugins „erinnerung“.
|
||||
|
||||
Alle Regeln liegen bewusst hier (und nicht im Kern):
|
||||
|
||||
- **Fenster:** Vier Wochen (28 Tage) vor dem Redaktionsschluss öffnet das
|
||||
Erinnerungsfenster; es gilt bis zum Redaktionsschluss (inklusive). Danach
|
||||
wird nicht mehr erinnert — vor dem Fenster erst recht nicht.
|
||||
- **Empfänger:** alle aktiven Rezensenten (Rolle „rezensent“), die noch
|
||||
offene Planungseinträge haben (Status ≠ „abgeschlossen“, also „offen“
|
||||
oder „in Bearbeitung“) oder gar keine Einträge. Wer alles abgeschlossen
|
||||
hat, wird nicht erinnert; Admins und Redakteure nie.
|
||||
- **Uhr:** `heute()` liefert das aktuelle Datum und ist die einzige
|
||||
Zeitquelle der Prüfung — Tests frieren sie ein (freezegun oder
|
||||
Monkeypatch), der geplante Job und die Routen nutzen sie unverändert.
|
||||
|
||||
Die Planungsliste wird nicht importiert, sondern über die gemeinsame
|
||||
SQLAlchemy-Metadata angesprochen (wie im planung-Plugin mit der
|
||||
Neuheiten-Tabelle): Fehlt das planung-Plugin, entfällt die Unterscheidung
|
||||
„hat offene Einträge“ und alle Rezensenten werden erinnert.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from redaktionskern.auth.models import Role, User
|
||||
from redaktionskern.db import Base
|
||||
|
||||
from .models import Ausgabe, ErinnerungsProtokoll
|
||||
|
||||
#: Länge des Erinnerungsfensters in Tagen (4 Wochen vor Redaktionsschluss).
|
||||
ERINNERUNGSFENSTER_TAGE = 4 * 7
|
||||
|
||||
#: Statuswert des planung-Plugins für „fertig“ (Kopie ohne Import-Abhängigkeit).
|
||||
STATUS_ABGESCHLOSSEN = "abgeschlossen"
|
||||
|
||||
|
||||
def heute() -> date:
|
||||
"""Aktuelles Datum — die eingefrierbare Uhr dieses Plugins."""
|
||||
return date.today()
|
||||
|
||||
|
||||
def tage_bis(schluss: date, tag: date) -> int:
|
||||
"""Tage vom Prüfdatum bis zum Redaktionsschluss (negativ = vorbei)."""
|
||||
return (schluss - tag).days
|
||||
|
||||
|
||||
def im_erinnerungsfenster(schluss: date, tag: date) -> bool:
|
||||
"""True, wenn am Prüfdatum erinnert werden darf.
|
||||
|
||||
Fenster: `schluss - 28 Tage` … `schluss` (jeweils inklusive).
|
||||
"""
|
||||
return 0 <= tage_bis(schluss, tag) <= ERINNERUNGSFENSTER_TAGE
|
||||
|
||||
|
||||
def parse_datum(roh: str) -> date | None:
|
||||
"""Parst ein ISO-Datum (JJJJ-MM-TT); None bei leer, ValueError bei Unsinn."""
|
||||
roh = (roh or "").strip()
|
||||
if not roh:
|
||||
return None
|
||||
return date.fromisoformat(roh)
|
||||
|
||||
|
||||
def alle_ausgaben(db: Session) -> list[Ausgabe]:
|
||||
"""Alle Ausgaben, frühester Redaktionsschluss zuerst."""
|
||||
return list(
|
||||
db.scalars(select(Ausgabe).order_by(Ausgabe.redaktionsschluss, Ausgabe.id)).all()
|
||||
)
|
||||
|
||||
|
||||
def faellige_ausgaben(db: Session, tag: date) -> list[Ausgabe]:
|
||||
"""Ausgaben, deren Erinnerungsfenster am Prüfdatum offen ist."""
|
||||
return [
|
||||
ausgabe
|
||||
for ausgabe in alle_ausgaben(db)
|
||||
if im_erinnerungsfenster(ausgabe.redaktionsschluss, tag)
|
||||
]
|
||||
|
||||
|
||||
def bereits_erinnerte_ids(db: Session, ausgabe_id: int) -> set[int]:
|
||||
"""Benutzer, die für diese Ausgabe schon erinnert wurden (Doppelschutz)."""
|
||||
return set(
|
||||
db.scalars(
|
||||
select(ErinnerungsProtokoll.user_id).where(
|
||||
ErinnerungsProtokoll.ausgabe_id == ausgabe_id
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def planungs_statistik(db: Session) -> tuple[set[int], set[int]]:
|
||||
"""Liefert (IDs mit offenen Einträgen, IDs mit Einträgen überhaupt)."""
|
||||
tabelle = Base.metadata.tables.get("planungsliste")
|
||||
if tabelle is None:
|
||||
# planung-Plugin nicht geladen → keine Unterscheidung möglich.
|
||||
return set(), set()
|
||||
offene = {
|
||||
zeile[0]
|
||||
for zeile in db.execute(
|
||||
select(tabelle.c.rezensent_id)
|
||||
.where(tabelle.c.status != STATUS_ABGESCHLOSSEN)
|
||||
.distinct()
|
||||
).all()
|
||||
}
|
||||
alle = {
|
||||
zeile[0]
|
||||
for zeile in db.execute(select(tabelle.c.rezensent_id).distinct()).all()
|
||||
}
|
||||
return offene, alle
|
||||
|
||||
|
||||
def empfaenger(db: Session) -> list[User]:
|
||||
"""Die zu erinnierenden Rezensenten (siehe Moduldokumentation).
|
||||
|
||||
Rezensent mit offenen Einträgen → erinnert.
|
||||
Rezensent ohne jeden Eintrag → erinnert.
|
||||
Rezensent nur mit abgeschlossenen Einträgen → nicht erinnert.
|
||||
"""
|
||||
rezensenten = list(
|
||||
db.scalars(
|
||||
select(User)
|
||||
.where(User.role == Role.REZENSENT.value, User.active.is_(True))
|
||||
.order_by(User.username)
|
||||
).all()
|
||||
)
|
||||
offene, alle = planungs_statistik(db)
|
||||
return [
|
||||
benutzer
|
||||
for benutzer in rezensenten
|
||||
if benutzer.id not in alle or benutzer.id in offene
|
||||
]
|
||||
|
||||
|
||||
def nachricht_text(ausgabe: Ausgabe, tage_rest: int) -> str:
|
||||
"""Text der Erinnerungs-Benachrichtigung (deutsch)."""
|
||||
if tage_rest == 0:
|
||||
rest = "heute ist der Redaktionsschluss"
|
||||
elif tage_rest == 1:
|
||||
rest = "morgen ist der Redaktionsschluss"
|
||||
else:
|
||||
rest = f"der Redaktionsschluss ist in {tage_rest} Tagen"
|
||||
return (
|
||||
f"Ausgabe „{ausgabe.name}“: {rest} ({ausgabe.redaktionsschluss.strftime('%d.%m.%Y')}).\n"
|
||||
"Bitte schließe deine Planungseinträge ab oder trage noch fehlende Titel "
|
||||
"unter „Planung“ nach."
|
||||
)
|
||||
64
plugins/erinnerung/models.py
Normal file
64
plugins/erinnerung/models.py
Normal 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}>"
|
||||
34
plugins/erinnerung/templates/erinnerung/bearbeiten.html
Normal file
34
plugins/erinnerung/templates/erinnerung/bearbeiten.html
Normal 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">„{{ ausgabe.name }}“ · 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 — pro Ausgabe
|
||||
und Benutzer wird nur einmal erinnert.
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -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 — die Funktion wird in einer späteren Phase implementiert.
|
||||
<p class="text-slate-600 mb-4">
|
||||
Redaktionsschlüsse der kommenden Ausgaben. Vier Wochen vor jedem
|
||||
Redaktionsschluss erhalten alle Rezensenten eine Erinnerung, die noch offene
|
||||
Planungseinträge haben — 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ö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 · 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 — {{ 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ö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üfen</h2>
|
||||
<p class="text-xs text-slate-500">Führt denselben Check aus wie der tä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ü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ür welche Ausgabe wann erinnert? Pro Ausgabe und Benutzer wird hö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ä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 %}—{% 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 '—' }}
|
||||
</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 %}
|
||||
|
||||
@@ -21,6 +21,7 @@ dependencies = [
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"httpx>=0.27",
|
||||
"freezegun>=1.5.5",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -22,8 +22,9 @@ def _hintergrundjobs_deaktiviert(monkeypatch):
|
||||
monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "1").
|
||||
"""
|
||||
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_ERINNERUNG_JOB_AKTIV", "0")
|
||||
# Auch der BGG-Hilfsclient des dedup-Plugins bleibt in Tests offline.
|
||||
monkeypatch.setenv("SPIELE_DEDUP_BGG_AKTIV", "0")
|
||||
|
||||
|
||||
550
tests/test_erinnerung.py
Normal file
550
tests/test_erinnerung.py
Normal 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
|
||||
@@ -41,7 +41,9 @@ def test_plugins_haben_navigation(app):
|
||||
|
||||
# Voll implementierte Plugins tragen keinen Platzhalter-Text mehr auf ihrer
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
35
uv.lock
generated
35
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "greenlet"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "python-dotenv"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "spiele-redaktion"
|
||||
version = "0.1.0"
|
||||
@@ -848,6 +881,7 @@ dependencies = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "freezegun" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
@@ -868,6 +902,7 @@ requires-dist = [
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "freezegun", specifier = ">=1.5.5" },
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "pytest", specifier = ">=8.0" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user