Plugin archiv: 12-Monats-Autopilot mit Sicherungsdatei-Prinzip

- Eigene Migration 0001_archiv_tabellen: Spiegel-Tabellen archiv_neuheiten
  und archiv_planung (ohne FK, damit Archiv-Zeilen Benutzerlöschung überleben)
- Service mit eingefrierbarer Uhr: Stichtag „jetzt minus 12 Monate“ in UTC,
  strenger Vergleich (genau 12 Monate bleibt aktiv); Neuheiten nach
  Erscheinungsjahr (großzügig Jahresende) sonst Eintragsdatum, Planung nach
  Eintragsdatum; Monatsarithmetik mit Klemmung (29. Februar)
- Täglicher APScheduler-Cron-Job (Standard 03:00, konfigurierbar über
  SPIELE_ARCHIV_JOB_UHRZEIT / SPIELE_ARCHIV_JOB_AKTIV); je archiviertem
  Titel ein Audit-Log-Eintrag als System
- Admin-Ansicht /archiv: vereinigte Liste beider Tabellen, Suche über
  Titel/Verlag/Autor, Herkunftsfilter, Wiederherstellen in die aktive Liste
  (BGG-Konflikt wird abgelehnt, verwaiste Rezensentenzuordnung übernimmt der
  Admin — gemeldet und auditiert); Rollen: nur Admin
- Kern minimal erweitert: PluginContext.registry stellt Hintergrund-Jobs die
  Plugin-Registry bereit (keine Fachlogik im Kern)
- 30 neue Tests mit eingefrorener Uhr (Grenzfälle, Zeitzonen, Schaltjahr,
  Job-Lauf, Wiederherstellen, Rollen, Suche/Filter); 171 Tests grün
- README aktualisiert: neues Plugin-Kapitel, Env-Variablen, Fortschritt #7 
This commit is contained in:
Flo Hartmann
2026-08-21 20:26:00 +00:00
parent 0f12cd952b
commit 72fe3b7eda
10 changed files with 1727 additions and 25 deletions

View File

@@ -2,9 +2,120 @@
{% 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 max-w-3xl mb-4">
Sicherungsdatei der Redaktion: Titel, deren Erscheinungs- bzw. Eintragsdatum
mehr als 12 Monate zur&uuml;ckliegt, werden t&auml;glich automatisch aus den
aktiven Listen hierher verschoben. Es geht nichts verloren &mdash; jeder
Eintrag l&auml;sst sich unver&auml;ndert zur&uuml;ck in die aktive Liste verschieben.
</p>
<div class="flex items-center gap-4 mb-4 text-xs text-slate-500">
<span class="inline-flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full {{ 'bg-emerald-500' if job_aktiv else 'bg-slate-300' }}"></span>
T&auml;glicher Archiv-Job: {{ 'aktiv' if job_aktiv else 'aus' }}
</span>
<span>{{ anzahl_neuheiten }} archivierte Neuheiten</span>
<span>{{ anzahl_planung }} archivierte Planungseintr&auml;ge</span>
</div>
{% 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 %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<form method="get" action="/archiv" class="flex flex-wrap items-end gap-3">
<div>
<label for="q" class="block text-xs font-medium text-slate-500 mb-1">Suche (Titel, Verlag, Autor)</label>
<input type="text" id="q" name="q" value="{{ q }}"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-72 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div>
<div>
<label for="quelle" class="block text-xs font-medium text-slate-500 mb-1">Herkunft</label>
<select id="quelle" name="quelle"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
onchange="this.form.submit()">
<option value="">Alle Listen</option>
<option value="neuheiten" {% if quelle_filter == 'neuheiten' %}selected{% endif %}>Neuheiten</option>
<option value="planung" {% if quelle_filter == 'planung' %}selected{% endif %}>Planung</option>
</select>
</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">
Suchen
</button>
{% if q or quelle_filter %}
<a href="/archiv" class="text-sm text-slate-500 hover:text-emerald-700 px-2 py-2">Zur&uuml;cksetzen</a>
{% endif %}
</form>
</div>
<p class="text-xs text-slate-400 mb-3">{{ anzahl }} Eintr&auml;ge im Archiv</p>
<div class="overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm">
<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">Spieltitel</th>
<th class="px-4 py-3 font-medium">Herkunft</th>
<th class="px-4 py-3 font-medium">Verlag</th>
<th class="px-4 py-3 font-medium">Jahr / Ausgabe</th>
<th class="px-4 py-3 font-medium">Status</th>
<th class="px-4 py-3 font-medium">Rezensent</th>
<th class="px-4 py-3 font-medium">Archiviert am</th>
<th class="px-4 py-3 font-medium">Grund</th>
<th class="px-4 py-3 font-medium">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for eintrag in eintraege %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-medium">
{{ eintrag.titel }}
{% if eintrag.bgg_id %}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap">
{% if eintrag.quelle == 'neuheiten' %}
<span class="inline-block px-2 py-0.5 rounded-full bg-sky-100 text-sky-800 text-xs">Neuheiten</span>
{% else %}
<span class="inline-block px-2 py-0.5 rounded-full bg-violet-100 text-violet-800 text-xs">Planung</span>
{% endif %}
</td>
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ eintrag.jahr if eintrag.jahr else (eintrag.ausgabe or '—') }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ status_anzeige.get(eintrag.status, eintrag.status) }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ namen.get(eintrag.rezensent_id, '—') if eintrag.rezensent_id else '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap text-slate-500">
{{ eintrag.archiviert_am.strftime('%d.%m.%Y %H:%M') if eintrag.archiviert_am else '—' }}
</td>
<td class="px-4 py-3 text-slate-500">{{ eintrag.grund }}</td>
<td class="px-4 py-3">
<form method="post" action="/archiv/{{ eintrag.quelle }}/{{ eintrag.id }}/wiederherstellen">
<button type="submit"
class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2.5 py-1.5 rounded-lg whitespace-nowrap"
title="Eintrag unverändert zurück in die aktive Liste verschieben">
&#8635; Wiederherstellen
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="9" class="px-4 py-8 text-center text-slate-400">
{% if q or quelle_filter %}
Keine Treffer f&uuml;r diese Suche.
{% else %}
Das Archiv ist leer — nichts zu sehen.
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}