Plugin erinnerung: Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung

- 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)
This commit is contained in:
Flo Hartmann
2026-08-21 20:31:13 +00:00
parent 0f12cd952b
commit 469cf8826e
11 changed files with 1549 additions and 23 deletions

View File

@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %}
{% block inhalt %}
<h1 class="text-2xl font-bold mb-2">Ausgabe bearbeiten</h1>
<p class="text-slate-600 mb-4">&bdquo;{{ ausgabe.name }}&ldquo; &middot; Redaktionsschluss:
{{ ausgabe.redaktionsschluss.strftime('%d.%m.%Y') }}
</p>
<form method="post" action="/erinnerung/ausgaben/{{ ausgabe.id }}/bearbeiten"
class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4">
<div>
<label for="name" class="block text-xs font-medium text-slate-500 mb-1">Name/Titel der Ausgabe *</label>
<input type="text" id="name" name="name" required value="{{ ausgabe.name }}"
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label for="redaktionsschluss" class="block text-xs font-medium text-slate-500 mb-1">Redaktionsschluss *</label>
<input type="date" id="redaktionsschluss" name="redaktionsschluss" required value="{{ ausgabe.redaktionsschluss.isoformat() }}"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-48 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div class="flex items-center gap-3">
<button type="submit"
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4 py-2 rounded-lg text-sm">
Speichern
</button>
<a href="/erinnerung" class="text-sm text-slate-500 hover:text-slate-800 underline">Abbrechen</a>
</div>
</form>
<p class="text-xs text-slate-400 mt-3 max-w-2xl">
Hinweis: Bereits protokollierte Erinnerungen bleiben bestehen &mdash; pro Ausgabe
und Benutzer wird nur einmal erinnert.
</p>
{% endblock %}

View File

@@ -2,9 +2,143 @@
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block 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 &mdash; die Funktion wird in einer sp&auml;teren Phase implementiert.
<p class="text-slate-600 mb-4">
Redaktionsschl&uuml;sse der kommenden Ausgaben. Vier Wochen vor jedem
Redaktionsschluss erhalten alle Rezensenten eine Erinnerung, die noch offene
Planungseinträge haben &mdash; 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&ouml;nnen parallel laufen.</p>
</div>
{% endif %}
<div class="overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm mb-6">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Ausgabe</th>
<th class="px-4 py-3 font-medium">Redaktionsschluss</th>
<th class="px-4 py-3 font-medium">Restzeit</th>
<th class="px-4 py-3 font-medium">Erinnerungen</th>
{% if ist_admin %}<th class="px-4 py-3 font-medium">Aktionen</th>{% endif %}
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for zeile in zeilen %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-medium">{{ zeile.ausgabe.name }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ zeile.ausgabe.redaktionsschluss.strftime('%d.%m.%Y') }}</td>
<td class="px-4 py-3 whitespace-nowrap">
{% if zeile.tage_rest > fenster_tage %}
<span class="inline-block px-2 py-0.5 rounded-full bg-slate-100 text-slate-600 text-xs">
noch {{ zeile.tage_rest }} Tage &middot; Erinnerung in {{ zeile.tage_rest - fenster_tage }} Tagen
</span>
{% elif zeile.im_fenster %}
<span class="inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs">
Erinnerungsphase &mdash; {{ zeile.tage_rest }} Tag{{ 'e' if zeile.tage_rest != 1 }} bis zum Redaktionsschluss
</span>
{% else %}
<span class="inline-block px-2 py-0.5 rounded-full bg-slate-200 text-slate-500 text-xs">Redaktionsschluss vorbei</span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-slate-600">
{{ zeile.erinnert_anzahl }} erinnert
</td>
{% if ist_admin %}
<td class="px-4 py-3">
<div class="flex flex-wrap items-center gap-1.5">
<a href="/erinnerung/ausgaben/{{ zeile.ausgabe.id }}/bearbeiten"
class="text-xs border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-2 py-1 rounded-lg whitespace-nowrap">Bearbeiten</a>
<form method="post" action="/erinnerung/ausgaben/{{ zeile.ausgabe.id }}/loeschen">
<button type="submit" class="text-xs border border-red-200 text-red-600 hover:bg-red-50 px-2 py-1 rounded-lg whitespace-nowrap">L&ouml;schen</button>
</form>
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr>
<td colspan="{{ 5 if ist_admin else 4 }}" class="px-4 py-8 text-center text-slate-400">
Noch keine Ausgaben angelegt.
{% if ist_admin %}Lege oben die erste Ausgabe mit ihrem Redaktionsschluss an.{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if ist_admin %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6 flex flex-wrap items-center gap-3">
<div class="mr-auto">
<h2 class="font-semibold">Sofort pr&uuml;fen</h2>
<p class="text-xs text-slate-500">F&uuml;hrt denselben Check aus wie der t&auml;gliche Hintergrund-Job ({{ fenster_tage }} Tage vor dem Redaktionsschluss). Doppelerinnerungen sind ausgeschlossen.</p>
</div>
<form method="post" action="/erinnerung/pruefen">
<button type="submit"
class="bg-sky-600 hover:bg-sky-700 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
Jetzt pr&uuml;fen und erinnern
</button>
</form>
</div>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<h2 class="font-semibold mb-3">Erinnerungsstatus</h2>
<p class="text-xs text-slate-500 mb-3">Wer wurde f&uuml;r welche Ausgabe wann erinnert? Pro Ausgabe und Benutzer wird h&ouml;chstens einmal erinnert.</p>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Ausgabe</th>
<th class="px-4 py-3 font-medium">Rezensent</th>
<th class="px-4 py-3 font-medium">Kan&auml;le</th>
<th class="px-4 py-3 font-medium">Erinnert am</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for eintrag in protokoll %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-medium">{{ eintrag.ausgabe_name }}</td>
<td class="px-4 py-3">{{ eintrag.benutzer }}</td>
<td class="px-4 py-3 text-slate-600">
{% if eintrag.eintrag.kanaele %}{{ eintrag.eintrag.kanaele.replace(',', ', ') }}{% else %}&mdash;{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-slate-500">
{{ eintrag.eintrag.erstellt_am.strftime('%d.%m.%Y %H:%M') if eintrag.eintrag.erstellt_am else '&mdash;' }}
</td>
</tr>
{% else %}
<tr><td colspan="4" class="px-4 py-8 text-center text-slate-400">Noch keine Erinnerungen versendet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock %}