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,
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
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 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ü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 — die Funktion wird in einer späteren Phase implementiert.
|
Planungseinträge haben — 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ö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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
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
|
# 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
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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user