From 469cf8826e118e4cc6f07bc55c24657bf754d94a Mon Sep 17 00:00:00 2001 From: Flo Hartmann Date: Fri, 21 Aug 2026 20:31:13 +0000 Subject: [PATCH] Plugin erinnerung: Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ausgaben (Name + Redaktionsschluss-Datum) verwalten, mehrere parallel, Anlegen/Bearbeiten/Löschen nur für Admin, mit Audit-Log-Einträgen - Erinnerung 28 Tage vor dem Redaktionsschluss an alle aktiven Rezensenten mit offener (oder fehlender) Planung, Versand über das benachrichtigung- Plugin je Kanal-Präferenz, Audit als System/benachrichtigt - Täglicher APScheduler-Job (SPIELE_ERINNERUNG_JOB_UHRZEIT, abschaltbar) plus manuelle Sofort-Prüfung in der Admin-Ansicht - Doppelschutz: Protokolltabelle mit Unique (Ausgabe, Benutzer) - Admin-UI: Ausgabenliste mit Restzeit-Badges, Erinnerungsstatus (wer wurde wann über welche Kanäle erinnert), deutsche Templates - Tests mit eingefrorener Uhr (freezegun): Fenster-Grenzen, kein Versand vor Frist/nach Schluss, Empfänger-/Rollenregeln, keine Duplikate, Scheduler-Konfiguration; Integrationstest mit den echten Plugins (167 Tests grün) --- README.md | 68 ++- plugins/erinnerung/__init__.py | 525 ++++++++++++++++- plugins/erinnerung/dienst.py | 148 +++++ plugins/erinnerung/models.py | 64 ++ .../templates/erinnerung/bearbeiten.html | 34 ++ .../templates/erinnerung/index.html | 142 ++++- pyproject.toml | 1 + tests/conftest.py | 1 + tests/test_erinnerung.py | 550 ++++++++++++++++++ tests/test_plugin_loader.py | 4 +- uv.lock | 35 ++ 11 files changed, 1549 insertions(+), 23 deletions(-) create mode 100644 plugins/erinnerung/dienst.py create mode 100644 plugins/erinnerung/models.py create mode 100644 plugins/erinnerung/templates/erinnerung/bearbeiten.html create mode 100644 tests/test_erinnerung.py diff --git a/README.md b/README.md index 1f83c96..9ecccab 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 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** und **Planungsliste**. +**Dedup-Prüfung**, **Planungsliste** und **Erinnerungen**. ## Stack @@ -54,6 +54,8 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt: | `SPIELE_BGG_SUCHBEGRIFFE` | `brettspiel` | Komma-getrennte Suchbegriffe für den regelmäßigen Sync | | `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `25` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) | | `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG-Zusatzdaten für die Dedup-Prüfung an (`1`) oder aus (`0`): Alternate-Names und Erweiterungs-Relationen | +| `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 @@ -75,6 +77,12 @@ Fake-BGG-Client, Prüfprotokoll, Prüfseite) und **planung** (beide Eintragswege mit Stub-gemockter dedup-/Benachrichtigungs-/Audit-API, Verlags-Auswahl- Dialog, Statuswechsel, Bearbeiten/Löschen, Rechte pro Rolle) plus ein Integrationstest mit den echten Plugins (In-App-Nachricht + Audit-Eintrag). +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 @@ -94,7 +102,8 @@ plugins/ ein Ordner pro Funktion, ladbar über den Plugin-Load ├── benachrichtigung/ VOLL IMPLEMENTIERT: E-Mail/Telegram/In-App ├── dedup/ VOLL IMPLEMENTIERT: check_titel-API, Heuristik, Protokoll ├── planung/ VOLL IMPLEMENTIERT: Planungsliste, Verschiebung, Dialoge -├── archiv/ erinnerung/ export/ (Stubs, ladbar) +├── erinnerung/ VOLL IMPLEMENTIERT: Ausgaben, 4-Wochen-Erinnerung, Tages-Job +├── archiv/ export/ (Stubs, ladbar) └── … je __init__.py + templates// ``` @@ -363,6 +372,58 @@ Rezensenten können **nur ihre eigenen Einträge** bearbeiten (Statuswechsel, Bearbeiten, Löschen); Admins und Redakteure alle. Jede Änderung wird im Audit-Log protokolliert (`erstellt`/`verschoben`/`geaendert`/`geloescht`). +## 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) Docker/Podman Compose ist vorgesehen (Henry-Lab, danach Kundenhardware). @@ -382,7 +443,7 @@ geschrieben. | 5 | Plugin `dedup` (Verlags-Konflikt, deutsche Version, Vorgänger-/Planungs-Check) | ✅ fertig (141 Tests grün) | | 6 | Plugin `planung` (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) | ✅ fertig (141 Tests grün) | | 7 | Plugin `archiv` (12-Monats-Autopilot) | ⏳ offen | -| 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung) | ⏳ offen | +| 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung, Tages-Job) | ✅ fertig (167 Tests grün) | | 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 | @@ -392,6 +453,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 diff --git a/plugins/erinnerung/__init__.py b/plugins/erinnerung/__init__.py index 98be7f2..eac98b6 100644 --- a/plugins/erinnerung/__init__.py +++ b/plugins/erinnerung/__init__.py @@ -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() diff --git a/plugins/erinnerung/dienst.py b/plugins/erinnerung/dienst.py new file mode 100644 index 0000000..7438a61 --- /dev/null +++ b/plugins/erinnerung/dienst.py @@ -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." + ) diff --git a/plugins/erinnerung/models.py b/plugins/erinnerung/models.py new file mode 100644 index 0000000..1e032b3 --- /dev/null +++ b/plugins/erinnerung/models.py @@ -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"" + + +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"" diff --git a/plugins/erinnerung/templates/erinnerung/bearbeiten.html b/plugins/erinnerung/templates/erinnerung/bearbeiten.html new file mode 100644 index 0000000..3672ebc --- /dev/null +++ b/plugins/erinnerung/templates/erinnerung/bearbeiten.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %} +{% block inhalt %} +

Ausgabe bearbeiten

+

„{{ ausgabe.name }}“ · Redaktionsschluss: + {{ ausgabe.redaktionsschluss.strftime('%d.%m.%Y') }} +

+ +
+
+ + +
+
+ + +
+
+ + Abbrechen +
+
+ +

+ Hinweis: Bereits protokollierte Erinnerungen bleiben bestehen — pro Ausgabe + und Benutzer wird nur einmal erinnert. +

+{% endblock %} diff --git a/plugins/erinnerung/templates/erinnerung/index.html b/plugins/erinnerung/templates/erinnerung/index.html index cc09663..f16bffc 100644 --- a/plugins/erinnerung/templates/erinnerung/index.html +++ b/plugins/erinnerung/templates/erinnerung/index.html @@ -2,9 +2,143 @@ {% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %} {% block inhalt %}

{{ titel }}

-

- Plugin {{ name }} - in Version {{ version }} ist geladen. - Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert. +

+ 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.

+ +{% if meldung %} +
{{ meldung }}
+{% endif %} +{% if fehler %} +
{{ fehler }}
+{% endif %} + +{% if ist_admin %} +
+

Neue Ausgabe anlegen

+
+
+ + +
+
+ + +
+ +
+

Mehrere Ausgaben können parallel laufen.

+
+{% endif %} + +
+ + + + + + + + {% if ist_admin %}{% endif %} + + + + {% for zeile in zeilen %} + + + + + + {% if ist_admin %} + + {% endif %} + + {% else %} + + + + {% endfor %} + +
AusgabeRedaktionsschlussRestzeitErinnerungenAktionen
{{ zeile.ausgabe.name }}{{ zeile.ausgabe.redaktionsschluss.strftime('%d.%m.%Y') }} + {% if zeile.tage_rest > fenster_tage %} + + noch {{ zeile.tage_rest }} Tage · Erinnerung in {{ zeile.tage_rest - fenster_tage }} Tagen + + {% elif zeile.im_fenster %} + + Erinnerungsphase — {{ zeile.tage_rest }} Tag{{ 'e' if zeile.tage_rest != 1 }} bis zum Redaktionsschluss + + {% else %} + Redaktionsschluss vorbei + {% endif %} + + {{ zeile.erinnert_anzahl }} erinnert + +
+ Bearbeiten +
+ +
+
+
+ Noch keine Ausgaben angelegt. + {% if ist_admin %}Lege oben die erste Ausgabe mit ihrem Redaktionsschluss an.{% endif %} +
+
+ +{% if ist_admin %} +
+
+

Sofort prüfen

+

Führt denselben Check aus wie der tägliche Hintergrund-Job ({{ fenster_tage }} Tage vor dem Redaktionsschluss). Doppelerinnerungen sind ausgeschlossen.

+
+
+ +
+
+ +
+

Erinnerungsstatus

+

Wer wurde für welche Ausgabe wann erinnert? Pro Ausgabe und Benutzer wird höchstens einmal erinnert.

+
+ + + + + + + + + + + {% for eintrag in protokoll %} + + + + + + + {% else %} + + {% endfor %} + +
AusgabeRezensentKanäleErinnert am
{{ eintrag.ausgabe_name }}{{ eintrag.benutzer }} + {% if eintrag.eintrag.kanaele %}{{ eintrag.eintrag.kanaele.replace(',', ', ') }}{% else %}—{% endif %} + + {{ eintrag.eintrag.erstellt_am.strftime('%d.%m.%Y %H:%M') if eintrag.eintrag.erstellt_am else '—' }} +
Noch keine Erinnerungen versendet.
+
+
+{% endif %} {% endblock %} diff --git a/pyproject.toml b/pyproject.toml index a0cbd27..1876495 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ dev = [ "pytest>=8.0", "httpx>=0.27", + "freezegun>=1.5.5", ] [build-system] diff --git a/tests/conftest.py b/tests/conftest.py index b728807..a2712eb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,7 @@ def _hintergrundjobs_deaktiviert(monkeypatch): monkeypatch.setenv("SPIELE_BGG_SYNC_AKTIV", "1"). """ monkeypatch.setenv("SPIELE_BGG_SYNC_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") diff --git a/tests/test_erinnerung.py b/tests/test_erinnerung.py new file mode 100644 index 0000000..dffc3dd --- /dev/null +++ b/tests/test_erinnerung.py @@ -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 diff --git a/tests/test_plugin_loader.py b/tests/test_plugin_loader.py index 12ebd3f..9985dd0 100644 --- a/tests/test_plugin_loader.py +++ b/tests/test_plugin_loader.py @@ -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"} +VOLL_IMPLEMENTIERT = { + "audit-log", "neuheiten", "benachrichtigung", "dedup", "planung", "erinnerung", +} PLATZHALTER_STUBS = ERWARTETE_STUBS - VOLL_IMPLEMENTIERT diff --git a/uv.lock b/uv.lock index 13277b0..9469030 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ]