Merge wt-erinnerung: Erinnerungs-Plugin (Konflikte aufgelöst)
This commit is contained in:
@@ -1,41 +1,538 @@
|
||||
"""Plugin „erinnerung“ — Platzhalter gemäß Plugin-Vertrag.
|
||||
"""Plugin „erinnerung“ — Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung.
|
||||
|
||||
Implementiert in einer späteren Phase. Der Stub zeigt den vollen Vertrag:
|
||||
eigene Route, eigenes Template, Lifecycle-Hooks, Migrations-Schnittstelle.
|
||||
Umfang:
|
||||
- **Ausgaben verwalten** (nur Rolle Admin): Name/Titel + Redaktionsschluss-Datum,
|
||||
mehrere Ausgaben parallel, anlegen/bearbeiten/löschen.
|
||||
- **Automatische Erinnerung:** vier Wochen vor dem Redaktionsschluss erhält
|
||||
jeder aktive Rezensent eine Benachrichtigung, der noch offene
|
||||
Planungseinträge hat (Status ≠ „abgeschlossen“) oder gar keine — über das
|
||||
Plugin „benachrichtigung“ (`send_notification`), Kanäle je Benutzer-Präferenz.
|
||||
- **Geplanter Job:** APScheduler, täglich einmal (Uhrzeit per Env
|
||||
`SPIELE_ERINNERUNG_JOB_UHRZEIT`, abschaltbar per `SPIELE_ERINNERUNG_JOB_AKTIV`).
|
||||
- **Keine Doppelerinnerung:** pro (Ausgabe, Benutzer) wird genau einmal
|
||||
erinnert; der Versand wird in `erinnerung_protokoll` protokolliert.
|
||||
- **Audit-Log:** Ausgaben-CRUD und jede Erinnerung werden über das
|
||||
Plugin „audit-log“ protokolliert (Erinnerung als „System“/`benachrichtigt`).
|
||||
|
||||
Geschäftsregeln liegen in `dienst.py` (einfrierbare Uhr: `heute()`),
|
||||
Partner-Plugins werden wie überall über die Registry geholt; fehlen sie,
|
||||
wird der Durchlauf ohne Versand/Protokoll übersprungen bzw. ohne Audit
|
||||
fortgesetzt — der Vorgang bricht nie.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, Request
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from urllib.parse import quote
|
||||
|
||||
from redaktionskern.auth.deps import require_user
|
||||
from redaktionskern.auth.models import User
|
||||
from redaktionskern.contracts import BasePlugin, NavEntry
|
||||
from fastapi import Depends, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from redaktionskern.auth.deps import get_db, require_roles, require_user
|
||||
from redaktionskern.auth.models import Role, User
|
||||
from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext
|
||||
|
||||
# Bewusst `from .dienst import …` (wie `from .models import …` überall sonst):
|
||||
# `from . import dienst` funktioniert unter dem dateibasierten Plugin-Loader
|
||||
# nicht zuverlässig (Python 3.14 versucht dort einen absoluten Import der
|
||||
# Namensraum-Oberpakete).
|
||||
from .dienst import (
|
||||
ERINNERUNGSFENSTER_TAGE,
|
||||
alle_ausgaben,
|
||||
bereits_erinnerte_ids,
|
||||
empfaenger,
|
||||
faellige_ausgaben,
|
||||
heute,
|
||||
im_erinnerungsfenster,
|
||||
nachricht_text,
|
||||
parse_datum,
|
||||
tage_bis,
|
||||
)
|
||||
from .models import Ausgabe, ErinnerungsProtokoll
|
||||
|
||||
_logger = logging.getLogger("plugins.erinnerung")
|
||||
|
||||
MODUL_PRAEFIX = "spiele_redaktion_plugins"
|
||||
JOB_ID = "erinnerungs-check"
|
||||
MAX_MELDUNGS_LAENGE = 300
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pruefbericht:
|
||||
"""Ergebnis eines `pruefe_und_erinnere`-Durchlaufs."""
|
||||
|
||||
gepruefte_ausgaben: int = 0
|
||||
versendet: list[dict] = field(default_factory=list)
|
||||
#: (Ausgabe, Benutzer)-Paare im Fenster, die wegen Protokolleintrag
|
||||
#: übersprungen wurden (Doppelschutz gegriffen).
|
||||
uebersprungen: int = 0
|
||||
|
||||
@property
|
||||
def anzahl_versendet(self) -> int:
|
||||
return len(self.versendet)
|
||||
|
||||
def als_text(self) -> str:
|
||||
if self.gepruefte_ausgaben == 0:
|
||||
return "Keine Ausgaben mit offenem Erinnerungsfenster."
|
||||
return (
|
||||
f"{self.gepruefte_ausgaben} Ausgabe(n) im Erinnerungsfenster geprüft, "
|
||||
f"{self.anzahl_versendet} Erinnerung(en) versendet, "
|
||||
f"{self.uebersprungen} bereits erinnert."
|
||||
)
|
||||
|
||||
|
||||
def _tabellen_erstellen(conn) -> None:
|
||||
"""Eigene Migration des Plugins: legt beide Tabellen an (idempotent)."""
|
||||
Ausgabe.__table__.create(conn, checkfirst=True)
|
||||
ErinnerungsProtokoll.__table__.create(conn, checkfirst=True)
|
||||
|
||||
|
||||
class ErinnerungPlugin(BasePlugin):
|
||||
name = "erinnerung"
|
||||
title = "Erinnerungen"
|
||||
description = "Erinnerungen vor Redaktionsschluss pro Ausgabe (Platzhalter)."
|
||||
description = (
|
||||
"Vier Wochen vor dem Redaktionsschluss pro Ausgabe Erinnerung an alle "
|
||||
"Rezensenten mit offener (oder fehlender) Planung."
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._scheduler = None # BackgroundScheduler, falls aktiviert
|
||||
self._lauf_sperre = threading.Lock()
|
||||
self._registry = None # wird bei Requests gemerkt (Partner-Auflösung)
|
||||
self._routen_registrieren()
|
||||
|
||||
# ---------- Plugin-Vertrag ----------
|
||||
|
||||
def migrations(self) -> list[Migration]:
|
||||
return [Migration(version="0001_ausgaben_und_protokoll", up=_tabellen_erstellen)]
|
||||
|
||||
def navigation(self) -> list[NavEntry]:
|
||||
return [NavEntry(label=self.title, url="/erinnerung")]
|
||||
|
||||
def on_load(self, context: PluginContext) -> None:
|
||||
super().on_load(context)
|
||||
import os
|
||||
|
||||
if os.environ.get("SPIELE_ERINNERUNG_JOB_AKTIV", "1").strip() == "1":
|
||||
self._scheduler_starten(os.environ.get("SPIELE_ERINNERUNG_JOB_UHRZEIT"))
|
||||
|
||||
def on_unload(self) -> None:
|
||||
if self._scheduler is not None:
|
||||
self._scheduler.shutdown(wait=False)
|
||||
self._scheduler = None
|
||||
super().on_unload()
|
||||
|
||||
# ---------- Partner-Plugins (benachrichtigung, audit-log) ----------
|
||||
|
||||
def _merke_registry(self, registry) -> None:
|
||||
"""Merk sich die Registry, damit auch der Hintergrund-Job Partner
|
||||
auflösen kann (auch ohne vorherigen Request via sys.modules-Fallback)."""
|
||||
self._registry = registry
|
||||
|
||||
def _partner(self, name: str):
|
||||
"""Löst ein Partner-Plugin auf: Registry zuerst, sonst Modul-Lookup.
|
||||
|
||||
Der Fallback über `sys.modules` deckt den Fall „Job läuft, bevor der
|
||||
erste Request die Registry gemerkt hat“ ab (Verzeichnis-Plugins).
|
||||
"""
|
||||
if self._registry is not None:
|
||||
partner = self._registry.get(name)
|
||||
if partner is not None:
|
||||
return partner
|
||||
modul = sys.modules.get(f"{MODUL_PRAEFIX}.{name}")
|
||||
return getattr(modul, "plugin", None) if modul else None
|
||||
|
||||
# ---------- Öffentliche API für andere Plugins/Tests ----------
|
||||
|
||||
async def pruefe_und_erinnere(
|
||||
self,
|
||||
jetzt: date | None = None,
|
||||
*,
|
||||
benachrichtigung=None,
|
||||
audit=None,
|
||||
) -> Pruefbericht:
|
||||
"""Prüft alle Ausgaben gegen die eingefrierbare Uhr und erinnert.
|
||||
|
||||
`jetzt` überschreibt die Uhr (Tests); ohne Angabe gilt `heute()`.
|
||||
Partner-Plugins können übergeben werden, sonst werden sie über die
|
||||
Registry aufgelöst. Der Durchlauf wirft nicht für einzelne Empfänger:
|
||||
Fehlschläge werden protokolliert und im Bericht gezählt.
|
||||
"""
|
||||
assert self.context is not None
|
||||
if benachrichtigung is None:
|
||||
benachrichtigung = self._partner("benachrichtigung")
|
||||
if audit is None:
|
||||
audit = self._partner("audit-log")
|
||||
|
||||
pruef_tag = jetzt if jetzt is not None else heute()
|
||||
bericht = Pruefbericht()
|
||||
|
||||
with self.context.session_factory() as db:
|
||||
for ausgabe in faellige_ausgaben(db, pruef_tag):
|
||||
bericht.gepruefte_ausgaben += 1
|
||||
tage_rest = tage_bis(ausgabe.redaktionsschluss, pruef_tag)
|
||||
schon_erinnert = bereits_erinnerte_ids(db, ausgabe.id)
|
||||
text = nachricht_text(ausgabe, tage_rest)
|
||||
|
||||
for empfaenger_user in empfaenger(db):
|
||||
if empfaenger_user.id in schon_erinnert:
|
||||
bericht.uebersprungen += 1
|
||||
continue
|
||||
kanaele: list[str] = []
|
||||
if benachrichtigung is not None:
|
||||
try:
|
||||
zustellbericht = await benachrichtigung.send_notification(
|
||||
empfaenger_user,
|
||||
f"Redaktionsschluss: {ausgabe.name}",
|
||||
text,
|
||||
"erinnerung",
|
||||
)
|
||||
kanaele = list(getattr(zustellbericht, "zugestellt", []))
|
||||
except Exception:
|
||||
# Ein Kanalfehler darf den Durchlauf nicht abbrechen;
|
||||
# ohne Protokolleintrag würde der nächste Lauf es
|
||||
# erneut (und hoffentlich erfolgreich) versuchen.
|
||||
_logger.exception(
|
||||
"Erinnerung an %s (Ausgabe %s) fehlgeschlagen.",
|
||||
empfaenger_user.username,
|
||||
ausgabe.name,
|
||||
)
|
||||
continue
|
||||
db.add(
|
||||
ErinnerungsProtokoll(
|
||||
ausgabe_id=ausgabe.id,
|
||||
user_id=empfaenger_user.id,
|
||||
kanaele=",".join(kanaele),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
bericht.versendet.append(
|
||||
{
|
||||
"ausgabe": ausgabe.name,
|
||||
"ausgabe_id": ausgabe.id,
|
||||
"benutzer": empfaenger_user.username,
|
||||
"user_id": empfaenger_user.id,
|
||||
"kanaele": kanaele,
|
||||
"tage_rest": tage_rest,
|
||||
}
|
||||
)
|
||||
if audit is not None:
|
||||
try:
|
||||
await audit.log(
|
||||
None,
|
||||
"benachrichtigt",
|
||||
"erinnerung",
|
||||
empfaenger_user.id,
|
||||
{
|
||||
"ausgabe": ausgabe.name,
|
||||
"ausgabe_id": ausgabe.id,
|
||||
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
|
||||
"tage_rest": tage_rest,
|
||||
"kanaele": kanaele,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"Audit-Log-Eintrag für Erinnerung fehlgeschlagen."
|
||||
)
|
||||
return bericht
|
||||
|
||||
def pruefe_und_erinnere_sync(
|
||||
self,
|
||||
jetzt: date | None = None,
|
||||
*,
|
||||
benachrichtigung=None,
|
||||
audit=None,
|
||||
) -> Pruefbericht:
|
||||
"""Synchrone Variante von `pruefe_und_erinnere` (wie audit.log_sync).
|
||||
|
||||
Für Kontexte ohne laufende Event-Loop (APScheduler-Thread, Tests).
|
||||
Darf nicht aus einer async-Funktion heraus aufgerufen werden.
|
||||
"""
|
||||
return asyncio.run(
|
||||
self.pruefe_und_erinnere(
|
||||
jetzt, benachrichtigung=benachrichtigung, audit=audit
|
||||
)
|
||||
)
|
||||
|
||||
# ---------- Hintergrund-Job ----------
|
||||
|
||||
def _scheduler_starten(self, uhrzeit_roh: str | None) -> None:
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
stunde, minute = 8, 0
|
||||
roh = (uhrzeit_roh or "08:00").strip()
|
||||
try:
|
||||
teile = roh.split(":")
|
||||
stunde = max(0, min(23, int(teile[0])))
|
||||
minute = max(0, min(59, int(teile[1]))) if len(teile) > 1 else 0
|
||||
except (ValueError, IndexError):
|
||||
stunde, minute = 8, 0
|
||||
|
||||
scheduler = BackgroundScheduler()
|
||||
scheduler.add_job(
|
||||
self._job,
|
||||
trigger="cron",
|
||||
hour=stunde,
|
||||
minute=minute,
|
||||
id=JOB_ID,
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
self._scheduler = scheduler
|
||||
_logger.info(
|
||||
"Erinnerungs-Check aktiv: täglich um %02d:%02d Uhr.", stunde, minute
|
||||
)
|
||||
|
||||
def _job(self) -> None:
|
||||
"""Hintergrund-Job: tägliche Prüfung (synchroner Kontext, eigener Thread)."""
|
||||
if not self._lauf_sperre.acquire(blocking=False):
|
||||
_logger.info("Erinnerungs-Check läuft bereits — Durchlauf übersprungen.")
|
||||
return
|
||||
try:
|
||||
bericht = self.pruefe_und_erinnere_sync()
|
||||
_logger.info("Erinnerungs-Check abgeschlossen: %s", bericht.als_text())
|
||||
except Exception:
|
||||
_logger.exception("Erinnerungs-Check fehlgeschlagen.")
|
||||
finally:
|
||||
self._lauf_sperre.release()
|
||||
|
||||
# ---------- Routen ----------
|
||||
|
||||
@staticmethod
|
||||
def _meldung(url: str, text: str, art: str = "meldung") -> RedirectResponse:
|
||||
return RedirectResponse(f"{url}?{art}={quote(text)}", status_code=303)
|
||||
|
||||
def _routen_registrieren(self) -> None:
|
||||
|
||||
@self.router.get("/erinnerung")
|
||||
def seite(request: Request, user: User = Depends(require_user)):
|
||||
"""Platzhalterseite des Plugins."""
|
||||
def seite(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_user),
|
||||
meldung: str = "",
|
||||
fehler: str = "",
|
||||
):
|
||||
"""Übersicht: Ausgaben mit Fristen; Admins verwalten und sehen
|
||||
den Erinnerungsstatus (wer wurde wann erinnert)."""
|
||||
self._merke_registry(request.app.state.registry)
|
||||
ist_admin = user.role == Role.ADMIN.value
|
||||
|
||||
pruef_tag = heute()
|
||||
ausgaben = alle_ausgaben(db)
|
||||
zeilen = []
|
||||
for ausgabe in ausgaben:
|
||||
tage_rest = tage_bis(ausgabe.redaktionsschluss, pruef_tag)
|
||||
zeilen.append(
|
||||
{
|
||||
"ausgabe": ausgabe,
|
||||
"tage_rest": tage_rest,
|
||||
"im_fenster": im_erinnerungsfenster(
|
||||
ausgabe.redaktionsschluss, pruef_tag
|
||||
),
|
||||
"erinnert_anzahl": db.scalar(
|
||||
select(func.count())
|
||||
.select_from(ErinnerungsProtokoll)
|
||||
.where(ErinnerungsProtokoll.ausgabe_id == ausgabe.id)
|
||||
)
|
||||
or 0,
|
||||
}
|
||||
)
|
||||
|
||||
protokoll = []
|
||||
if ist_admin:
|
||||
namen = {
|
||||
benutzer.id: (benutzer.display_name or benutzer.username)
|
||||
for benutzer in db.scalars(select(User)).all()
|
||||
}
|
||||
eintraege = list(
|
||||
db.scalars(
|
||||
select(ErinnerungsProtokoll).order_by(
|
||||
ErinnerungsProtokoll.erstellt_am.desc(),
|
||||
ErinnerungsProtokoll.id.desc(),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
ausgaben_namen = {a.id: a.name for a in ausgaben}
|
||||
for eintrag in eintraege:
|
||||
protokoll.append(
|
||||
{
|
||||
"eintrag": eintrag,
|
||||
"ausgabe_name": ausgaben_namen.get(eintrag.ausgabe_id, "—"),
|
||||
"benutzer": namen.get(eintrag.user_id, "—"),
|
||||
}
|
||||
)
|
||||
|
||||
return self.context.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="erinnerung/index.html",
|
||||
context={
|
||||
"user": user,
|
||||
"titel": self.title,
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"zeilen": zeilen,
|
||||
"ist_admin": ist_admin,
|
||||
"protokoll": protokoll,
|
||||
"fenster_tage": ERINNERUNGSFENSTER_TAGE,
|
||||
"meldung": meldung[:MAX_MELDUNGS_LAENGE],
|
||||
"fehler": fehler[:MAX_MELDUNGS_LAENGE],
|
||||
},
|
||||
)
|
||||
|
||||
def navigation(self) -> list[NavEntry]:
|
||||
return [NavEntry(label=self.title, url="/erinnerung")]
|
||||
@self.router.post("/erinnerung/ausgaben")
|
||||
def ausgabe_anlegen(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_roles(Role.ADMIN.value)),
|
||||
name: str = Form(""),
|
||||
redaktionsschluss: str = Form(""),
|
||||
):
|
||||
self._merke_registry(request.app.state.registry)
|
||||
roh_name = name.strip()
|
||||
if not roh_name:
|
||||
return self._meldung("/erinnerung", "Bitte einen Ausgaben-Namen angeben.", "fehler")
|
||||
try:
|
||||
schluss = parse_datum(redaktionsschluss)
|
||||
except ValueError:
|
||||
return self._meldung(
|
||||
"/erinnerung",
|
||||
"Ungültiges Datum — bitte als JJJJ-MM-TT angeben.",
|
||||
"fehler",
|
||||
)
|
||||
if schluss is None:
|
||||
return self._meldung("/erinnerung", "Bitte ein Redaktionsschluss-Datum angeben.", "fehler")
|
||||
|
||||
ausgabe = Ausgabe(name=roh_name, redaktionsschluss=schluss)
|
||||
db.add(ausgabe)
|
||||
db.commit()
|
||||
self._audit_sync(request, user, "erstellt", ausgabe.id, {
|
||||
"name": ausgabe.name,
|
||||
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
|
||||
})
|
||||
return self._meldung(
|
||||
"/erinnerung",
|
||||
f"Ausgabe „{ausgabe.name}“ angelegt (Redaktionsschluss "
|
||||
f"{ausgabe.redaktionsschluss.strftime('%d.%m.%Y')}).",
|
||||
)
|
||||
|
||||
@self.router.get("/erinnerung/ausgaben/{ausgabe_id}/bearbeiten")
|
||||
def bearbeiten_formular(
|
||||
request: Request,
|
||||
ausgabe_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_roles(Role.ADMIN.value)),
|
||||
):
|
||||
ausgabe = db.get(Ausgabe, ausgabe_id)
|
||||
if ausgabe is None:
|
||||
return self._meldung("/erinnerung", "Ausgabe nicht gefunden.", "fehler")
|
||||
return self.context.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="erinnerung/bearbeiten.html",
|
||||
context={
|
||||
"user": user,
|
||||
"titel_headline": f"Ausgabe bearbeiten — {ausgabe.name}",
|
||||
"ausgabe": ausgabe,
|
||||
},
|
||||
)
|
||||
|
||||
@self.router.post("/erinnerung/ausgaben/{ausgabe_id}/bearbeiten")
|
||||
def bearbeiten_speichern(
|
||||
request: Request,
|
||||
ausgabe_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_roles(Role.ADMIN.value)),
|
||||
name: str = Form(""),
|
||||
redaktionsschluss: str = Form(""),
|
||||
):
|
||||
self._merke_registry(request.app.state.registry)
|
||||
ausgabe = db.get(Ausgabe, ausgabe_id)
|
||||
if ausgabe is None:
|
||||
return self._meldung("/erinnerung", "Ausgabe nicht gefunden.", "fehler")
|
||||
roh_name = name.strip()
|
||||
if not roh_name:
|
||||
return self._meldung("/erinnerung", "Bitte einen Ausgaben-Namen angeben.", "fehler")
|
||||
try:
|
||||
schluss = parse_datum(redaktionsschluss)
|
||||
except ValueError:
|
||||
return self._meldung(
|
||||
"/erinnerung",
|
||||
"Ungültiges Datum — bitte als JJJJ-MM-TT angeben.",
|
||||
"fehler",
|
||||
)
|
||||
if schluss is None:
|
||||
return self._meldung("/erinnerung", "Bitte ein Redaktionsschluss-Datum angeben.", "fehler")
|
||||
|
||||
alt = {
|
||||
"name": ausgabe.name,
|
||||
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
|
||||
}
|
||||
ausgabe.name = roh_name
|
||||
ausgabe.redaktionsschluss = schluss
|
||||
db.commit()
|
||||
neu = {
|
||||
"name": ausgabe.name,
|
||||
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
|
||||
}
|
||||
geaendert = [k for k in alt if alt[k] != neu[k]]
|
||||
if geaendert:
|
||||
self._audit_sync(request, user, "geaendert", ausgabe.id, {
|
||||
"alt": {k: alt[k] for k in geaendert},
|
||||
"neu": {k: neu[k] for k in geaendert},
|
||||
})
|
||||
return self._meldung("/erinnerung", f"Ausgabe „{ausgabe.name}“ gespeichert.")
|
||||
|
||||
@self.router.post("/erinnerung/ausgaben/{ausgabe_id}/loeschen")
|
||||
def ausgabe_loeschen(
|
||||
request: Request,
|
||||
ausgabe_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_roles(Role.ADMIN.value)),
|
||||
):
|
||||
self._merke_registry(request.app.state.registry)
|
||||
ausgabe = db.get(Ausgabe, ausgabe_id)
|
||||
if ausgabe is None:
|
||||
return self._meldung("/erinnerung", "Ausgabe nicht gefunden.", "fehler")
|
||||
snapshot = {
|
||||
"name": ausgabe.name,
|
||||
"redaktionsschluss": ausgabe.redaktionsschluss.isoformat(),
|
||||
}
|
||||
# Protokollzeilen dieser Ausgabe mitlöschen (FK ohne Cascade).
|
||||
db.execute(
|
||||
delete(ErinnerungsProtokoll).where(
|
||||
ErinnerungsProtokoll.ausgabe_id == ausgabe_id
|
||||
)
|
||||
)
|
||||
db.delete(ausgabe)
|
||||
db.commit()
|
||||
self._audit_sync(request, user, "geloescht", ausgabe_id, snapshot)
|
||||
return self._meldung("/erinnerung", f"Ausgabe „{snapshot['name']}“ gelöscht.")
|
||||
|
||||
@self.router.post("/erinnerung/pruefen")
|
||||
async def jetzt_pruefen(
|
||||
request: Request,
|
||||
user: User = Depends(require_roles(Role.ADMIN.value)),
|
||||
):
|
||||
"""Manueller Durchlauf (gleiche Logik wie der tägliche Job)."""
|
||||
self._merke_registry(request.app.state.registry)
|
||||
bericht = await self.pruefe_und_erinnere()
|
||||
return self._meldung("/erinnerung", f"Prüfung abgeschlossen: {bericht.als_text()}")
|
||||
|
||||
# ---------- Audit (synchrone Routen → log_sync des audit-log-Plugins) ----------
|
||||
|
||||
def _audit_sync(self, request: Request, actor, action: str, objekt_id, details: dict) -> None:
|
||||
audit = request.app.state.registry.get("audit-log")
|
||||
if audit is None:
|
||||
return
|
||||
try:
|
||||
audit.log_sync(actor, action, "ausgabe", objekt_id, details)
|
||||
except Exception:
|
||||
_logger.exception("Audit-Log-Eintrag für Ausgabe fehlgeschlagen.")
|
||||
|
||||
|
||||
plugin = ErinnerungPlugin()
|
||||
|
||||
148
plugins/erinnerung/dienst.py
Normal file
148
plugins/erinnerung/dienst.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Geschäftslogik des Plugins „erinnerung“.
|
||||
|
||||
Alle Regeln liegen bewusst hier (und nicht im Kern):
|
||||
|
||||
- **Fenster:** Vier Wochen (28 Tage) vor dem Redaktionsschluss öffnet das
|
||||
Erinnerungsfenster; es gilt bis zum Redaktionsschluss (inklusive). Danach
|
||||
wird nicht mehr erinnert — vor dem Fenster erst recht nicht.
|
||||
- **Empfänger:** alle aktiven Rezensenten (Rolle „rezensent“), die noch
|
||||
offene Planungseinträge haben (Status ≠ „abgeschlossen“, also „offen“
|
||||
oder „in Bearbeitung“) oder gar keine Einträge. Wer alles abgeschlossen
|
||||
hat, wird nicht erinnert; Admins und Redakteure nie.
|
||||
- **Uhr:** `heute()` liefert das aktuelle Datum und ist die einzige
|
||||
Zeitquelle der Prüfung — Tests frieren sie ein (freezegun oder
|
||||
Monkeypatch), der geplante Job und die Routen nutzen sie unverändert.
|
||||
|
||||
Die Planungsliste wird nicht importiert, sondern über die gemeinsame
|
||||
SQLAlchemy-Metadata angesprochen (wie im planung-Plugin mit der
|
||||
Neuheiten-Tabelle): Fehlt das planung-Plugin, entfällt die Unterscheidung
|
||||
„hat offene Einträge“ und alle Rezensenten werden erinnert.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from redaktionskern.auth.models import Role, User
|
||||
from redaktionskern.db import Base
|
||||
|
||||
from .models import Ausgabe, ErinnerungsProtokoll
|
||||
|
||||
#: Länge des Erinnerungsfensters in Tagen (4 Wochen vor Redaktionsschluss).
|
||||
ERINNERUNGSFENSTER_TAGE = 4 * 7
|
||||
|
||||
#: Statuswert des planung-Plugins für „fertig“ (Kopie ohne Import-Abhängigkeit).
|
||||
STATUS_ABGESCHLOSSEN = "abgeschlossen"
|
||||
|
||||
|
||||
def heute() -> date:
|
||||
"""Aktuelles Datum — die eingefrierbare Uhr dieses Plugins."""
|
||||
return date.today()
|
||||
|
||||
|
||||
def tage_bis(schluss: date, tag: date) -> int:
|
||||
"""Tage vom Prüfdatum bis zum Redaktionsschluss (negativ = vorbei)."""
|
||||
return (schluss - tag).days
|
||||
|
||||
|
||||
def im_erinnerungsfenster(schluss: date, tag: date) -> bool:
|
||||
"""True, wenn am Prüfdatum erinnert werden darf.
|
||||
|
||||
Fenster: `schluss - 28 Tage` … `schluss` (jeweils inklusive).
|
||||
"""
|
||||
return 0 <= tage_bis(schluss, tag) <= ERINNERUNGSFENSTER_TAGE
|
||||
|
||||
|
||||
def parse_datum(roh: str) -> date | None:
|
||||
"""Parst ein ISO-Datum (JJJJ-MM-TT); None bei leer, ValueError bei Unsinn."""
|
||||
roh = (roh or "").strip()
|
||||
if not roh:
|
||||
return None
|
||||
return date.fromisoformat(roh)
|
||||
|
||||
|
||||
def alle_ausgaben(db: Session) -> list[Ausgabe]:
|
||||
"""Alle Ausgaben, frühester Redaktionsschluss zuerst."""
|
||||
return list(
|
||||
db.scalars(select(Ausgabe).order_by(Ausgabe.redaktionsschluss, Ausgabe.id)).all()
|
||||
)
|
||||
|
||||
|
||||
def faellige_ausgaben(db: Session, tag: date) -> list[Ausgabe]:
|
||||
"""Ausgaben, deren Erinnerungsfenster am Prüfdatum offen ist."""
|
||||
return [
|
||||
ausgabe
|
||||
for ausgabe in alle_ausgaben(db)
|
||||
if im_erinnerungsfenster(ausgabe.redaktionsschluss, tag)
|
||||
]
|
||||
|
||||
|
||||
def bereits_erinnerte_ids(db: Session, ausgabe_id: int) -> set[int]:
|
||||
"""Benutzer, die für diese Ausgabe schon erinnert wurden (Doppelschutz)."""
|
||||
return set(
|
||||
db.scalars(
|
||||
select(ErinnerungsProtokoll.user_id).where(
|
||||
ErinnerungsProtokoll.ausgabe_id == ausgabe_id
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def planungs_statistik(db: Session) -> tuple[set[int], set[int]]:
|
||||
"""Liefert (IDs mit offenen Einträgen, IDs mit Einträgen überhaupt)."""
|
||||
tabelle = Base.metadata.tables.get("planungsliste")
|
||||
if tabelle is None:
|
||||
# planung-Plugin nicht geladen → keine Unterscheidung möglich.
|
||||
return set(), set()
|
||||
offene = {
|
||||
zeile[0]
|
||||
for zeile in db.execute(
|
||||
select(tabelle.c.rezensent_id)
|
||||
.where(tabelle.c.status != STATUS_ABGESCHLOSSEN)
|
||||
.distinct()
|
||||
).all()
|
||||
}
|
||||
alle = {
|
||||
zeile[0]
|
||||
for zeile in db.execute(select(tabelle.c.rezensent_id).distinct()).all()
|
||||
}
|
||||
return offene, alle
|
||||
|
||||
|
||||
def empfaenger(db: Session) -> list[User]:
|
||||
"""Die zu erinnierenden Rezensenten (siehe Moduldokumentation).
|
||||
|
||||
Rezensent mit offenen Einträgen → erinnert.
|
||||
Rezensent ohne jeden Eintrag → erinnert.
|
||||
Rezensent nur mit abgeschlossenen Einträgen → nicht erinnert.
|
||||
"""
|
||||
rezensenten = list(
|
||||
db.scalars(
|
||||
select(User)
|
||||
.where(User.role == Role.REZENSENT.value, User.active.is_(True))
|
||||
.order_by(User.username)
|
||||
).all()
|
||||
)
|
||||
offene, alle = planungs_statistik(db)
|
||||
return [
|
||||
benutzer
|
||||
for benutzer in rezensenten
|
||||
if benutzer.id not in alle or benutzer.id in offene
|
||||
]
|
||||
|
||||
|
||||
def nachricht_text(ausgabe: Ausgabe, tage_rest: int) -> str:
|
||||
"""Text der Erinnerungs-Benachrichtigung (deutsch)."""
|
||||
if tage_rest == 0:
|
||||
rest = "heute ist der Redaktionsschluss"
|
||||
elif tage_rest == 1:
|
||||
rest = "morgen ist der Redaktionsschluss"
|
||||
else:
|
||||
rest = f"der Redaktionsschluss ist in {tage_rest} Tagen"
|
||||
return (
|
||||
f"Ausgabe „{ausgabe.name}“: {rest} ({ausgabe.redaktionsschluss.strftime('%d.%m.%Y')}).\n"
|
||||
"Bitte schließe deine Planungseinträge ab oder trage noch fehlende Titel "
|
||||
"unter „Planung“ nach."
|
||||
)
|
||||
64
plugins/erinnerung/models.py
Normal file
64
plugins/erinnerung/models.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Datenmodelle des Plugins „erinnerung“.
|
||||
|
||||
Zwei eigene Tabellen (SQLite ↔ Postgres portabel), der Kern bleibt unberührt:
|
||||
|
||||
- `erinnerung_ausgabe` — eine Magazin-Ausgabe mit Redaktionsschluss-Datum.
|
||||
Mehrere Ausgaben laufen parallel; der Name ist frei wählbar (z. B. „3/2025“).
|
||||
- `erinnerung_protokoll` — protokolliert jede versendete Erinnerung
|
||||
(Ausgabe + Benutzer). Die Eindeutigkeit von (ausgabe_id, user_id) stellt
|
||||
auf Datenbankebene sicher, dass niemand zweimal pro Ausgabe erinnert wird.
|
||||
|
||||
Keine ORM-Indizes: Bei erneuter Ausführung des Moduls würde ein deklarativ
|
||||
definierter Index doppelt im Table-Objekt landen und die Migration würde ihn
|
||||
zweimal anlegen (gleiche Begründung wie im benachrichtigung-Plugin).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from redaktionskern.db import Base
|
||||
|
||||
|
||||
class Ausgabe(Base):
|
||||
"""Eine Magazin-Ausgabe mit ihrem Redaktionsschluss-Datum."""
|
||||
|
||||
__tablename__ = "erinnerung_ausgabe"
|
||||
__table_args__ = {"extend_existing": True}
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
#: Name/Titel der Ausgabe, z. B. „3/2025“ oder „Sommer-Sonderheft“.
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
#: Redaktionsschluss (Datum, ohne Uhrzeit).
|
||||
redaktionsschluss: Mapped[date] = mapped_column(Date)
|
||||
erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
aktualisiert_am: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
|
||||
return f"<Ausgabe {self.name!r} (Redaktionsschluss {self.redaktionsschluss})>"
|
||||
|
||||
|
||||
class ErinnerungsProtokoll(Base):
|
||||
"""Eine versendete Erinnerung: wer wurde zu welcher Ausgabe wann erinnert."""
|
||||
|
||||
__tablename__ = "erinnerung_protokoll"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("ausgabe_id", "user_id", name="uq_erinnerung_ausgabe_user"),
|
||||
{"extend_existing": True},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
ausgabe_id: Mapped[int] = mapped_column(ForeignKey("erinnerung_ausgabe.id"))
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
#: Zugestellte Kanäle (Komma-liste, z. B. „email,inapp“) — wie vom
|
||||
#: benachrichtigung-Plugin gemeldet; leer, falls kein Kanal zustellte.
|
||||
kanaele: Mapped[str] = mapped_column(String(200), default="")
|
||||
#: Wann wurde die Erinnerung ausgelöst.
|
||||
erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
|
||||
return f"<ErinnerungsProtokoll Ausgabe={self.ausgabe_id} User={self.user_id}>"
|
||||
34
plugins/erinnerung/templates/erinnerung/bearbeiten.html
Normal file
34
plugins/erinnerung/templates/erinnerung/bearbeiten.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %}
|
||||
{% block inhalt %}
|
||||
<h1 class="text-2xl font-bold mb-2">Ausgabe bearbeiten</h1>
|
||||
<p class="text-slate-600 mb-4">„{{ ausgabe.name }}“ · Redaktionsschluss:
|
||||
{{ ausgabe.redaktionsschluss.strftime('%d.%m.%Y') }}
|
||||
</p>
|
||||
|
||||
<form method="post" action="/erinnerung/ausgaben/{{ ausgabe.id }}/bearbeiten"
|
||||
class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-xs font-medium text-slate-500 mb-1">Name/Titel der Ausgabe *</label>
|
||||
<input type="text" id="name" name="name" required value="{{ ausgabe.name }}"
|
||||
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="redaktionsschluss" class="block text-xs font-medium text-slate-500 mb-1">Redaktionsschluss *</label>
|
||||
<input type="date" id="redaktionsschluss" name="redaktionsschluss" required value="{{ ausgabe.redaktionsschluss.isoformat() }}"
|
||||
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-48 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit"
|
||||
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-lg text-sm">
|
||||
Speichern
|
||||
</button>
|
||||
<a href="/erinnerung" class="text-sm text-slate-500 hover:text-slate-800 underline">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="text-xs text-slate-400 mt-3 max-w-2xl">
|
||||
Hinweis: Bereits protokollierte Erinnerungen bleiben bestehen — pro Ausgabe
|
||||
und Benutzer wird nur einmal erinnert.
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -2,9 +2,143 @@
|
||||
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
|
||||
{% block inhalt %}
|
||||
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1>
|
||||
<p class="text-slate-600 max-w-2xl">
|
||||
Plugin <code class="bg-slate-200 rounded px-1 py-0.5 text-sm">{{ name }}</code>
|
||||
in Version {{ version }} ist geladen.
|
||||
Diese Seite ist ein Platzhalter — die Funktion wird in einer späteren Phase implementiert.
|
||||
<p class="text-slate-600 mb-4">
|
||||
Redaktionsschlüsse der kommenden Ausgaben. Vier Wochen vor jedem
|
||||
Redaktionsschluss erhalten alle Rezensenten eine Erinnerung, die noch offene
|
||||
Planungseinträge haben — oder noch gar keine eingetragen haben.
|
||||
</p>
|
||||
|
||||
{% if meldung %}
|
||||
<div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div>
|
||||
{% endif %}
|
||||
{% if fehler %}
|
||||
<div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ist_admin %}
|
||||
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
|
||||
<h2 class="font-semibold mb-3">Neue Ausgabe anlegen</h2>
|
||||
<form method="post" action="/erinnerung/ausgaben" class="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label for="name" class="block text-xs font-medium text-slate-500 mb-1">Name/Titel der Ausgabe *</label>
|
||||
<input type="text" id="name" name="name" required placeholder="z. B. 3/2025"
|
||||
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="redaktionsschluss" class="block text-xs font-medium text-slate-500 mb-1">Redaktionsschluss *</label>
|
||||
<input type="date" id="redaktionsschluss" name="redaktionsschluss" required
|
||||
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-48 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
|
||||
Ausgabe anlegen
|
||||
</button>
|
||||
</form>
|
||||
<p class="text-xs text-slate-400 mt-2">Mehrere Ausgaben können parallel laufen.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm mb-6">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Ausgabe</th>
|
||||
<th class="px-4 py-3 font-medium">Redaktionsschluss</th>
|
||||
<th class="px-4 py-3 font-medium">Restzeit</th>
|
||||
<th class="px-4 py-3 font-medium">Erinnerungen</th>
|
||||
{% if ist_admin %}<th class="px-4 py-3 font-medium">Aktionen</th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for zeile in zeilen %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-medium">{{ zeile.ausgabe.name }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">{{ zeile.ausgabe.redaktionsschluss.strftime('%d.%m.%Y') }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
{% if zeile.tage_rest > fenster_tage %}
|
||||
<span class="inline-block px-2 py-0.5 rounded-full bg-slate-100 text-slate-600 text-xs">
|
||||
noch {{ zeile.tage_rest }} Tage · Erinnerung in {{ zeile.tage_rest - fenster_tage }} Tagen
|
||||
</span>
|
||||
{% elif zeile.im_fenster %}
|
||||
<span class="inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs">
|
||||
Erinnerungsphase — {{ zeile.tage_rest }} Tag{{ 'e' if zeile.tage_rest != 1 }} bis zum Redaktionsschluss
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-block px-2 py-0.5 rounded-full bg-slate-200 text-slate-500 text-xs">Redaktionsschluss vorbei</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-slate-600">
|
||||
{{ zeile.erinnert_anzahl }} erinnert
|
||||
</td>
|
||||
{% if ist_admin %}
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<a href="/erinnerung/ausgaben/{{ zeile.ausgabe.id }}/bearbeiten"
|
||||
class="text-xs border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-2 py-1 rounded-lg whitespace-nowrap">Bearbeiten</a>
|
||||
<form method="post" action="/erinnerung/ausgaben/{{ zeile.ausgabe.id }}/loeschen">
|
||||
<button type="submit" class="text-xs border border-red-200 text-red-600 hover:bg-red-50 px-2 py-1 rounded-lg whitespace-nowrap">Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="{{ 5 if ist_admin else 4 }}" class="px-4 py-8 text-center text-slate-400">
|
||||
Noch keine Ausgaben angelegt.
|
||||
{% if ist_admin %}Lege oben die erste Ausgabe mit ihrem Redaktionsschluss an.{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if ist_admin %}
|
||||
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6 flex flex-wrap items-center gap-3">
|
||||
<div class="mr-auto">
|
||||
<h2 class="font-semibold">Sofort prüfen</h2>
|
||||
<p class="text-xs text-slate-500">Führt denselben Check aus wie der tägliche Hintergrund-Job ({{ fenster_tage }} Tage vor dem Redaktionsschluss). Doppelerinnerungen sind ausgeschlossen.</p>
|
||||
</div>
|
||||
<form method="post" action="/erinnerung/pruefen">
|
||||
<button type="submit"
|
||||
class="bg-sky-600 hover:bg-sky-700 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
|
||||
Jetzt prüfen und erinnern
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
|
||||
<h2 class="font-semibold mb-3">Erinnerungsstatus</h2>
|
||||
<p class="text-xs text-slate-500 mb-3">Wer wurde für welche Ausgabe wann erinnert? Pro Ausgabe und Benutzer wird höchstens einmal erinnert.</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Ausgabe</th>
|
||||
<th class="px-4 py-3 font-medium">Rezensent</th>
|
||||
<th class="px-4 py-3 font-medium">Kanäle</th>
|
||||
<th class="px-4 py-3 font-medium">Erinnert am</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for eintrag in protokoll %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-medium">{{ eintrag.ausgabe_name }}</td>
|
||||
<td class="px-4 py-3">{{ eintrag.benutzer }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
{% if eintrag.eintrag.kanaele %}{{ eintrag.eintrag.kanaele.replace(',', ', ') }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-slate-500">
|
||||
{{ eintrag.eintrag.erstellt_am.strftime('%d.%m.%Y %H:%M') if eintrag.eintrag.erstellt_am else '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="px-4 py-8 text-center text-slate-400">Noch keine Erinnerungen versendet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user