Compare commits

..

24 Commits

Author SHA1 Message Date
Flo Hartmann
49f3b2d2fb Docs: README um Dashboard-Karten, Nav-Badges und UI-Makros ergänzt 2026-08-26 12:34:16 +00:00
Flo Hartmann
968333bf45 P2: Typografie-Hierarchie — Seiten-/Karten-/Tabellen-Header
- h1: text-2xl font-bold text-slate-900
- Karten-h2: text-base font-semibold text-slate-800
- th: text-xs font-medium uppercase tracking-wider text-slate-500
2026-08-26 09:12:22 +00:00
Flo Hartmann
f90c0de46d P2: Ungelesen-Badge in der Hauptnavigation
- Plugin-Vertrag: nav_badges(user_id) -> {URL: Anzahl}
- Kern-Middleware sammelt Badges pro Anfrage in request.state.nav_badges
- benachrichtigung-Plugin meldet die ungelesene Anzahl
- Rotes Zähler-Badge am Nav-Eintrag (Desktop + Mobil), nur wenn > 0
2026-08-26 09:08:24 +00:00
Flo Hartmann
d6ed8fa56e P2: Einheitliche Fokus-Ringe global
- .focus-ring Utility via Tailwind-Play-CDN (@apply) in base.html
- Globaler :focus-visible-Fallback (emerald Outline) für alle
  Bedienelemente ohne explizite Fokus-Klassen
- Benutzer-Formular und Audit-Log-Filter nutzen .focus-ring
2026-08-26 09:04:45 +00:00
Flo Hartmann
fee5f8b9a3 P1: Tabellen — Sticky-Header, größere mobile Cover, einklappbare Audit-Details
- Desktop-Tabellen (Neuheiten, Planung, Archiv, Audit-Log): Scrollbox
  max-h-70vh mit sticky thead
- Mobile Cover jetzt min. 48x68 px (bereits im Icon-Commit umgesetzt)
- Audit-Log: Alt/Neu/Details-JSON per nativem <details> einklappbar,
  monospace 11px
2026-08-26 09:03:21 +00:00
Flo Hartmann
7645f6a791 P1: Einheitliche klassische Pagination (Neuheiten, Planung, Audit-Log)
- Infinite Scroll ersetzt durch Zurück/Weiter + Seitenzahlen-Fenster
  (funktioniert ohne JavaScript)
- Gemeinsames Makro seitennavigation.html (aria-current auf aktiver
  Seite, deaktivierte Buttons als span)
- Neuheiten: Pagination bleibt beim HTMX-Filter-Swap innerhalb von
  #neuheiten-liste konsistent; Filter/Sortierung/Jahr bleiben in den
  Seiten-URLs erhalten
- Audit-Log: gleiche Optik statt reiner Text-Links
2026-08-26 08:59:49 +00:00
Flo Hartmann
117b34653c P1: Aktiver Navigationszustand (aria-current + visuell)
Desktop: emerald Unterstrich + font-semibold auf dem aktiven Eintrag;
mobil: fett + emerald. request.url.path steuert den Zustand, kein JS
nötig. Mobiles Menü schließt weiterhin nach jedem Link-Klick.
2026-08-26 08:51:44 +00:00
Flo Hartmann
bba39525a0 P1: Login-Seite gruppiert + Passwort-Anzeigen-Toggle
- Karte und BGG-Logo in einer Spalte gruppiert (vorher Flex-Zeile —
  Logo stand neben der Karte)
- Alpine-Toggle 'Passwort anzeigen/verbergen' (statisch type=password,
  funktioniert ohne JS sicher)
- Fokus-Ringe auf Feldern und Button
2026-08-26 08:50:44 +00:00
Flo Hartmann
9bd3b700c4 P1: Rollenbasiertes Dashboard mit Plugin-Karten
- Plugin-Vertrag erweitert: optionaler Hook dashboard_karten(user) +
  DashboardKarte-Dataclass (Kern bleibt schlank, Fachlogik im Plugin)
- Karten: Neuheiten+System-Status (neuheiten), Planungs-Stats bzw.
  eigene Einträge (planung), offene Dedup-Konflikte (dedup), nächste
  Redaktionsschlüsse (erinnerung), ungelesene Benachrichtigungen
  (benachrichtigung), letzte Audit-Einträge (audit-log, nur Admin),
  Benutzer-Anzahl (Kern, nur Admin)
- 'Geladene Plugins' als aufklappbarer Admin-Technikbereich
2026-08-26 08:48:30 +00:00
Flo Hartmann
742c265df8 P0: Formular-Accessibility — Fehlerboxen mit role=alert, Pflichtfeld-ARIA
- Alle fehler-Boxen: role=alert + tabindex=-1
- Login: aria-invalid/aria-describedby auf Feldern bei Fehler
- Pflichtfelder: aria-required=true ergänzt
2026-08-26 08:40:45 +00:00
Flo Hartmann
b9444289b5 P0: Status-Badge-Farben semantisch vereinheitlicht
Zentrales status_badge-Makro (status.html): neuheit/neu+abgeschlossen =
emerald, offen = slate, in_bearbeitung/planung = sky, warnung/konflikt/
pruefung = amber mit Warn-Icon, fehler = red, deaktiviert/archiviert =
slate. Angewendet in neuheiten, planung, archiv, dedup, Benutzerliste.
2026-08-26 08:38:56 +00:00
Flo Hartmann
14a4846948 P0: Lösch-Aktionen mit Bestätigungsdialog (Planung + Erinnerungen)
Alpine-Modal 'Eintrag löschen?' mit Abbrechen/Löschen; ohne JavaScript
sendet das Formular wie bisher direkt (Progressive Enhancement).
benutzer/liste.html hat keine Lösch-Aktion — nichts zu tun.
2026-08-26 08:35:18 +00:00
Flo Hartmann
e1ef7ba926 P0: x-cloak aktiv + Emojis durch Inline-SVG-Icons ersetzt
- base.html: [x-cloak]-CSS gegen Alpine-Flackern
- Neues zentrales Icon-Makro-Template (icons.html, Heroicons-Stil,
  currentColor, aria-hidden)
- Logo-Würfelbecher, Sync-Pfeile, Sortier-Chevrons, Warn-/Check-Icons,
  dedup-Karten (Gebäude/Tag/Buch/Dokument), Cover-Platzhalter als SVG
- Cover-Platzhalter: 'Kein Cover'-Text in Großansicht, Mindestgröße
  48x68 px in der Kartenansicht
2026-08-26 08:33:37 +00:00
Flo Hartmann
929cea1887 Fix: dedup BGG-Client nutzt Token; Sync-Filter Jahr+deutscher Titel; Defaults für deutsche Neuerscheinungen 2026-08-25 17:28:35 +00:00
Flo Hartmann
d91402881a Login-Seite: Powered-by-BGG-Logo ergänzt 2026-08-25 12:13:01 +00:00
Flo Hartmann
ca6d94938c BGG: Powered-by-Logo im Footer (Lizenzpflicht) + Erscheinungsjahr-Filter (Standard aktuelles Jahr+) 2026-08-25 12:11:06 +00:00
Flo Hartmann
667196a4ba Web-Quellen: An/Aus-Schalter pro Quelle im UI + Einzel-Quellen-Sync 2026-08-25 01:10:36 +00:00
Flo Hartmann
8b38bc6c89 Cliquenabend: Cover-Bilder aus den Spieldetailseiten übernehmen 2026-08-25 00:55:07 +00:00
Flo Hartmann
199f8b080d Cliquenabend: Release-Feld der Spieldetailseiten parsen (echtes Erscheinungsjahr statt Update-Datum) 2026-08-25 00:44:31 +00:00
Flo Hartmann
aea52d1563 Neuheiten+Planung: Pagination (50/Seite, Infinite Scroll) + quellen_url in Planung mit Quellen-Link 2026-08-24 22:44:18 +00:00
Flo Hartmann
fa453518d4 Spieleinträge: quellen_url speichern + als klickbaren Quellen-Link anzeigen 2026-08-24 21:55:39 +00:00
Flo Hartmann
96d6af8163 Coverbilder (bild_url) + responsives Layout für Mobil/Tablet
Teil 1 — Coverbilder:
- Migration 0004_bild_url (neuheiten), 0002_bild_url (planung, archiv
  mit beiden Spiegel-Tabellen); nullable VARCHAR(500), SQLite↔Postgres
  portabel, bestehende Zeilen bleiben erhalten
- BGG-Client extrahiert Cover aus der Thing-Antwort (bevorzugt <image>,
  sonst <thumbnail>; protokoll-relative URLs → https)
- Sync-Upsert pflegt bild_url beim Update; fehlendes Bild löscht keines
- Web-Quellen übernehmen img-URLs aus den Listenelementen (src/data-src,
  relative Pfade gegen die Seiten-URL aufgelöst); SPIEL-Essen nutzt das
  BILDER-Feld des JSON-Endpunkts
- Verschiebung Neuheiten→Planung und Archivierung/Wiederherstellen
  schreiben bild_url mit
- Anzeige: kleine Vorschaubilder (40x56) in den Listen, groß (max 200px)
  in Planung-Bearbeiten/Prüf-Dialog; Platzhalter-Div ohne Bild;
  Hotlinking mit loading=lazy + decoding=async

Teil 2 — Responsives Layout:
- Basislayout: Burger-Menü (Alpine.js) auf Mobil, Desktop-Leiste ab md:
- Listen kollabieren <768px zu Karten-Ansichten (Cover links, Titel +
  Metadaten rechts, Status-Badge oben); Desktop behält Tabellenform
- Touch-Ziele >= 44px auf Mobil, Formulare vollbreit mit Labels oben
- Sync-Übersicht/Audit-Log bleiben horizontal scrollbare Tabellen

Tests: 15 neue Fälle (tests/test_covers.py) — Migration inkl.
Daten-Erhalt, BGG-Parsing gemockt, Quellen-Übernahme, Template-Render;
316 Tests grün. README aktualisiert.
2026-08-24 16:54:56 +00:00
Flo Hartmann
20b84a8e1a TODO: alle Punkte abgeschlossen 2026-08-23 01:13:02 +00:00
Flo Hartmann
f27940db8e Plugin neuheiten: optionale LLM-Hilfsstufe für den Web-Quellen-Crawler
Neues Modul quellen/ki_hilfe.py (konsistent zum dedup-LLM-Muster):
- Struktur-Erkennung: bei verdächtig leerem Parser-Ergebnis schlägt das
  LLM Pagination-/Filter-Folge-URLs vor; hart gefiltert auf gleiche
  Domain, max. 20 je Seite, nicht erreichbare Vorschläge brechen den
  Lauf nicht ab.
- Feld-Extraktion: nur unsichere Treffer (fehlender Verlag, verdächtiger
  Titel); korrigiert ausschließlich titel/verlag/autor, niemals die URL.
- Env-Konfiguration SPIELE_NEUHEITEN_KI_* (AKTIV default 0,
  KONFIDENZ_MIN default 0.7), OpenAI-kompatible Chat-Completions via
  httpx mit Timeout und genau einem Retry.
- Fallback-Pflicht: ohne Konfiguration oder bei jedem Fehler läuft exakt
  der klassische Crawler; KI-Fehler blockieren den Sync nie.
- Audit: KI-Eingriffe je Quelle und Lauf ins audit-log (Modell,
  Konfidenz, korrigierte Felder, verfolgte Folge-URLs).

20 neue Tests (gemocktes HTTP/LLM): Fallback-Fälle, Konfidenz-Schwelle,
Domain-Filter, Max-20-Grenze, URL-Unveränderbarkeit, Audit.
2026-08-23 01:07:18 +00:00
62 changed files with 4033 additions and 313 deletions

9
.env Normal file
View File

@@ -0,0 +1,9 @@
SPIELE_SESSION_SECRET=48ebf7ddae261a931652b5a55cff708aa21cf4936a8bdeb287eeac7cec4ee3e2
SPIELE_INITIAL_ADMIN_PASSWORD=3fb93f84014ff45406b36ec3
SPIELE_BGG_TOKEN=6fa6f9f6-32fd-443d-a5c3-ac2527f6a6fc
# BGG-Sync: deutsche Neuerscheinungen ab aktuellem Jahr
SPIELE_BGG_SUCHBEGRIFFE=brettspiel,gesellschaftsspiel,familienspiel,strategiespiel,kartenspiel,würfelspiel
SPIELE_BGG_MAX_TREFFER_PRO_SUCHE=50
SPIELE_BGG_JAHR_FILTER=1
SPIELE_BGG_DEUTSCHE_TITEL_FILTER=1

152
README.md
View File

@@ -55,9 +55,11 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt:
| `SPIELE_TELEGRAM_BOT_TOKEN` | *(leer)* | Bot-Token für den Telegram-Kanal; leer = Dev-Fallback (nur Protokoll) | | `SPIELE_TELEGRAM_BOT_TOKEN` | *(leer)* | Bot-Token für den Telegram-Kanal; leer = Dev-Fallback (nur Protokoll) |
| `SPIELE_BGG_SYNC_AKTIV` | `1` | Hintergrund-Sync des Neuheiten-Plugins an (`1`) oder aus (`0`) | | `SPIELE_BGG_SYNC_AKTIV` | `1` | Hintergrund-Sync des Neuheiten-Plugins an (`1`) oder aus (`0`) |
| `SPIELE_BGG_SYNC_INTERVALL_STUNDEN` | `24` | Intervall des BGG-Syncs in Stunden (min. 1) | | `SPIELE_BGG_SYNC_INTERVALL_STUNDEN` | `24` | Intervall des BGG-Syncs in Stunden (min. 1) |
| `SPIELE_BGG_SUCHBEGRIFFE` | `brettspiel` | Komma-getrennte Suchbegriffe für den regelmäßigen Sync | | `SPIELE_BGG_SUCHBEGRIFFE` | `brettspiel, gesellschaftsspiel, familienspiel, strategiespiel, kartenspiel, würfelspiel` | Komma-getrennte Suchbegriffe für den regelmäßigen Sync (deutsche Spielbegriffe) |
| `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `25` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) | | `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `50` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) |
| `SPIELE_BGG_TOKEN` | *(leer)* | API-Token für die BGG-XML-API2, wird als `Authorization: Bearer …`-Header gesendet; seit der Token-Pflicht von BGG erforderlich (siehe [BGG-Thread 3602374](https://boardgamegeek.com/thread/3602374)) — ohne Token wird der Sync übersprungen | | `SPIELE_BGG_JAHR_FILTER` | `1` | Nur Spiele mit Erscheinungsjahr ≥ aktuelles Jahr übernehmen (`1`) oder alle (`0`) |
| `SPIELE_BGG_DEUTSCHE_TITEL_FILTER` | `1` | Nur Spiele mit deutsch klingendem Titel übernehmen (`1`) oder alle (`0`) |
| `SPIELE_BGG_TOKEN` | *(leer)* | API-Token für die BGG-XML-API2, wird als `Authorization: Bearer *** gesendet; seit der Token-Pflicht von BGG erforderlich (siehe [BGG-Thread 3602374](https://boardgamegeek.com/thread/3602374)) — ohne Token wird der Sync übersprungen |
| `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG-Zusatzdaten für die Dedup-Prüfung an (`1`) oder aus (`0`): Alternate-Names und Erweiterungs-Relationen | | `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG-Zusatzdaten für die Dedup-Prüfung an (`1`) oder aus (`0`): Alternate-Names und Erweiterungs-Relationen |
| `SPIELE_DEDUP_LLM_AKTIV` | `0` | LLM-Zweitprüfung der Dedup-Grenzfälle an (`1`) oder aus (`0`) | | `SPIELE_DEDUP_LLM_AKTIV` | `0` | LLM-Zweitprüfung der Dedup-Grenzfälle an (`1`) oder aus (`0`) |
| `SPIELE_DEDUP_LLM_BASIS_URL` | *(leer)* | Basis-URL einer OpenAI-kompatiblen Chat-Completions-API, z. B. `https://api.openai.com/v1` | | `SPIELE_DEDUP_LLM_BASIS_URL` | *(leer)* | Basis-URL einer OpenAI-kompatiblen Chat-Completions-API, z. B. `https://api.openai.com/v1` |
@@ -65,6 +67,12 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt:
| `SPIELE_DEDUP_LLM_MODELL` | *(leer)* | Modellname, z. B. `gpt-4o-mini` | | `SPIELE_DEDUP_LLM_MODELL` | *(leer)* | Modellname, z. B. `gpt-4o-mini` |
| `SPIELE_DEDUP_LLM_KONFIDENZ_MIN` | `0.7` | Mindest-Konfidenz; darunter wird das LLM-Ergebnis verworfen und nur der Regelbefund angezeigt | | `SPIELE_DEDUP_LLM_KONFIDENZ_MIN` | `0.7` | Mindest-Konfidenz; darunter wird das LLM-Ergebnis verworfen und nur der Regelbefund angezeigt |
| `SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN` | `20` | Timeout je LLM-Aufruf; Netzwerk-/Server-Fehler werden genau einmal wiederholt | | `SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN` | `20` | Timeout je LLM-Aufruf; Netzwerk-/Server-Fehler werden genau einmal wiederholt |
| `SPIELE_NEUHEITEN_KI_AKTIV` | `0` | Optionale LLM-Hilfsstufe des Web-Quellen-Crawlers an (`1`) oder aus (`0`) |
| `SPIELE_NEUHEITEN_KI_BASIS_URL` | *(leer)* | Basis-URL einer OpenAI-kompatiblen Chat-Completions-API, z. B. `https://api.openai.com/v1` |
| `SPIELE_NEUHEITEN_KI_API_KEY` | *(leer)* | API-Key, wird als `Authorization: Bearer …` gesendet |
| `SPIELE_NEUHEITEN_KI_MODELL` | *(leer)* | Modellname, z. B. `gpt-4o-mini` |
| `SPIELE_NEUHEITEN_KI_KONFIDENZ_MIN` | `0.7` | Mindest-Konfidenz; darunter wird das KI-Ergebnis verworfen und der klassische Parser-Befund behalten |
| `SPIELE_NEUHEITEN_KI_TIMEOUT_SEKUNDEN` | `20` | Timeout je LLM-Aufruf; Netzwerk-/Server-Fehler werden genau einmal wiederholt |
| `SPIELE_ARCHIV_JOB_AKTIV` | `1` | Täglicher Archivierungs-Job an (`1`) oder aus (`0`) | | `SPIELE_ARCHIV_JOB_AKTIV` | `1` | Täglicher Archivierungs-Job an (`1`) oder aus (`0`) |
| `SPIELE_ARCHIV_JOB_UHRZEIT` | `03:00` | Tageszeit des täglichen Archiv-Laufs im Format `HH:MM` | | `SPIELE_ARCHIV_JOB_UHRZEIT` | `03:00` | Tageszeit des täglichen Archiv-Laufs im Format `HH:MM` |
| `SPIELE_ERINNERUNG_JOB_AKTIV` | `1` | Täglicher Erinnerungs-Check an (`1`) oder aus (`0`) | | `SPIELE_ERINNERUNG_JOB_AKTIV` | `1` | Täglicher Erinnerungs-Check an (`1`) oder aus (`0`) |
@@ -90,6 +98,11 @@ Fake-BGG-Client, Prüfprotokoll, Prüfseite) und **planung** (beide Eintragswege
mit Stub-gemockter dedup-/Benachrichtigungs-/Audit-API, Verlags-Auswahl- mit Stub-gemockter dedup-/Benachrichtigungs-/Audit-API, Verlags-Auswahl-
Dialog, Statuswechsel, Bearbeiten/Löschen, Rechte pro Rolle) plus ein Dialog, Statuswechsel, Bearbeiten/Löschen, Rechte pro Rolle) plus ein
Integrationstest mit den echten Plugins (In-App-Nachricht + Audit-Eintrag). Integrationstest mit den echten Plugins (In-App-Nachricht + Audit-Eintrag).
Dazu die **Coverbilder-Erweiterung**: Migrationen für `bild_url` in allen
vier Tabellen inkl. Daten-Erhalt beim ALTER TABLE, BGG-Bildextraktion
(`<image>` bevorzugt, `<thumbnail>` als Rückfallebene, gemockte Antworten),
Bildübernahme aus Web-Quellen, Update-Pflege des Bildes im Sync-Upsert sowie
Template-Darstellung (`loading="lazy"`/`decoding="async"` bzw. Platzhalter).
Dazu das **archiv**-Plugin: 12-Monats-Regel mit eingefrorener Uhr (Grenzfälle Dazu das **archiv**-Plugin: 12-Monats-Regel mit eingefrorener Uhr (Grenzfälle
„genau 12 Monate“, Schaltjahr/29. Februar, UTC-Konsistenz bei „genau 12 Monate“, Schaltjahr/29. Februar, UTC-Konsistenz bei
Zeitzonen-Unterschieden), Job-Lauf mit Audit-Einträgen, Wiederherstellen Zeitzonen-Unterschieden), Job-Lauf mit Audit-Einträgen, Wiederherstellen
@@ -148,7 +161,7 @@ Alternativ: installiertes Paket mit Entry-Point in der Gruppe
```python ```python
from fastapi import Depends, Request from fastapi import Depends, Request
from redaktionskern.auth.deps import require_user from redaktionskern.auth.deps import require_user
from redaktionskern.contracts import BasePlugin, Migration, NavEntry from redaktionskern.contracts import BasePlugin, DashboardKarte, Migration, NavEntry
class MeinPlugin(BasePlugin): class MeinPlugin(BasePlugin):
name = "meinplugin" # eindeutig, = Ordnername name = "meinplugin" # eindeutig, = Ordnername
@@ -161,6 +174,12 @@ class MeinPlugin(BasePlugin):
def navigation(self): # Einträge in der Hauptnavigation (optional) def navigation(self): # Einträge in der Hauptnavigation (optional)
return [NavEntry(label=self.title, url="/meinplugin")] return [NavEntry(label=self.title, url="/meinplugin")]
def dashboard_karten(self, user): # Dashboard-Karten (optional, leer = keine)
return [DashboardKarte(titel="…", wert="…", link_url="/meinplugin")]
def nav_badges(self, user_id): # Zähler-Badges in der Navigation
return {"/meinplugin": 3} # (optional; Werte <= 0 werden ignoriert)
def on_load(self, context): # Lifecycle-Hook beim Start def on_load(self, context): # Lifecycle-Hook beim Start
super().on_load(context) # context.engine/.session_factory/.templates/ super().on_load(context) # context.engine/.session_factory/.templates/
# .settings/.registry (Plugin-Registry für # .settings/.registry (Plugin-Registry für
@@ -174,7 +193,10 @@ plugin = MeinPlugin()
Der Kern stellt jedem Plugin über `context.templates` eine gemeinsame Jinja- Der Kern stellt jedem Plugin über `context.templates` eine gemeinsame Jinja-
Umgebung bereit (Plugin-Templates erben `base.html`), dazu Engine und Umgebung bereit (Plugin-Templates erben `base.html`), dazu Engine und
Session-Fabrik. Migrationen laufen transaktional und werden pro Plugin in Session-Fabrik. Zusätzlich stehen allen Templates die Kern-Makros zur
Verfügung: `icons.html` (Inline-SVG-Icons), `status.html` (`status_badge` —
einheitliche Status-Farben) und `seitennavigation.html` (klassische
Seitennavigation). Migrationen laufen transaktional und werden pro Plugin in
`schema_migrations` protokolliert; die `up`-Funktionen bekommen eine `schema_migrations` protokolliert; die `up`-Funktionen bekommen eine
SQLAlchemy-Connection und können bei Dialekt-Unterschieden verzweigen. SQLAlchemy-Connection und können bei Dialekt-Unterschieden verzweigen.
@@ -264,7 +286,8 @@ manuelle Synchronisation für die Redaktion, deutsche sortier-/filterbare Liste.
### Datentabelle ### Datentabelle
Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten` Eigene Migrationen `0001_neuheiten_tabelle`, `0002_bgg_id_nullable`,
`0003_quellen_status` und `0004_bild_url`; Tabelle `neuheiten`
(SQLite ↔ Postgres portabel): (SQLite ↔ Postgres portabel):
| Spalte | Inhalt | | Spalte | Inhalt |
@@ -273,6 +296,7 @@ Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten`
| `verlag` / `autor` | Verlag bzw. Autor(en), kommagetrennt | | `verlag` / `autor` | Verlag bzw. Autor(en), kommagetrennt |
| `erscheinungsjahr` | Erscheinungsjahr — BGG liefert nur das Jahr, kein genaues Datum | | `erscheinungsjahr` | Erscheinungsjahr — BGG liefert nur das Jahr, kein genaues Datum |
| `bgg_id` | BoardGameGeek-ID, eindeutig → Merge-Kriterium des Syncs | | `bgg_id` | BoardGameGeek-ID, eindeutig → Merge-Kriterium des Syncs |
| `bild_url` | URL des Coverbildes (nullable; BGG-Image/-Thumbnail oder Bild der Web-Quelle) |
| `status` | `neuheit` (spätere Plugins: Planung/Archiv) | | `status` | `neuheit` (spätere Plugins: Planung/Archiv) |
| `quelle` | Datenquelle (`boardgamegeek`) | | `quelle` | Datenquelle (`boardgamegeek`) |
| `erstellt_am` / `aktualisiert_am` | Zeitstempel | | `erstellt_am` / `aktualisiert_am` | Zeitstempel |
@@ -282,6 +306,14 @@ Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten`
Pro Suchbegriff: `/search?type=boardgame&query=…` → IDs sammeln → Pro Suchbegriff: `/search?type=boardgame&query=…` → IDs sammeln →
`/thing?id=…&type=boardgame` in Batches à 20 IDs. `/thing?id=…&type=boardgame` in Batches à 20 IDs.
**Coverbilder:** Aus der Thing-Antwort wird die Bild-URL extrahiert
(bevorzugt das große `<image>`, sonst das kleinere `<thumbnail>`;
protokoll-relative URLs werden auf https normalisiert) und in
`bild_url` gespeichert. Beim Update bestehender Einträge wird das Bild
mitgepflegt; fehlt es in einer Antwort, bleibt ein bereits gespeichertes
Bild erhalten. Bilder werden direkt von BGG eingebettet (Hotlinking,
kein Download) — im UI immer mit `loading="lazy"` und `decoding="async"`.
- **Rate-Limit:** mindestens 1 Sekunde zwischen zwei Requests. - **Rate-Limit:** mindestens 1 Sekunde zwischen zwei Requests.
- **Retry mit Backoff:** exponentielles Zurückhalten bei 5xx, 429 und - **Retry mit Backoff:** exponentielles Zurückhalten bei 5xx, 429 und
Netzwerkfehlern; HTTP 202 (BGG-Warteschlange) wird gemäß `Retry-After` Netzwerkfehlern; HTTP 202 (BGG-Warteschlange) wird gemäß `Retry-After`
@@ -331,6 +363,46 @@ Autor, Erscheinungsjahr, Status, aktualisiert am):
- Jeder Titel verlinkt direkt auf den BGG-Eintrag. - Jeder Titel verlinkt direkt auf den BGG-Eintrag.
- Sync-Steuerung nur für Rolle Admin/Redakteur. - Sync-Steuerung nur für Rolle Admin/Redakteur.
### Web-Quellen-Crawler mit optionaler KI-Hilfsstufe
Neben BGG crawlt das Plugin zusätzlich redaktionell gepflegte Web-Quellen
(spielbox.de, brettspielbox.de, spiel-essen.de, cliquenabend.de) über je einen
Adapter in `plugins/neuheiten/quellen/` — mit Rate-Limit (1 Request/s),
Retry/Backoff, konditionalen Requests (ETag/304), Fehler-Isolation pro Quelle
und Duplikat-Gate gegen die BGG-Liste. Der Crawler arbeitet standardmäßig rein
regelbasiert. Liefert eine Quelle im Listenelement ein Bild (`img` mit `src`
bzw. `data-src`), wird dessen URL in `bild_url` übernommen — sonst bleibt das
Feld leer.
Optional kann eine **LLM-Hilfsstufe** (`plugins/neuheiten/quellen/ki_hilfe.py`)
zugeschaltet werden — konsistent zur Dedup-KI über die
`SPIELE_NEUHEITEN_KI_*`-Variablen (siehe Tabelle oben). Das LLM wird nur in
zwei Situationen genutzt:
- **Struktur-Erkennung:** Liefert der Regel-Parser keine oder verdächtig wenige
Einträge (0 Treffer; 12 Treffer auf einer sehr linkreichen Seite), darf das
LLM aus dem gelieferten HTML Pagination-/Filter-Folge-URLs vorschlagen.
Der Vorschlag wird im Code hart gefiltert: nur URLs derselben Domain wie die
geparste Seite, maximal 20 je Seite, bereits besuchte/geplante URLs werden
übersprungen, nicht erreichbare Vorschläge brechen den Lauf nicht ab —
Schutz vor Rate-Limit-Überlastung und Domain-Ausbruch.
- **Feld-Extraktion:** Einzelne Treffer sind unsicher (fehlender Verlag oder
verdächtiger/generischer Titel). Nur solche Treffer gehen ans LLM, das
höchstens `titel`, `verlag` und `autor` korrigiert — die URL ist strukturell
nicht änderbar.
Unter der Mindest-Konfidenz (`SPIELE_NEUHEITEN_KI_KONFIDENZ_MIN`,
Standard 0.7) wird jedes KI-Ergebnis verworfen; der klassische Parser-Befund
bleibt dann unverändert bestehen.
- **Fallback-Pflicht:** Standardmäßig aus (`SPIELE_NEUHEITEN_KI_AKTIV=0`).
Ohne vollständige Konfiguration oder bei jedem Fehler (Timeout,
Server-Störung nach genau einem Retry, kaputtes JSON) läuft EXAKT der
klassische Crawler — ein KI-Fehler kann den Sync nie blockieren.
- **Audit:** KI-Eingriffe eines Sync-Laufs landen gebündelt im audit-log
(`korrigiert` / `neuheiten_ki_crawler`) mit Quelle, Modell, Konfidenz und
den jeweils korrigierten Feldern sowie den verfolgten KI-Folge-URLs.
## Plugin „dedup“ (implementiert) ## Plugin „dedup“ (implementiert)
Dedup-Prüfungen bei Import und händigem Eintrag. Die Prüf-Logik liegt Dedup-Prüfungen bei Import und händigem Eintrag. Die Prüf-Logik liegt
@@ -408,12 +480,14 @@ laufen automatisch durch die dedup-Prüfung.
### Datentabelle ### Datentabelle
Eigene Migration `0001_planungsliste`, Tabelle `planungsliste`: Eigene Migrationen `0001_planungsliste` und `0002_bild_url`,
Tabelle `planungsliste`:
| Spalte | Inhalt | | Spalte | Inhalt |
|--------|--------| |--------|--------|
| `titel` / `verlag` / `autor` | Spieldaten | | `titel` / `verlag` / `autor` | Spieldaten |
| `bgg_id` | BoardGameGeek-ID (Match-Kriterium der dedup-Prüfung) | | `bgg_id` | BoardGameGeek-ID (Match-Kriterium der dedup-Prüfung) |
| `bild_url` | URL des Coverbildes (nullable; wird beim Verschieben aus den Neuheiten übernommen) |
| `ausgabe` | Magazin-Ausgabe, z. B. „3/2025“ (mehrere parallel möglich) | | `ausgabe` | Magazin-Ausgabe, z. B. „3/2025“ (mehrere parallel möglich) |
| `rezensent_id` | Zuordnung (FK auf die Benutzer-Tabelle des Kerns) | | `rezensent_id` | Zuordnung (FK auf die Benutzer-Tabelle des Kerns) |
| `status` | `offen` → `in_bearbeitung` → `abgeschlossen` | | `status` | `offen` → `in_bearbeitung` → `abgeschlossen` |
@@ -457,9 +531,10 @@ unverändert zurück in die aktive Liste verschieben.
### Datentabellen ### Datentabellen
Eigene Migration `0001_archiv_tabellen` mit zwei Spiegel-Tabellen Eigene Migrationen `0001_archiv_tabellen` und `0002_bild_url`
(SQLite ↔ Postgres portabel, ohne Fremdschlüssel — archivierte Zeilen (Spiegel-Tabellen, SQLite ↔ Postgres portabel, ohne Fremdschlüssel —
überleben auch das Löschen eines Benutzers): archivierte Zeilen überleben auch das Löschen eines Benutzers;
`bild_url` wird mit archiviert und beim Wiederherstellen zurückgeschrieben):
| Tabelle | Spiegel von | Besondere Spalten | | Tabelle | Spiegel von | Besondere Spalten |
|---------|-------------|-------------------| |---------|-------------|-------------------|
@@ -595,6 +670,60 @@ Docker/Podman Compose ist vorgesehen (Henry-Lab, danach Kundenhardware).
Für Postgres genügt `SPIELE_DATABASE_URL`; die Migrationen sind portabel Für Postgres genügt `SPIELE_DATABASE_URL`; die Migrationen sind portabel
geschrieben. geschrieben.
## Oberfläche: Coverbilder & responsives Layout
**Coverbilder:** Die Listen (Neuheiten, Planung, Archiv) zeigen je Eintrag
eine kleine Cover-Vorschau (48×68 px, `loading="lazy"`, `decoding="async"`,
Alt-Text = Spieltitel); die Detail-/Prüf-Ansichten der Planung zeigen das
Cover großformatig (max. 200 px Breite). Fehlt ein Bild, erscheint ein
dezenter Platzhalter mit SVG-Würfel (in der Großansicht zusätzlich mit dem
Hinweis „Kein Cover"). Eingebettet wird per
Hotlink direkt von BGG bzw. der jeweiligen Quelle — es werden keine Bilder
heruntergeladen oder lokal gespeichert.
**Responsive Verhalten:** Das Basis-Layout ist mobil-first:
- Navigation: Ab Tablet (`md:`) horizontale Leiste; auf Mobilgeräten ein
Burger-Menü (Alpine.js) mit vollhöhen Touch-Zielen.
- Listen: Auf schmalen Screens (<768 px) kollabieren Neuheiten, Planung und
Archiv zu Karten-Ansichten — Cover links, Titel + Metadaten rechts,
Status-Badge oben. Desktop behält die Tabellenform.
- Formulare (Planung, Benutzerverwaltung, Einstellungen): vollbreite
Eingabefelder mit Labels oberhalb auf Mobil.
- Sync-Übersicht und Audit-Log bleiben Tabellen und scrollen horizontal
(`overflow-x-auto`).
- Buttons/Links haben auf Mobil mindestens 44 px Höhe (Touch-Ziel).
Getestet gegen 375 px (Smartphone), 768 px (Tablet) und Desktop.
**Bedienung & Konsistenz (UI/UX-Runde):**
- **Dashboard:** rollenbasierte Karten statt Technik-Liste — Rezensenten
sehen ihre offenen/in-Bearbeitung-Einträge, Redaktionsschlüsse und
ungelesene Benachrichtigungen; Redaktion zusätzlich Neuheiten-Stand,
Planungs-Stats und offene Dedup-Konflikte; Admins zusätzlich
Benutzer-Anzahl, letzte Audit-Einträge und System-Status
(BGG-Token/Sync). Karten kommen per `dashboard_karten`-Hook aus den
Plugins; die Plugin-Liste steckt in einem aufklappbaren Admin-Bereich.
- **Icons:** Alle Emojis sind durch Inline-SVGs ersetzt
(`icons.html`-Makros, `currentColor`, `aria-hidden`).
- **Status-Badges:** einheitliches Farbsystem über `status_badge`
(neu/abgeschlossen = emerald, offen = slate, in Bearbeitung = sky,
Warnung/Konflikt/Prüfung = amber mit Warn-Icon, Fehler = red,
deaktiviert/archiviert = slate).
- **Löschen mit Bestätigung:** Alpine-Dialog „Eintrag löschen?" in Planung
und Erinnerungen (ohne JavaScript sendet das Formular direkt).
- **Pagination:** klassische Seitennavigation (Zurück/Weiter +
Seitenzahlen) für Neuheiten, Planung und Audit-Log — funktioniert ohne
JavaScript.
- **Tabellen:** Sticky-Header in den langen Desktop-Listen; Audit-Log
JSON-Details per `<details>` einklappbar.
- **Accessibility:** `[x-cloak]` gegen Alpine-Flackern, aktiver Nav-Eintrag
mit `aria-current`, Ungelesen-Badge am Nav-Eintrag „Benachrichtigungen",
einheitliche Fokus-Ringe (`.focus-ring` + `:focus-visible`-Fallback),
Fehlerboxen mit `role="alert"`, Pflichtfelder mit `aria-required`.
- **Login:** gruppierte Karte mit „Passwort anzeigen"-Toggle (Alpine).
## Projekt-Fortschritt ## Projekt-Fortschritt
> Diese Tabelle ist der Live-Status. Sie wird bei jedem Push aktualisiert. > Diese Tabelle ist der Live-Status. Sie wird bei jedem Push aktualisiert.
@@ -610,8 +739,9 @@ geschrieben.
| 7 | Plugin `archiv` (12-Monats-Autopilot, Wiederherstellen, täglicher Job) | ✅ fertig | | 7 | Plugin `archiv` (12-Monats-Autopilot, Wiederherstellen, täglicher Job) | ✅ fertig |
| 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung, Tages-Job) | ✅ fertig | | 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung, Tages-Job) | ✅ fertig |
| 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ✅ fertig | | 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ✅ fertig |
| 10 | Integrationstests über alle Plugins | ✅ 217 Tests grün | | 10 | Integrationstests über alle Plugins | ✅ 316 Tests grün |
| 11 | Deployment auf swen.henry.insight-it.de (Compose + Traefik, Live-Check) | ✅ live | | 11 | Deployment auf swen.henry.insight-it.de (Compose + Traefik, Live-Check) | ✅ live |
| 12 | UI/UX-Verbesserungen (Dashboard-Karten, SVG-Icons, Status-Farben, Pagination, A11y) | ✅ 323 Tests grün |
Legende: ✅ fertig · 🔄 in Arbeit · ⏳ offen · ⚠️ fertig mit offenen Punkten Legende: ✅ fertig · 🔄 in Arbeit · ⏳ offen · ⚠️ fertig mit offenen Punkten

View File

@@ -1,6 +1,6 @@
# TODO — Web-Quellen im Neuheiten-Plugin (Branch wt-quellen) # TODO — Web-Quellen im Neuheiten-Plugin (Branch wt-quellen)
> Live-Status, wird laufend aktualisiert. > Abgeschlossen.
- [x] Quellen-Adapter angelegt: basis.py, spielbox.py, brettspielbox.py, cliquenabend.py - [x] Quellen-Adapter angelegt: basis.py, spielbox.py, brettspielbox.py, cliquenabend.py
- [x] Adapter spiel-essen - [x] Adapter spiel-essen
@@ -8,7 +8,7 @@
- [x] Rate-Limit (1 req/s pro Domain) + User-Agent + ETag/Last-Modified - [x] Rate-Limit (1 req/s pro Domain) + User-Agent + ETag/Last-Modified
- [x] Dedup-Gate vor Übernahme in Neuheitenliste - [x] Dedup-Gate vor Übernahme in Neuheitenliste
- [x] APScheduler-Job + Env-Schalter je Quelle (_AKTIV) - [x] APScheduler-Job + Env-Schalter je Quelle (_AKTIV)
- [ ] KI-Hilfsstufe optional (Struktur-Erkennung + Feld-Extraktion, Fallback klassisch) → folgt als eigener Nachtrag - [x] KI-Hilfsstufe optional (Struktur-Erkennung + Feld-Extraktion, Fallback klassisch) → folgt als eigener Nachtrag
- [x] Sync-Übersicht im UI (/neuheiten) - [x] Sync-Übersicht im UI (/neuheiten)
- [x] Tests (gemocktes HTML) - [x] Tests (gemocktes HTML)
- [x] README.md aktualisieren - [x] README.md aktualisieren

View File

@@ -8,6 +8,8 @@ services:
- SPIELE_SESSION_SECRET=${SPIELE_SESSION_SECRET:-change-me-in-production} - SPIELE_SESSION_SECRET=${SPIELE_SESSION_SECRET:-change-me-in-production}
# Vor dem ersten Start setzen, sonst wird der Admin mit "admin" angelegt: # Vor dem ersten Start setzen, sonst wird der Admin mit "admin" angelegt:
- SPIELE_INITIAL_ADMIN_PASSWORD=${SPIELE_INITIAL_ADMIN_PASSWORD:-admin} - SPIELE_INITIAL_ADMIN_PASSWORD=${SPIELE_INITIAL_ADMIN_PASSWORD:-admin}
# BGG XML API2 (Pflicht seit Token-Umstellung): siehe .env
- SPIELE_BGG_TOKEN=${SPIELE_BGG_TOKEN}
# Benachrichtigungen (optional): ohne SMTP-Host/Bot-Token wird nur protokolliert # Benachrichtigungen (optional): ohne SMTP-Host/Bot-Token wird nur protokolliert
#- SPIELE_SMTP_HOST=smtp.example.org #- SPIELE_SMTP_HOST=smtp.example.org
#- SPIELE_SMTP_PORT=587 #- SPIELE_SMTP_PORT=587

View File

@@ -71,6 +71,33 @@ def _tabelle_anlegen(conn) -> None:
ArchivPlanung.__table__.create(conn, checkfirst=True) ArchivPlanung.__table__.create(conn, checkfirst=True)
def _spalte_existiert(conn, tabelle: str, spalte: str) -> bool:
"""Dialekt-agnostische Spaltenprüfung (PRAGMA bzw. information_schema)."""
if conn.dialect.name == "sqlite":
info = conn.exec_driver_sql(f"PRAGMA table_info({tabelle})").fetchall()
return any(zeile[1] == spalte for zeile in info)
existiert = conn.exec_driver_sql(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = :tabelle AND column_name = :spalte",
{"tabelle": tabelle, "spalte": spalte},
).scalar()
return existiert is not None
def _bild_url_spalten(conn) -> None:
"""Migration 0002: Spalte bild_url in beiden Spiegel-Tabellen.
`ALTER TABLE … ADD COLUMN` mit nullable VARCHAR ohne Default ist
SQLite- und Postgres-portabel und erhält bestehende Zeilen.
"""
for tabelle in ("archiv_neuheiten", "archiv_planung"):
if _spalte_existiert(conn, tabelle, "bild_url"):
continue
conn.exec_driver_sql(
f"ALTER TABLE {tabelle} ADD COLUMN bild_url VARCHAR(500)"
)
class ArchivPlugin(BasePlugin): class ArchivPlugin(BasePlugin):
name = "archiv" name = "archiv"
title = "Archiv" title = "Archiv"
@@ -89,7 +116,10 @@ class ArchivPlugin(BasePlugin):
# ---------- Plugin-Vertrag ---------- # ---------- Plugin-Vertrag ----------
def migrations(self) -> list[Migration]: def migrations(self) -> list[Migration]:
return [Migration(version="0001_archiv_tabellen", up=_tabelle_anlegen)] return [
Migration(version="0001_archiv_tabellen", up=_tabelle_anlegen),
Migration(version="0002_bild_url", up=_bild_url_spalten),
]
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/archiv")] return [NavEntry(label=self.title, url="/archiv")]

View File

@@ -63,6 +63,7 @@ class ArchivNeuheit(_ArchivMeta, Base):
autor: Mapped[str | None] = mapped_column(String(300)) autor: Mapped[str | None] = mapped_column(String(300))
erscheinungsjahr: Mapped[int | None] = mapped_column(Integer) erscheinungsjahr: Mapped[int | None] = mapped_column(Integer)
bgg_id: Mapped[int | None] = mapped_column(Integer) bgg_id: Mapped[int | None] = mapped_column(Integer)
bild_url: Mapped[str | None] = mapped_column(String(500))
status: Mapped[str] = mapped_column(String(50), default="neuheit") status: Mapped[str] = mapped_column(String(50), default="neuheit")
quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek") quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek")
erstellt_am: Mapped[datetime | None] = mapped_column(DateTime) erstellt_am: Mapped[datetime | None] = mapped_column(DateTime)
@@ -83,6 +84,7 @@ class ArchivPlanung(_ArchivMeta, Base):
verlag: Mapped[str | None] = mapped_column(String(300)) verlag: Mapped[str | None] = mapped_column(String(300))
autor: Mapped[str | None] = mapped_column(String(300)) autor: Mapped[str | None] = mapped_column(String(300))
bgg_id: Mapped[int | None] = mapped_column(Integer) bgg_id: Mapped[int | None] = mapped_column(Integer)
bild_url: Mapped[str | None] = mapped_column(String(500))
ausgabe: Mapped[str] = mapped_column(String(100), default="") ausgabe: Mapped[str] = mapped_column(String(100), default="")
#: Kein FK — Archiv-Zeilen überleben das Löschen des Benutzers. #: Kein FK — Archiv-Zeilen überleben das Löschen des Benutzers.
rezensent_id: Mapped[int | None] = mapped_column(Integer) rezensent_id: Mapped[int | None] = mapped_column(Integer)

View File

@@ -201,6 +201,7 @@ class ArchivService:
autor=zeile["autor"], autor=zeile["autor"],
erscheinungsjahr=zeile["erscheinungsjahr"], erscheinungsjahr=zeile["erscheinungsjahr"],
bgg_id=zeile["bgg_id"], bgg_id=zeile["bgg_id"],
bild_url=zeile["bild_url"],
status=zeile["status"], status=zeile["status"],
quelle=zeile["quelle"], quelle=zeile["quelle"],
erstellt_am=zeile["erstellt_am"], erstellt_am=zeile["erstellt_am"],
@@ -250,6 +251,7 @@ class ArchivService:
verlag=zeile["verlag"], verlag=zeile["verlag"],
autor=zeile["autor"], autor=zeile["autor"],
bgg_id=zeile["bgg_id"], bgg_id=zeile["bgg_id"],
bild_url=zeile["bild_url"],
ausgabe=zeile["ausgabe"], ausgabe=zeile["ausgabe"],
rezensent_id=zeile["rezensent_id"], rezensent_id=zeile["rezensent_id"],
status=zeile["status"], status=zeile["status"],
@@ -305,6 +307,7 @@ class ArchivService:
"verlag": a.verlag, "verlag": a.verlag,
"autor": a.autor, "autor": a.autor,
"bgg_id": a.bgg_id, "bgg_id": a.bgg_id,
"bild_url": a.bild_url,
"jahr": a.erscheinungsjahr, "jahr": a.erscheinungsjahr,
"ausgabe": "", "ausgabe": "",
"status": a.status, "status": a.status,
@@ -328,6 +331,7 @@ class ArchivService:
"verlag": a.verlag, "verlag": a.verlag,
"autor": a.autor, "autor": a.autor,
"bgg_id": a.bgg_id, "bgg_id": a.bgg_id,
"bild_url": a.bild_url,
"jahr": None, "jahr": None,
"ausgabe": a.ausgabe, "ausgabe": a.ausgabe,
"status": a.status, "status": a.status,
@@ -409,6 +413,7 @@ class ArchivService:
autor=archiv.autor, autor=archiv.autor,
erscheinungsjahr=archiv.erscheinungsjahr, erscheinungsjahr=archiv.erscheinungsjahr,
bgg_id=archiv.bgg_id, bgg_id=archiv.bgg_id,
bild_url=archiv.bild_url,
status=archiv.status, status=archiv.status,
quelle=archiv.quelle, quelle=archiv.quelle,
erstellt_am=archiv.erstellt_am, erstellt_am=archiv.erstellt_am,
@@ -464,6 +469,7 @@ class ArchivService:
verlag=archiv.verlag, verlag=archiv.verlag,
autor=archiv.autor, autor=archiv.autor,
bgg_id=archiv.bgg_id, bgg_id=archiv.bgg_id,
bild_url=archiv.bild_url,
ausgabe=archiv.ausgabe, ausgabe=archiv.ausgabe,
rezensent_id=rezensent_id, rezensent_id=rezensent_id,
status=archiv.status, status=archiv.status,

View File

@@ -0,0 +1,31 @@
{# Coverbild-Vorschau.
Erwartete Variablen: cover_bild (URL oder None), cover_titel,
cover_gross (optional: true für großformatige Ansichten).
Bilder werden direkt von der Quelle eingebettet (Hotlinking, kein
Download) — mit loading="lazy" und decoding="async". Ohne Bild zeigt
das Partial einen dezenten Platzhalter mit SVG-Würfel (in der großen
Ansicht zusätzlich mit dem Hinweis „Kein Cover"). #}
{% from "icons.html" import icon_wuerfel %}
{% set _gross = cover_gross | default(false) %}
{% if _gross %}
{% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async"
class="block h-auto w-auto max-w-[200px] max-h-[280px] rounded-lg border border-slate-200 bg-white shadow-sm">
{% else %}
<div role="img" aria-label="Kein Cover vorhanden" title="Kein Cover vorhanden"
class="flex h-[210px] w-[150px] max-w-[200px] flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 text-slate-300 select-none">
{{ icon_wuerfel("h-12 w-12") }}
<span class="text-xs font-medium text-slate-400">Kein Cover</span>
</div>
{% endif %}
{% else %}
{% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" width="48" height="68"
class="block h-[68px] w-12 shrink-0 rounded border border-slate-200 bg-slate-50 object-cover">
{% else %}
<div role="img" aria-label="Kein Cover vorhanden" title="Kein Cover vorhanden"
class="flex h-[68px] w-12 shrink-0 items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 text-slate-400 select-none">
{{ icon_wuerfel("h-5 w-5") }}
</div>
{% endif %}
{% endif %}

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1> <h1 class="text-2xl font-bold text-slate-900 mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-3xl mb-4"> <p class="text-slate-600 max-w-3xl mb-4">
Sicherungsdatei der Redaktion: Titel, deren Erscheinungs- bzw. Eintragsdatum Sicherungsdatei der Redaktion: Titel, deren Erscheinungs- bzw. Eintragsdatum
mehr als 12 Monate zur&uuml;ckliegt, werden t&auml;glich automatisch aus den mehr als 12 Monate zur&uuml;ckliegt, werden t&auml;glich automatisch aus den
@@ -22,20 +22,20 @@
<div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div> <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 %} {% endif %}
{% if fehler %} {% 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> <div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm" role="alert" tabindex="-1">{{ fehler }}</div>
{% endif %} {% endif %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6"> <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"> <form method="get" action="/archiv" class="grid grid-cols-1 gap-3 sm:flex sm:flex-wrap sm:items-end">
<div> <div class="sm:w-64 lg:w-72">
<label for="q" class="block text-xs font-medium text-slate-500 mb-1">Suche (Titel, Verlag, Autor)</label> <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 }}" <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"> class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<div> <div class="sm:w-48">
<label for="quelle" class="block text-xs font-medium text-slate-500 mb-1">Herkunft</label> <label for="quelle" class="block text-xs font-medium text-slate-500 mb-1">Herkunft</label>
<select id="quelle" name="quelle" <select id="quelle" name="quelle"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white" class="w-full sm:w-auto min-h-[44px] md:min-h-0 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"
onchange="this.form.submit()"> onchange="this.form.submit()">
<option value="">Alle Listen</option> <option value="">Alle Listen</option>
<option value="neuheiten" {% if quelle_filter == 'neuheiten' %}selected{% endif %}>Neuheiten</option> <option value="neuheiten" {% if quelle_filter == 'neuheiten' %}selected{% endif %}>Neuheiten</option>
@@ -43,7 +43,7 @@
</select> </select>
</div> </div>
<button type="submit" <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"> class="w-full sm:w-auto min-h-[44px] inline-flex items-center justify-center bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
Suchen Suchen
</button> </button>
{% if q or quelle_filter %} {% if q or quelle_filter %}
@@ -54,24 +54,84 @@
<p class="text-xs text-slate-400 mb-3">{{ anzahl }} Eintr&auml;ge im Archiv</p> <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"> <!-- Mobil (<768px): Karten-Ansicht — Cover links, Titel+Metadaten rechts, Status oben -->
<ul class="md:hidden space-y-3" role="list">
{% for eintrag in eintraege %}
<li class="bg-white rounded-xl border border-slate-200 shadow-sm p-3 flex gap-3">
<div class="pt-0.5">
{% set cover_bild = eintrag.bild_url %}
{% set cover_titel = eintrag.titel %}
{% include "archiv/_cover.html" %}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<h3 class="text-sm font-medium leading-snug break-words">
{{ eintrag.titel }}
{% if eintrag.bgg_id %}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
class="ml-0.5 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
{% endif %}
</h3>
{% if eintrag.quelle == 'neuheiten' %}
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-sky-100 text-sky-800 text-xs">Neuheiten</span>
{% else %}
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-violet-100 text-violet-800 text-xs">Planung</span>
{% endif %}
</div>
<dl class="mt-1.5 space-y-0.5 text-xs text-slate-500">
{% if eintrag.verlag %}<div><dt class="inline font-medium text-slate-400">Verlag: </dt><dd class="inline">{{ eintrag.verlag }}</dd></div>{% endif %}
{% if eintrag.jahr or eintrag.ausgabe %}<div><dt class="inline font-medium text-slate-400">Jahr/Ausgabe: </dt><dd class="inline">{{ eintrag.jahr if eintrag.jahr else eintrag.ausgabe }}</dd></div>{% endif %}
<div><dt class="inline font-medium text-slate-400">Status: </dt><dd class="inline">{% from "status.html" import status_badge %}{{ status_badge(eintrag.status, status_anzeige.get(eintrag.status, eintrag.status)) }}</dd></div>
<div>
<dt class="inline font-medium text-slate-400">Archiviert am: </dt>
<dd class="inline">{{ eintrag.archiviert_am.strftime('%d.%m.%Y %H:%M') if eintrag.archiviert_am else '—' }}</dd>
</div>
<div><dt class="inline font-medium text-slate-400">Grund: </dt><dd class="inline">{{ eintrag.grund }}</dd></div>
</dl>
<form method="post" action="/archiv/{{ eintrag.quelle }}/{{ eintrag.id }}/wiederherstellen" class="mt-2">
<button type="submit"
class="w-full min-h-[44px] inline-flex items-center justify-center gap-1 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-3 py-2 rounded-lg text-sm whitespace-nowrap"
title="Eintrag unverändert zurück in die aktive Liste verschieben">
{% from "icons.html" import icon_sync %}{{ icon_sync("h-4 w-4") }} Wiederherstellen
</button>
</form>
</div>
</li>
{% else %}
<li class="bg-white rounded-xl border border-slate-200 shadow-sm px-4 py-8 text-center text-sm text-slate-400">
{% if q or quelle_filter %}
Keine Treffer f&uuml;r diese Suche.
{% else %}
Das Archiv ist leer — nichts zu sehen.
{% endif %}
</li>
{% endfor %}
</ul>
<div class="hidden md:block overflow-auto max-h-[70vh] bg-white rounded-xl border border-slate-200 shadow-sm">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="text-left text-slate-500 sticky top-0 z-10 [&>tr>th]:bg-slate-50 [&>tr>th]:shadow-[inset_0_-1px_0_0_#e2e8f0]">
<tr> <tr>
<th class="px-4 py-3 font-medium">Spieltitel</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Cover</th>
<th class="px-4 py-3 font-medium">Herkunft</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Spieltitel</th>
<th class="px-4 py-3 font-medium">Verlag</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Herkunft</th>
<th class="px-4 py-3 font-medium">Jahr / Ausgabe</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Verlag</th>
<th class="px-4 py-3 font-medium">Status</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Jahr / Ausgabe</th>
<th class="px-4 py-3 font-medium">Rezensent</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Status</th>
<th class="px-4 py-3 font-medium">Archiviert am</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Rezensent</th>
<th class="px-4 py-3 font-medium">Grund</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Archiviert am</th>
<th class="px-4 py-3 font-medium">Aktionen</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Grund</th>
<th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
{% for eintrag in eintraege %} {% for eintrag in eintraege %}
<tr class="hover:bg-slate-50"> <tr class="hover:bg-slate-50">
<td class="px-4 py-3">
{% set cover_bild = eintrag.bild_url %}
{% set cover_titel = eintrag.titel %}
{% include "archiv/_cover.html" %}
</td>
<td class="px-4 py-3 font-medium"> <td class="px-4 py-3 font-medium">
{{ eintrag.titel }} {{ eintrag.titel }}
{% if eintrag.bgg_id %} {% if eintrag.bgg_id %}
@@ -88,7 +148,7 @@
</td> </td>
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</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">{{ 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">{% from "status.html" import status_badge %}{{ status_badge(eintrag.status, 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">{{ namen.get(eintrag.rezensent_id, '—') if eintrag.rezensent_id else '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap text-slate-500"> <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 '—' }} {{ eintrag.archiviert_am.strftime('%d.%m.%Y %H:%M') if eintrag.archiviert_am else '—' }}
@@ -97,16 +157,16 @@
<td class="px-4 py-3"> <td class="px-4 py-3">
<form method="post" action="/archiv/{{ eintrag.quelle }}/{{ eintrag.id }}/wiederherstellen"> <form method="post" action="/archiv/{{ eintrag.quelle }}/{{ eintrag.id }}/wiederherstellen">
<button type="submit" <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" class="inline-flex items-center gap-1 text-xs bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-2.5 py-2 rounded-lg whitespace-nowrap"
title="Eintrag unverändert zurück in die aktive Liste verschieben"> title="Eintrag unverändert zurück in die aktive Liste verschieben">
&#8635; Wiederherstellen {% from "icons.html" import icon_sync %}{{ icon_sync("h-4 w-4") }} Wiederherstellen
</button> </button>
</form> </form>
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="9" class="px-4 py-8 text-center text-slate-400"> <td colspan="10" class="px-4 py-8 text-center text-slate-400">
{% if q or quelle_filter %} {% if q or quelle_filter %}
Keine Treffer f&uuml;r diese Suche. Keine Treffer f&uuml;r diese Suche.
{% else %} {% else %}

View File

@@ -28,7 +28,7 @@ from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_roles, require_user from redaktionskern.auth.deps import get_db, require_roles, require_user
from redaktionskern.auth.models import Role, User from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry from redaktionskern.contracts import BasePlugin, DashboardKarte, Migration, NavEntry
from .models import AuditEintrag from .models import AuditEintrag
@@ -88,6 +88,29 @@ class AuditLogPlugin(BasePlugin):
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url=f"/{self.name}")] return [NavEntry(label=self.title, url=f"/{self.name}")]
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: die letzten fünf Audit-Einträge (nur Admins)."""
if user.role != Role.ADMIN.value:
return []
with self.context.session_factory() as db:
letzte = db.scalars(
select(AuditEintrag).order_by(AuditEintrag.id.desc()).limit(5)
).all()
zeilen = tuple(
f"{e.erstellt_am.strftime('%d.%m. %H:%M') if e.erstellt_am else ''}"
f" · {e.actor_name or 'System'}: {e.action}"
for e in letzte
)
return [
DashboardKarte(
titel="Letzte Audit-Log-Einträge",
zeilen=zeilen,
beschreibung="" if zeilen else "Noch keine Ereignisse protokolliert.",
link_url="/audit-log",
link_label="Zum Audit-Log",
)
]
def on_load(self, context) -> None: def on_load(self, context) -> None:
super().on_load(context) super().on_load(context)
# Jinja-Filter für die JSON-Anzeige in der Admin-Ansicht. # Jinja-Filter für die JSON-Anzeige in der Admin-Ansicht.
@@ -262,6 +285,7 @@ class AuditLogPlugin(BasePlugin):
"ende": offset + len(eintraege), "ende": offset + len(eintraege),
"url_zurueck": _seiten_url(seite - 1), "url_zurueck": _seiten_url(seite - 1),
"url_weiter": _seiten_url(seite + 1), "url_weiter": _seiten_url(seite + 1),
"seiten_url": _seiten_url,
"url_ohne_seite": f"/audit-log?{basis}" if basis else "/audit-log", "url_ohne_seite": f"/audit-log?{basis}" if basis else "/audit-log",
"benutzer_auswahl": benutzer_auswahl, "benutzer_auswahl": benutzer_auswahl,
"aktionen_auswahl": aktionen_auswahl, "aktionen_auswahl": aktionen_auswahl,

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel }} — {{ app_name }}{% endblock %} {% block titel %}{{ titel }} — {{ app_name }}{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-1">{{ titel }}</h1> <h1 class="text-2xl font-bold text-slate-900 mb-1">{{ titel }}</h1>
<p class="text-slate-600 mb-4 text-sm"> <p class="text-slate-600 mb-4 text-sm">
Protokoll aller relevanten Ereignisse — nur für Administratoren sichtbar. Protokoll aller relevanten Ereignisse — nur für Administratoren sichtbar.
</p> </p>
@@ -19,7 +19,7 @@
<label class="block text-sm"> <label class="block text-sm">
<span class="text-slate-500 block mb-1">Benutzer</span> <span class="text-slate-500 block mb-1">Benutzer</span>
<select name="benutzer" <select name="benutzer"
class="w-full border border-slate-300 rounded-lg px-2 py-2 bg-white"> class="w-full border border-slate-300 rounded-lg px-2 py-2 bg-white focus-ring">
<option value="">Alle</option> <option value="">Alle</option>
{% for name in benutzer_auswahl %} {% for name in benutzer_auswahl %}
<option value="{{ name }}" {% if name == filter_benutzer %}selected{% endif %}>{{ name }}</option> <option value="{{ name }}" {% if name == filter_benutzer %}selected{% endif %}>{{ name }}</option>
@@ -29,7 +29,7 @@
<label class="block text-sm"> <label class="block text-sm">
<span class="text-slate-500 block mb-1">Aktionstyp</span> <span class="text-slate-500 block mb-1">Aktionstyp</span>
<select name="aktion" <select name="aktion"
class="w-full border border-slate-300 rounded-lg px-2 py-2 bg-white"> class="w-full border border-slate-300 rounded-lg px-2 py-2 bg-white focus-ring">
<option value="">Alle</option> <option value="">Alle</option>
{% for a in aktionen_auswahl %} {% for a in aktionen_auswahl %}
<option value="{{ a }}" {% if a == filter_aktion %}selected{% endif %}> <option value="{{ a }}" {% if a == filter_aktion %}selected{% endif %}>
@@ -41,12 +41,12 @@
<label class="block text-sm"> <label class="block text-sm">
<span class="text-slate-500 block mb-1">Von</span> <span class="text-slate-500 block mb-1">Von</span>
<input type="date" name="von" value="{{ filter_von }}" <input type="date" name="von" value="{{ filter_von }}"
class="w-full border border-slate-300 rounded-lg px-2 py-2"> class="w-full border border-slate-300 rounded-lg px-2 py-2 focus-ring">
</label> </label>
<label class="block text-sm"> <label class="block text-sm">
<span class="text-slate-500 block mb-1">Bis</span> <span class="text-slate-500 block mb-1">Bis</span>
<input type="date" name="bis" value="{{ filter_bis }}" <input type="date" name="bis" value="{{ filter_bis }}"
class="w-full border border-slate-300 rounded-lg px-2 py-2"> class="w-full border border-slate-300 rounded-lg px-2 py-2 focus-ring">
</label> </label>
<div class="flex gap-2"> <div class="flex gap-2">
<button type="submit" <button type="submit"
@@ -61,16 +61,16 @@
</div> </div>
</form> </form>
<div class="bg-white rounded-xl border border-slate-200 overflow-x-auto"> <div class="bg-white rounded-xl border border-slate-200 overflow-auto max-h-[70vh]">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="text-left text-slate-500 sticky top-0 z-10 [&>tr>th]:bg-slate-50 [&>tr>th]:shadow-[inset_0_-1px_0_0_#e2e8f0]">
<tr> <tr>
<th class="px-4 py-2 font-medium whitespace-nowrap">Zeitpunkt</th> <th class="px-4 py-2 font-medium whitespace-nowrap text-xs uppercase tracking-wider">Zeitpunkt</th>
<th class="px-4 py-2 font-medium">Benutzer</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Benutzer</th>
<th class="px-4 py-2 font-medium">Aktion</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Aktion</th>
<th class="px-4 py-2 font-medium">Objekt</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Objekt</th>
<th class="px-4 py-2 font-medium">Änderung / Details</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Änderung / Details</th>
<th class="px-4 py-2 font-medium">IP</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">IP</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@@ -86,12 +86,18 @@
</td> </td>
<td class="px-4 py-2 max-w-md"> <td class="px-4 py-2 max-w-md">
{% if e.alt or e.neu %} {% if e.alt or e.neu %}
{% if e.alt %}<div class="mb-1"><span class="text-slate-400 text-xs">Alt:</span> <details>
<pre class="text-xs bg-slate-50 rounded p-1 overflow-x-auto whitespace-pre-wrap">{{ e.alt | json_schoen }}</pre></div>{% endif %} <summary class="cursor-pointer text-xs text-emerald-700 hover:text-emerald-900 select-none">Änderungen anzeigen</summary>
{% if e.neu %}<div><span class="text-slate-400 text-xs">Neu:</span> {% if e.alt %}<div class="mt-1 mb-1"><span class="text-slate-400 text-xs">Alt:</span>
<pre class="text-xs bg-emerald-50 rounded p-1 overflow-x-auto whitespace-pre-wrap">{{ e.neu | json_schoen }}</pre></div>{% endif %} <pre class="font-mono text-[11px] bg-slate-50 rounded p-1 overflow-x-auto whitespace-pre-wrap">{{ e.alt | json_schoen }}</pre></div>{% endif %}
{% if e.neu %}<div class="mt-1"><span class="text-slate-400 text-xs">Neu:</span>
<pre class="font-mono text-[11px] bg-emerald-50 rounded p-1 overflow-x-auto whitespace-pre-wrap">{{ e.neu | json_schoen }}</pre></div>{% endif %}
</details>
{% elif e.details %} {% elif e.details %}
<pre class="text-xs bg-slate-50 rounded p-1 overflow-x-auto whitespace-pre-wrap">{{ e.details | json_schoen }}</pre> <details>
<summary class="cursor-pointer text-xs text-emerald-700 hover:text-emerald-900 select-none">Details anzeigen</summary>
<pre class="mt-1 font-mono text-[11px] bg-slate-50 rounded p-1 overflow-x-auto whitespace-pre-wrap">{{ e.details | json_schoen }}</pre>
</details>
{% else %} {% else %}
<span class="text-slate-400">&ndash;</span> <span class="text-slate-400">&ndash;</span>
{% endif %} {% endif %}
@@ -117,16 +123,7 @@
0 Einträge 0 Einträge
{% endif %} {% endif %}
</p> </p>
{% if gesamt_seiten > 1 %}
<div class="flex items-center gap-2">
{% if seite > 1 %}
<a href="{{ url_zurueck }}" class="border border-slate-300 hover:bg-slate-50 rounded-lg px-3 py-1">&larr; Zurück</a>
{% endif %}
<span>Seite {{ seite }} von {{ gesamt_seiten }}</span>
{% if seite < gesamt_seiten %}
<a href="{{ url_weiter }}" class="border border-slate-300 hover:bg-slate-50 rounded-lg px-3 py-1">Weiter &rarr;</a>
{% endif %}
</div>
{% endif %}
</div> </div>
{% from "seitennavigation.html" import seitennavigation %}
{{ seitennavigation(seiten_url, seite, gesamt_seiten) }}
{% endblock %} {% endblock %}

View File

@@ -25,7 +25,7 @@ from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_user from redaktionskern.auth.deps import get_db, require_user
from redaktionskern.auth.models import User from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry from redaktionskern.contracts import BasePlugin, DashboardKarte, Migration, NavEntry
from .adapter import BenachrichtigungsAdapter, adapter_aus_konfiguration from .adapter import BenachrichtigungsAdapter, adapter_aus_konfiguration
from .konfiguration import aus_umgebung from .konfiguration import aus_umgebung
@@ -80,6 +80,41 @@ class BenachrichtigungPlugin(BasePlugin):
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/benachrichtigung")] return [NavEntry(label=self.title, url="/benachrichtigung")]
def nav_badges(self, user_id: int) -> dict[str, int]:
"""Nav-Badge: Anzahl ungelesener Benachrichtigungen."""
with self.context.session_factory() as db:
ungelesen = db.scalar(
select(func.count())
.select_from(Benachrichtigung)
.where(
Benachrichtigung.user_id == user_id,
Benachrichtigung.gelesen.is_(False),
)
) or 0
return {"/benachrichtigung": ungelesen} if ungelesen else {}
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: Anzahl ungelesener In-App-Benachrichtigungen."""
with self.context.session_factory() as db:
ungelesen = db.scalar(
select(func.count())
.select_from(Benachrichtigung)
.where(
Benachrichtigung.user_id == user.id,
Benachrichtigung.gelesen.is_(False),
)
) or 0
return [
DashboardKarte(
titel="Ungelesene Benachrichtigungen",
wert=str(ungelesen),
beschreibung="Nachrichten in deinem Posteingang",
link_url="/benachrichtigung",
link_label="Zum Posteingang",
ton="warnung" if ungelesen else "ok",
)
]
# ---------------- Öffentliche Plugin-API ---------------- # ---------------- Öffentliche Plugin-API ----------------
async def send_notification( async def send_notification(

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel }} &mdash; {{ app_name }}{% endblock %} {% block titel %}{{ titel }} &mdash; {{ app_name }}{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-1">Benachrichtigungen &mdash; Einstellungen</h1> <h1 class="text-2xl font-bold text-slate-900 mb-1">Benachrichtigungen &mdash; Einstellungen</h1>
<p class="text-slate-600 mb-4"> <p class="text-slate-600 mb-4">
W&auml;hlen Sie, &uuml;ber welche Kan&auml;le Sie Benachrichtigungen erhalten. W&auml;hlen Sie, &uuml;ber welche Kan&auml;le Sie Benachrichtigungen erhalten.
Mehrere Kan&auml;le sind gleichzeitig m&ouml;glich. Mehrere Kan&auml;le sind gleichzeitig m&ouml;glich.
@@ -15,7 +15,7 @@
</div> </div>
{% endif %} {% endif %}
{% if fehler %} {% if fehler %}
<div class="mt-4 mb-2 rounded-lg bg-red-50 border border-red-200 text-red-800 px-4 py-2 text-sm"> <div class="mt-4 mb-2 rounded-lg bg-red-50 border border-red-200 text-red-800 px-4 py-2 text-sm" role="alert" tabindex="-1">
{{ fehler }} {{ fehler }}
</div> </div>
{% endif %} {% endif %}
@@ -66,7 +66,7 @@
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button type="submit" class="px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700"> <button type="submit" class="w-full sm:w-auto min-h-[44px] inline-flex items-center justify-center px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 active:bg-emerald-800">
Speichern Speichern
</button> </button>
<span class="text-xs text-slate-400">Ohne ausgew&auml;hlten Kanal erhalten Sie keine Benachrichtigungen.</span> <span class="text-xs text-slate-400">Ohne ausgew&auml;hlten Kanal erhalten Sie keine Benachrichtigungen.</span>

View File

@@ -2,7 +2,7 @@
{% block titel %}{{ titel }} &mdash; {{ app_name }}{% endblock %} {% block titel %}{{ titel }} &mdash; {{ app_name }}{% endblock %}
{% block inhalt %} {% block inhalt %}
<div class="flex flex-wrap items-center gap-3 mb-1"> <div class="flex flex-wrap items-center gap-3 mb-1">
<h1 class="text-2xl font-bold">{{ titel }}</h1> <h1 class="text-2xl font-bold text-slate-900">{{ titel }}</h1>
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-medium <span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-medium
{% if ungelesen %}bg-red-100 text-red-700{% else %}bg-slate-200 text-slate-500{% endif %}"> {% if ungelesen %}bg-red-100 text-red-700{% else %}bg-slate-200 text-slate-500{% endif %}">
{{ ungelesen }} ungelesen {{ ungelesen }} ungelesen

View File

@@ -33,12 +33,12 @@ import logging
import os import os
from fastapi import Depends, Form, Request from fastapi import Depends, Form, Request
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_user from redaktionskern.auth.deps import get_db, require_user
from redaktionskern.auth.models import User from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry from redaktionskern.contracts import BasePlugin, DashboardKarte, Migration, NavEntry
from .bgg import BggPruefClient from .bgg import BggPruefClient
from .ki_pruefung import KiPruefClient, lade_konfiguration from .ki_pruefung import KiPruefClient, lade_konfiguration
@@ -87,6 +87,36 @@ class DedupPlugin(BasePlugin):
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/dedup")] return [NavEntry(label=self.title, url="/dedup")]
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: offene Dedup-Konflikte (nur Redaktion)."""
from redaktionskern.auth.models import Role
from redaktionskern.db import Base
if user.role not in (Role.ADMIN.value, Role.REDAKTEUR.value):
return []
tabelle = Base.metadata.tables.get("planungsliste")
if tabelle is None:
return []
with self.context.session_factory() as db:
offene = db.scalar(
select(func.count())
.select_from(tabelle)
.where(
tabelle.c.pruefung.is_not(None),
tabelle.c.status != "abgeschlossen",
)
) or 0
return [
DashboardKarte(
titel="Offene Dedup-Konflikte",
wert=str(offene),
beschreibung="Planungseinträge mit Prüfhinweis",
link_url="/planung",
link_label="Zur Planung",
ton="warnung" if offene else "ok",
)
]
def on_load(self, context) -> None: def on_load(self, context) -> None:
super().on_load(context) super().on_load(context)
self._bgg_aktiv = os.environ.get("SPIELE_DEDUP_BGG_AKTIV", "1").strip() != "0" self._bgg_aktiv = os.environ.get("SPIELE_DEDUP_BGG_AKTIV", "1").strip() != "0"
@@ -192,7 +222,8 @@ class DedupPlugin(BasePlugin):
if not self._bgg_aktiv or self.context is None: if not self._bgg_aktiv or self.context is None:
return None return None
if self._bgg_client is None: if self._bgg_client is None:
self._bgg_client = BggPruefClient() token = (os.environ.get("SPIELE_BGG_TOKEN") or "").strip() or None
self._bgg_client = BggPruefClient(token=token)
return self._bgg_client return self._bgg_client
def _protokolliere(self, ergebnis: PruefErgebnis, user: User | None) -> None: def _protokolliere(self, ergebnis: PruefErgebnis, user: User | None) -> None:

View File

@@ -33,6 +33,7 @@ class BggPruefClient:
self, self,
mindestabstand_sekunden: float = 1.0, mindestabstand_sekunden: float = 1.0,
*, *,
token: str | None = None,
transport: httpx.BaseTransport | None = None, transport: httpx.BaseTransport | None = None,
uhr: Callable[[], float] = time.monotonic, uhr: Callable[[], float] = time.monotonic,
schlafen: Callable[[float], None] = time.sleep, schlafen: Callable[[float], None] = time.sleep,
@@ -42,8 +43,11 @@ class BggPruefClient:
self._zuletzt: float | None = None self._zuletzt: float | None = None
self._uhr = uhr self._uhr = uhr
self._schlafen = schlafen self._schlafen = schlafen
headers: dict[str, str] = {}
if token:
headers["Authorization"] = f"Bearer {token}"
self._client = httpx.Client( self._client = httpx.Client(
base_url=BASIS_URL, timeout=timeout, transport=transport base_url=BASIS_URL, timeout=timeout, transport=transport, headers=headers
) )
def schliessen(self) -> None: def schliessen(self) -> None:

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1> <h1 class="text-2xl font-bold text-slate-900 mb-2">{{ titel }}</h1>
<p class="text-slate-600 mb-4"> <p class="text-slate-600 mb-4">
Die Prüfung läuft automatisch bei jeder Eintragung in die Planungsliste. Die Prüfung läuft automatisch bei jeder Eintragung in die Planungsliste.
Hier kann sie auch von Hand für einen Titel ausgeführt werden. Hier kann sie auch von Hand für einen Titel ausgeführt werden.
@@ -9,29 +9,29 @@
<div class="grid md:grid-cols-4 gap-3 mb-6 text-sm"> <div class="grid md:grid-cols-4 gap-3 mb-6 text-sm">
<div class="bg-white border border-slate-200 rounded-xl p-3"> <div class="bg-white border border-slate-200 rounded-xl p-3">
<p class="font-medium text-slate-700 mb-1">&#127962; Verlags-Konflikt</p> <p class="font-medium text-slate-700 mb-1 flex items-center gap-1.5">{% from "icons.html" import icon_gebaeude %}{{ icon_gebaeude("h-4 w-4") }} Verlags-Konflikt</p>
<p class="text-slate-500">Gleiches Spiel bei verschiedenen Verlagen/Vertrieb &rarr; Verlagsauswahl.</p> <p class="text-slate-500">Gleiches Spiel bei verschiedenen Verlagen/Vertrieb &rarr; Verlagsauswahl.</p>
</div> </div>
<div class="bg-white border border-slate-200 rounded-xl p-3"> <div class="bg-white border border-slate-200 rounded-xl p-3">
<p class="font-medium text-slate-700 mb-1">&#127463; Titel-Varianten</p> <p class="font-medium text-slate-700 mb-1 flex items-center gap-1.5">{% from "icons.html" import icon_tag %}{{ icon_tag("h-4 w-4") }} Titel-Varianten</p>
<p class="text-slate-500">Deutsche Version wird bevorzugt (Heuristik + BGG Alternate-Names).</p> <p class="text-slate-500">Deutsche Version wird bevorzugt (Heuristik + BGG Alternate-Names).</p>
</div> </div>
<div class="bg-white border border-slate-200 rounded-xl p-3"> <div class="bg-white border border-slate-200 rounded-xl p-3">
<p class="font-medium text-slate-700 mb-1">&#128214; Bereits besprochen</p> <p class="font-medium text-slate-700 mb-1 flex items-center gap-1.5">{% from "icons.html" import icon_buch %}{{ icon_buch("h-4 w-4") }} Bereits besprochen</p>
<p class="text-slate-500">Spiel oder Vorg&auml;nger (BGG-Erweiterungs-Relation + Fuzzy-Match) schon in der Redaktion.</p> <p class="text-slate-500">Spiel oder Vorg&auml;nger (BGG-Erweiterungs-Relation + Fuzzy-Match) schon in der Redaktion.</p>
</div> </div>
<div class="bg-white border border-slate-200 rounded-xl p-3"> <div class="bg-white border border-slate-200 rounded-xl p-3">
<p class="font-medium text-slate-700 mb-1">&#128203; Schon in Planung</p> <p class="font-medium text-slate-700 mb-1 flex items-center gap-1.5">{% from "icons.html" import icon_dokument %}{{ icon_dokument("h-4 w-4") }} Schon in Planung</p>
<p class="text-slate-500">Titel steht bereits in der Planungsliste eines anderen Rezensenten.</p> <p class="text-slate-500">Titel steht bereits in der Planungsliste eines anderen Rezensenten.</p>
</div> </div>
</div> </div>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6"> <div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<h2 class="font-semibold mb-3">Titel pr&uuml;fen</h2> <h2 class="text-base font-semibold text-slate-800 mb-3">Titel pr&uuml;fen</h2>
<form method="post" action="/dedup/pruefen" class="flex flex-wrap items-end gap-3"> <form method="post" action="/dedup/pruefen" class="flex flex-wrap items-end gap-3">
<div> <div>
<label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label> <label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label>
<input type="text" id="titel" name="titel" required value="{{ ergebnis.titel if ergebnis else '' }}" <input type="text" id="titel" name="titel" required aria-required="true" value="{{ ergebnis.titel if ergebnis else '' }}"
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"> 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>
<div> <div>
@@ -51,7 +51,7 @@
</form> </form>
{% if fehler %} {% if fehler %}
<div class="mt-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div> <div class="mt-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm" role="alert" tabindex="-1">{{ fehler }}</div>
{% endif %} {% endif %}
{% if meldung %} {% if meldung %}
<div class="mt-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div> <div class="mt-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div>
@@ -60,8 +60,8 @@
{% if ergebnis %} {% if ergebnis %}
<div class="mt-4 rounded-lg border p-4 text-sm <div class="mt-4 rounded-lg border p-4 text-sm
{% if ergebnis.hat_konflikte %}bg-amber-50 border-amber-300{% else %}bg-emerald-50 border-emerald-200{% endif %}"> {% if ergebnis.hat_konflikte %}bg-amber-50 border-amber-300{% else %}bg-emerald-50 border-emerald-200{% endif %}">
<p class="font-medium mb-2 {% if ergebnis.hat_konflikte %}text-amber-800{% else %}text-emerald-800{% endif %}"> <p class="font-medium mb-2 flex items-center gap-1.5 {% if ergebnis.hat_konflikte %}text-amber-800{% else %}text-emerald-800{% endif %}">
{% if ergebnis.hat_konflikte %}&#9888; {{ ergebnis.konflikte|length }} Treffer f&uuml;r „{{ ergebnis.titel }}“{% else %}&#10003; Keine Treffer f&uuml;r „{{ ergebnis.titel }}“{% endif %} {% if ergebnis.hat_konflikte %}{% from "icons.html" import icon_warnung %}{{ icon_warnung("h-4 w-4") }} {{ ergebnis.konflikte|length }} Treffer f&uuml;r „{{ ergebnis.titel }}“{% else %}{% from "icons.html" import icon_check %}{{ icon_check("h-4 w-4") }} Keine Treffer f&uuml;r „{{ ergebnis.titel }}“{% endif %}
</p> </p>
<ul class="list-disc list-inside space-y-1 text-slate-700"> <ul class="list-disc list-inside space-y-1 text-slate-700">
{% for konflikt in ergebnis.konflikte %} {% for konflikt in ergebnis.konflikte %}
@@ -83,16 +83,16 @@
</div> </div>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-x-auto"> <div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-x-auto">
<h2 class="font-semibold px-4 pt-4 pb-2">Letzte Pr&uuml;fungen</h2> <h2 class="text-base font-semibold text-slate-800 px-4 pt-4 pb-2">Letzte Pr&uuml;fungen</h2>
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="bg-slate-50 text-left text-slate-500">
<tr> <tr>
<th class="px-4 py-2 font-medium">Zeitpunkt</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Zeitpunkt</th>
<th class="px-4 py-2 font-medium">Titel</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Titel</th>
<th class="px-4 py-2 font-medium">Verlag</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Verlag</th>
<th class="px-4 py-2 font-medium">BGG-ID</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">BGG-ID</th>
<th class="px-4 py-2 font-medium">Gepr&uuml;ft von</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Gepr&uuml;ft von</th>
<th class="px-4 py-2 font-medium">Befund</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Befund</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@@ -109,7 +109,7 @@
<span class="inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs mr-1">{{ konflikt_anzeige.get(konflikt.art, konflikt.art) }}</span> <span class="inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs mr-1">{{ konflikt_anzeige.get(konflikt.art, konflikt.art) }}</span>
{% endfor %} {% endfor %}
{% else %} {% else %}
<span class="text-emerald-700">Keine Treffer</span> <span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 text-xs">{% from "icons.html" import icon_check %}{{ icon_check("h-3.5 w-3.5") }} Keine Treffer</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@@ -36,7 +36,13 @@ from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_roles, require_user from redaktionskern.auth.deps import get_db, require_roles, require_user
from redaktionskern.auth.models import Role, User from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext from redaktionskern.contracts import (
BasePlugin,
DashboardKarte,
Migration,
NavEntry,
PluginContext,
)
# Bewusst `from .dienst import …` (wie `from .models import …` überall sonst): # Bewusst `from .dienst import …` (wie `from .models import …` überall sonst):
# `from . import dienst` funktioniert unter dem dateibasierten Plugin-Loader # `from . import dienst` funktioniert unter dem dateibasierten Plugin-Loader
@@ -116,6 +122,39 @@ class ErinnerungPlugin(BasePlugin):
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/erinnerung")] return [NavEntry(label=self.title, url="/erinnerung")]
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: die nächsten Redaktionsschlüsse (für alle Rollen)."""
from .dienst import heute
with self.context.session_factory() as db:
ausgaben = db.scalars(
select(Ausgabe)
.where(Ausgabe.redaktionsschluss >= heute())
.order_by(Ausgabe.redaktionsschluss)
.limit(3)
).all()
if not ausgaben:
return [
DashboardKarte(
titel="Nächste Redaktionsschlüsse",
beschreibung="Aktuell ist kein Redaktionsschluss geplant.",
link_url="/erinnerung",
)
]
zeilen = tuple(
f"{a.redaktionsschluss.strftime('%d.%m.%Y')}{a.name}"
for a in ausgaben
)
return [
DashboardKarte(
titel="Nächste Redaktionsschlüsse",
wert=str(len(ausgaben)),
zeilen=zeilen,
link_url="/erinnerung",
link_label="Alle Ausgaben",
)
]
def on_load(self, context: PluginContext) -> None: def on_load(self, context: PluginContext) -> None:
super().on_load(context) super().on_load(context)
import os import os

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">Ausgabe bearbeiten</h1> <h1 class="text-2xl font-bold text-slate-900 mb-2">Ausgabe bearbeiten</h1>
<p class="text-slate-600 mb-4">&bdquo;{{ ausgabe.name }}&ldquo; &middot; Redaktionsschluss: <p class="text-slate-600 mb-4">&bdquo;{{ ausgabe.name }}&ldquo; &middot; Redaktionsschluss:
{{ ausgabe.redaktionsschluss.strftime('%d.%m.%Y') }} {{ ausgabe.redaktionsschluss.strftime('%d.%m.%Y') }}
</p> </p>
@@ -10,12 +10,12 @@
class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4"> class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4">
<div> <div>
<label for="name" class="block text-xs font-medium text-slate-500 mb-1">Name/Titel der Ausgabe *</label> <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 }}" <input type="text" id="name" name="name" required aria-required="true" 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"> 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>
<div> <div>
<label for="redaktionsschluss" class="block text-xs font-medium text-slate-500 mb-1">Redaktionsschluss *</label> <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() }}" <input type="date" id="redaktionsschluss" name="redaktionsschluss" required aria-required="true" 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"> 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>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">

View File

@@ -1,7 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1> <div x-data="{ loeschFormular: null, loeschTitel: '' }">
<h1 class="text-2xl font-bold text-slate-900 mb-2">{{ titel }}</h1>
<p class="text-slate-600 mb-4"> <p class="text-slate-600 mb-4">
Redaktionsschl&uuml;sse der kommenden Ausgaben. Vier Wochen vor jedem Redaktionsschl&uuml;sse der kommenden Ausgaben. Vier Wochen vor jedem
Redaktionsschluss erhalten alle Rezensenten eine Erinnerung, die noch offene Redaktionsschluss erhalten alle Rezensenten eine Erinnerung, die noch offene
@@ -12,21 +13,21 @@
<div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div> <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 %} {% endif %}
{% if fehler %} {% 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> <div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm" role="alert" tabindex="-1">{{ fehler }}</div>
{% endif %} {% endif %}
{% if ist_admin %} {% if ist_admin %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6"> <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> <h2 class="text-base font-semibold text-slate-800 mb-3">Neue Ausgabe anlegen</h2>
<form method="post" action="/erinnerung/ausgaben" class="flex flex-wrap items-end gap-3"> <form method="post" action="/erinnerung/ausgaben" class="flex flex-wrap items-end gap-3">
<div> <div>
<label for="name" class="block text-xs font-medium text-slate-500 mb-1">Name/Titel der Ausgabe *</label> <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" <input type="text" id="name" name="name" required aria-required="true" 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"> 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>
<div> <div>
<label for="redaktionsschluss" class="block text-xs font-medium text-slate-500 mb-1">Redaktionsschluss *</label> <label for="redaktionsschluss" class="block text-xs font-medium text-slate-500 mb-1">Redaktionsschluss *</label>
<input type="date" id="redaktionsschluss" name="redaktionsschluss" required <input type="date" id="redaktionsschluss" name="redaktionsschluss" required aria-required="true"
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"> 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>
<button type="submit" <button type="submit"
@@ -42,11 +43,11 @@
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="bg-slate-50 text-left text-slate-500">
<tr> <tr>
<th class="px-4 py-3 font-medium">Ausgabe</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Ausgabe</th>
<th class="px-4 py-3 font-medium">Redaktionsschluss</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Redaktionsschluss</th>
<th class="px-4 py-3 font-medium">Restzeit</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Restzeit</th>
<th class="px-4 py-3 font-medium">Erinnerungen</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Erinnerungen</th>
{% if ist_admin %}<th class="px-4 py-3 font-medium">Aktionen</th>{% endif %} {% if ist_admin %}<th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Aktionen</th>{% endif %}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@@ -75,7 +76,9 @@
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-1.5">
<a href="/erinnerung/ausgaben/{{ zeile.ausgabe.id }}/bearbeiten" <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> 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"> <form method="post" action="/erinnerung/ausgaben/{{ zeile.ausgabe.id }}/loeschen"
data-titel="{{ zeile.ausgabe.name }}"
@submit.prevent="loeschTitel = $event.target.dataset.titel; loeschFormular = $event.target">
<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> <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> </form>
</div> </div>
@@ -97,7 +100,7 @@
{% if ist_admin %} {% 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="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"> <div class="mr-auto">
<h2 class="font-semibold">Sofort pr&uuml;fen</h2> <h2 class="text-base font-semibold text-slate-800">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> <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> </div>
<form method="post" action="/erinnerung/pruefen"> <form method="post" action="/erinnerung/pruefen">
@@ -109,16 +112,16 @@
</div> </div>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6"> <div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<h2 class="font-semibold mb-3">Erinnerungsstatus</h2> <h2 class="text-base font-semibold text-slate-800 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> <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"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="bg-slate-50 text-left text-slate-500">
<tr> <tr>
<th class="px-4 py-3 font-medium">Ausgabe</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Ausgabe</th>
<th class="px-4 py-3 font-medium">Rezensent</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Rezensent</th>
<th class="px-4 py-3 font-medium">Kan&auml;le</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Kan&auml;le</th>
<th class="px-4 py-3 font-medium">Erinnert am</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Erinnert am</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@@ -141,4 +144,25 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Lösch-Bestätigungsdialog (Progressive Enhancement: ohne JavaScript
sendet das Formular sofort, mit Alpine öffnet sich dieser Dialog). -->
<div x-show="loeschFormular !== null" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog" aria-modal="true" aria-labelledby="loesch-dialog-titel">
<div class="absolute inset-0 bg-slate-900/50" @click="loeschFormular = null"></div>
<div class="relative bg-white rounded-xl shadow-xl max-w-sm w-full p-5">
<h2 id="loesch-dialog-titel" class="text-base font-semibold text-slate-800 mb-1">Eintrag löschen?</h2>
<p class="text-sm text-slate-600 mb-4">
&bdquo;<span x-text="loeschTitel"></span>&ldquo; wird endgültig gelöscht.
Das lässt sich nicht rückgängig machen.
</p>
<div class="flex justify-end gap-2">
<button type="button" @click="loeschFormular = null"
class="px-4 py-2 rounded-lg border border-slate-300 text-sm hover:bg-slate-50">Abbrechen</button>
<button type="button" @click="if (loeschFormular) { loeschFormular.submit(); }"
class="px-4 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium">Löschen</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1> <h1 class="text-2xl font-bold text-slate-900 mb-2">{{ titel }}</h1>
<p class="text-slate-600 mb-4 max-w-3xl"> <p class="text-slate-600 mb-4 max-w-3xl">
Redaktionslisten als Datei herunterladen: Redaktionslisten als Datei herunterladen:
<strong>CSV</strong> (Semikolon-getrennt, UTF-8 mit BOM &mdash; direkt in Excel <strong>CSV</strong> (Semikolon-getrennt, UTF-8 mit BOM &mdash; direkt in Excel
@@ -11,7 +11,7 @@
</p> </p>
{% if fehler %} {% 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> <div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm" role="alert" tabindex="-1">{{ fehler }}</div>
{% endif %} {% endif %}
<div class="grid gap-4 md:grid-cols-3 mb-6"> <div class="grid gap-4 md:grid-cols-3 mb-6">

View File

@@ -27,15 +27,22 @@ import logging
import os import os
import threading import threading
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from urllib.parse import quote from math import ceil
from urllib.parse import quote, urlencode
from fastapi import Depends, Form, Request from fastapi import Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from redaktionskern.auth.deps import require_roles, require_user from redaktionskern.auth.deps import require_roles, require_user
from redaktionskern.auth.models import Role, User from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext from redaktionskern.contracts import (
BasePlugin,
DashboardKarte,
Migration,
NavEntry,
PluginContext,
)
from .bgg import BggClient, BggFehler from .bgg import BggClient, BggFehler
from .models import Neuheit, QuellenStatus from .models import Neuheit, QuellenStatus
@@ -87,6 +94,8 @@ class NeuheitenPlugin(BasePlugin):
self._quellen_sperre = threading.Lock() self._quellen_sperre = threading.Lock()
self._suchbegriffe: list[str] = [] self._suchbegriffe: list[str] = []
self._max_treffer_pro_suche = 25 self._max_treffer_pro_suche = 25
self._jahr_filter = True
self._deutscher_titel_filter = True
@self.router.get("/neuheiten") @self.router.get("/neuheiten")
def seite( def seite(
@@ -94,14 +103,18 @@ class NeuheitenPlugin(BasePlugin):
user: User = Depends(require_user), user: User = Depends(require_user),
q: str = "", q: str = "",
status: str = "", status: str = "",
jahr: int | None = None,
sort: str = STANDARD_SORTIERUNG[0], sort: str = STANDARD_SORTIERUNG[0],
richtung: str = STANDARD_SORTIERUNG[1], richtung: str = STANDARD_SORTIERUNG[1],
meldung: str = "", meldung: str = "",
fehler: str = "", fehler: str = "",
seite_nr: int = 1,
): ):
spalte = SORTIERBAR.get(sort, SORTIERBAR[STANDARD_SORTIERUNG[0]]) spalte = SORTIERBAR.get(sort, SORTIERBAR[STANDARD_SORTIERUNG[0]])
abwaerts = richtung == "ab" if sort in SORTIERBAR else False abwaerts = richtung == "ab" if sort in SORTIERBAR else False
spalte_sortiert = spalte.desc() if abwaerts else spalte.asc() spalte_sortiert = spalte.desc() if abwaerts else spalte.asc()
pro_seite = 50
seite_aktuell = max(seite_nr, 1)
with self.context.session_factory() as db: with self.context.session_factory() as db:
abfrage = select(Neuheit) abfrage = select(Neuheit)
@@ -116,8 +129,33 @@ class NeuheitenPlugin(BasePlugin):
) )
if status.strip(): if status.strip():
abfrage = abfrage.where(Neuheit.status == status.strip()) abfrage = abfrage.where(Neuheit.status == status.strip())
# Erscheinungsjahr-Filter: Standard „aktuelles Jahr und neuer“.
jahr_standard = datetime.now(timezone.utc).year
if jahr is None:
jahr_filter = jahr_standard
abfrage = abfrage.where(
or_(
Neuheit.erscheinungsjahr >= jahr_standard,
Neuheit.erscheinungsjahr.is_(None), # ohne Jahr nicht wegfiltern
)
)
else:
jahr_filter = jahr
abfrage = abfrage.where(Neuheit.erscheinungsjahr >= jahr)
jahr_optionen = [
j for j in db.scalars(
select(Neuheit.erscheinungsjahr).distinct().order_by(
Neuheit.erscheinungsjahr.desc()
)
) if j is not None
]
gesamt = db.scalar(select(func.count()).select_from(abfrage.subquery())) or 0
gesamt_seiten = max(1, ceil(gesamt / pro_seite))
seite_aktuell = min(seite_aktuell, gesamt_seiten)
eintraege = db.scalars( eintraege = db.scalars(
abfrage.order_by(spalte_sortiert, Neuheit.id.asc()) abfrage.order_by(spalte_sortiert, Neuheit.id.asc())
.offset((seite_aktuell - 1) * pro_seite)
.limit(pro_seite)
).all() ).all()
status_optionen = [ status_optionen = [
zeile for zeile in db.scalars( zeile for zeile in db.scalars(
@@ -126,12 +164,27 @@ class NeuheitenPlugin(BasePlugin):
] ]
quellen_zeilen = self._quellen_uebersicht(db) quellen_zeilen = self._quellen_uebersicht(db)
def _seiten_url(nr: int) -> str:
parameter = {
"q": q,
"status": status,
"sort": sort if sort in SORTIERBAR else STANDARD_SORTIERUNG[0],
"richtung": "ab" if abwaerts else "auf",
"seite_nr": str(nr),
}
if jahr is not None:
parameter["jahr"] = str(jahr)
return f"/neuheiten?{urlencode(parameter)}"
kontext = { kontext = {
"user": user, "user": user,
"titel": self.title, "titel": self.title,
"eintraege": eintraege, "eintraege": eintraege,
"q": q, "q": q,
"status_filter": status, "status_filter": status,
"jahr_filter": jahr_filter,
"jahr_standard": jahr_standard,
"jahr_optionen": jahr_optionen,
"sort": sort if sort in SORTIERBAR else STANDARD_SORTIERUNG[0], "sort": sort if sort in SORTIERBAR else STANDARD_SORTIERUNG[0],
"richtung": "ab" if abwaerts else "auf", "richtung": "ab" if abwaerts else "auf",
"status_optionen": status_optionen, "status_optionen": status_optionen,
@@ -143,7 +196,10 @@ class NeuheitenPlugin(BasePlugin):
), ),
"suchbegriffe": ", ".join(self._suchbegriffe) or "", "suchbegriffe": ", ".join(self._suchbegriffe) or "",
"sync_aktiv": self._scheduler is not None and self._scheduler.running, "sync_aktiv": self._scheduler is not None and self._scheduler.running,
"anzahl": len(eintraege), "anzahl": gesamt,
"seite": seite_aktuell,
"gesamt_seiten": gesamt_seiten,
"seiten_url": _seiten_url,
"quellen_zeilen": quellen_zeilen, "quellen_zeilen": quellen_zeilen,
"quellen_sync_aktiv": ( "quellen_sync_aktiv": (
os.environ.get("SPIELE_NEUHEITEN_QUELLEN_SYNC_AKTIV", "1").strip() os.environ.get("SPIELE_NEUHEITEN_QUELLEN_SYNC_AKTIV", "1").strip()
@@ -184,12 +240,41 @@ class NeuheitenPlugin(BasePlugin):
@self.router.post("/neuheiten/quellen-sync") @self.router.post("/neuheiten/quellen-sync")
def quellen_jetzt_synchronisieren( def quellen_jetzt_synchronisieren(
user: User = Depends(require_roles(Role.ADMIN.value, Role.REDAKTEUR.value)), user: User = Depends(require_roles(Role.ADMIN.value, Role.REDAKTEUR.value)),
quelle: str = Form(""),
): ):
"""Gesamt-Sync oder Einzel-Quellen-Sync (`quelle` = Quellname)."""
if quelle:
zeilen = self._quellen_sync_ausfuehren(nur_quellen={quelle})
ziel = f"/neuheiten?meldung={quote('Web-Quelle „' + quelle + '“ synchronisiert: ' + ' · '.join(z.als_text() for z in zeilen))}"
else:
zeilen = self._quellen_sync_ausfuehren() zeilen = self._quellen_sync_ausfuehren()
text = " · ".join(z.als_text() for z in zeilen) text = " · ".join(z.als_text() for z in zeilen)
ziel = f"/neuheiten?meldung={quote(f'Web-Quellen-Sync abgeschlossen: {text}')}" ziel = f"/neuheiten?meldung={quote(f'Web-Quellen-Sync abgeschlossen: {text}')}"
return RedirectResponse(ziel[:2000], status_code=303) return RedirectResponse(ziel[:2000], status_code=303)
@self.router.post("/neuheiten/quellen/{quellen_name}/schalter")
def quellen_schalter(
quellen_name: str,
user: User = Depends(require_roles(Role.ADMIN.value, Role.REDAKTEUR.value)),
aktiv: str = Form(...),
):
"""An/Aus-Schalter einer Web-Quelle (übersteuert den Env-Standard)."""
if quellen_name not in {k.name for k in ADAPTER_KLASSEN}:
raise HTTPException(status_code=404, detail="Unbekannte Quelle")
assert self.context is not None and self.context.session_factory is not None
with self.context.session_factory() as db:
eintrag = db.scalar(
select(QuellenStatus).where(QuellenStatus.quelle == quellen_name)
)
if eintrag is None:
eintrag = QuellenStatus(quelle=quellen_name, url="")
db.add(eintrag)
eintrag.aktiv_schalter = aktiv == "1"
db.commit()
zustand = "eingeschaltet" if aktiv == "1" else "ausgeschaltet"
ziel = f"/neuheiten?meldung={quote(f'Quelle „{quellen_name}{zustand}.')}"
return RedirectResponse(ziel, status_code=303)
def _quellen_uebersicht(self, db) -> list[dict]: def _quellen_uebersicht(self, db) -> list[dict]:
"""Zeilen der Sync-Übersicht: Adapter + letzter Lauf pro Quelle.""" """Zeilen der Sync-Übersicht: Adapter + letzter Lauf pro Quelle."""
status_nach_quelle = { status_nach_quelle = {
@@ -199,12 +284,18 @@ class NeuheitenPlugin(BasePlugin):
ansichten = [] ansichten = []
for klasse in ADAPTER_KLASSEN: for klasse in ADAPTER_KLASSEN:
status = status_nach_quelle.get(klasse.name) status = status_nach_quelle.get(klasse.name)
schalter = status.aktiv_schalter if status is not None else None
aktiv = (
schalter
if schalter is not None
else quelle_aktiv(klasse.name)
)
ansichten.append( ansichten.append(
{ {
"name": klasse.name, "name": klasse.name,
"anzeigename": klasse.anzeigename, "anzeigename": klasse.anzeigename,
"url": klasse.start_urls[0] if klasse.start_urls else "", "url": klasse.start_urls[0] if klasse.start_urls else "",
"aktiv": quelle_aktiv(klasse.name), "aktiv": aktiv,
"letzte_laufzeit": status.letzte_laufzeit if status else None, "letzte_laufzeit": status.letzte_laufzeit if status else None,
"dauer_sekunden": status.dauer_sekunden if status else None, "dauer_sekunden": status.dauer_sekunden if status else None,
"neu": status.anzahl_neu if status else 0, "neu": status.anzahl_neu if status else 0,
@@ -255,26 +346,134 @@ class NeuheitenPlugin(BasePlugin):
def quellen_status_tabelle(conn) -> None: def quellen_status_tabelle(conn) -> None:
QuellenStatus.__table__.create(conn, checkfirst=True) QuellenStatus.__table__.create(conn, checkfirst=True)
def bild_url_spalte(conn) -> None:
"""Migration 0004: Spalte bild_url für Coverbilder (nullable).
`ALTER TABLE … ADD COLUMN` mit nullable VARCHAR ohne Default ist
SQLite- und Postgres-portabel und erhält bestehende Zeilen.
"""
info = conn.exec_driver_sql("PRAGMA table_info(neuheiten)").fetchall()
vorhanden = any(zeile[1] == "bild_url" for zeile in info)
if conn.dialect.name == "sqlite" and vorhanden:
return
if conn.dialect.name != "sqlite":
existiert = conn.exec_driver_sql(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'neuheiten' AND column_name = 'bild_url'"
).scalar()
if existiert is not None:
return
conn.exec_driver_sql(
"ALTER TABLE neuheiten ADD COLUMN bild_url VARCHAR(500)"
)
def quellen_url_spalte(conn) -> None:
"""Migration 0005: Spalte quellen_url für den Link zur Quelle."""
info = conn.exec_driver_sql("PRAGMA table_info(neuheiten)").fetchall()
vorhanden = any(zeile[1] == "quellen_url" for zeile in info)
if conn.dialect.name == "sqlite" and vorhanden:
return
if conn.dialect.name != "sqlite":
existiert = conn.exec_driver_sql(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'neuheiten' AND column_name = 'quellen_url'"
).scalar()
if existiert is not None:
return
conn.exec_driver_sql(
"ALTER TABLE neuheiten ADD COLUMN quellen_url VARCHAR(500)"
)
def aktiv_schalter_spalte(conn) -> None:
"""Migration 0006: UI-Schalter pro Web-Quelle (nullable Boolean)."""
info = conn.exec_driver_sql(
"PRAGMA table_info(neuheiten_quellen_status)"
).fetchall()
vorhanden = any(zeile[1] == "aktiv_schalter" for zeile in info)
if conn.dialect.name == "sqlite" and vorhanden:
return
if conn.dialect.name != "sqlite":
existiert = conn.exec_driver_sql(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'neuheiten_quellen_status' "
"AND column_name = 'aktiv_schalter'"
).scalar()
if existiert is not None:
return
conn.exec_driver_sql(
"ALTER TABLE neuheiten_quellen_status ADD COLUMN aktiv_schalter BOOLEAN"
)
return [ return [
Migration(version="0001_neuheiten_tabelle", up=neuheiten_tabelle), Migration(version="0001_neuheiten_tabelle", up=neuheiten_tabelle),
Migration(version="0002_bgg_id_nullable", up=bgg_id_nullable), Migration(version="0002_bgg_id_nullable", up=bgg_id_nullable),
Migration(version="0003_quellen_status", up=quellen_status_tabelle), Migration(version="0003_quellen_status", up=quellen_status_tabelle),
Migration(version="0004_bild_url", up=bild_url_spalte),
Migration(version="0005_quellen_url", up=quellen_url_spalte),
Migration(version="0006_aktiv_schalter", up=aktiv_schalter_spalte),
] ]
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/neuheiten")] return [NavEntry(label=self.title, url="/neuheiten")]
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: Neuheiten-Übersicht (Redaktion) + System-Status (Admin)."""
if user.role not in (Role.ADMIN.value, Role.REDAKTEUR.value):
return []
with self.context.session_factory() as db:
neu = db.scalar(
select(func.count())
.select_from(Neuheit)
.where(Neuheit.status == "neuheit")
) or 0
karten = [
DashboardKarte(
titel="Neuheiten",
wert=str(neu),
beschreibung="Offene Einträge in der Neuheitenliste",
link_url="/neuheiten",
link_label="Zu den Neuheiten",
ton="ok" if neu else "neutral",
)
]
if user.role == Role.ADMIN.value:
token = self._aktives_bgg_token()
sync_aktiv = self._scheduler is not None and self._scheduler.running
karten.append(
DashboardKarte(
titel="System-Status",
zeilen=(
f"BGG-API-Token: {'konfiguriert' if token else 'fehlt'}",
f"Hintergrund-Sync: {'aktiv' if sync_aktiv else 'aus'}",
),
ton="ok" if token else "fehler",
link_url="/neuheiten",
link_label="Neuheiten synchronisieren",
)
)
return karten
def on_load(self, context: PluginContext) -> None: def on_load(self, context: PluginContext) -> None:
super().on_load(context) super().on_load(context)
import os import os
self._suchbegriffe = _umgebung_liste("SPIELE_BGG_SUCHBEGRIFFE", "brettspiel") self._suchbegriffe = _umgebung_liste(
"SPIELE_BGG_SUCHBEGRIFFE",
"brettspiel, gesellschaftsspiel, familienspiel, strategiespiel, "
"kartenspiel, würfelspiel",
)
try: try:
self._max_treffer_pro_suche = int( self._max_treffer_pro_suche = int(
os.environ.get("SPIELE_BGG_MAX_TREFFER_PRO_SUCHE", "25") os.environ.get("SPIELE_BGG_MAX_TREFFER_PRO_SUCHE", "50")
) )
except ValueError: except ValueError:
self._max_treffer_pro_suche = 25 self._max_treffer_pro_suche = 50
self._jahr_filter = (
os.environ.get("SPIELE_BGG_JAHR_FILTER", "1").strip() != "0"
)
self._deutscher_titel_filter = (
os.environ.get("SPIELE_BGG_DEUTSCHE_TITEL_FILTER", "1").strip() != "0"
)
if os.environ.get("SPIELE_BGG_SYNC_AKTIV", "1").strip() == "1": if os.environ.get("SPIELE_BGG_SYNC_AKTIV", "1").strip() == "1":
self._scheduler_starten(os.environ.get("SPIELE_BGG_SYNC_INTERVALL_STUNDEN")) self._scheduler_starten(os.environ.get("SPIELE_BGG_SYNC_INTERVALL_STUNDEN"))
@@ -317,6 +516,8 @@ class NeuheitenPlugin(BasePlugin):
self.context.session_factory, self.context.session_factory,
client, client,
max_treffer_pro_suchbegriff=self._max_treffer_pro_suche, max_treffer_pro_suchbegriff=self._max_treffer_pro_suche,
jahr_filter=self._jahr_filter,
deutscher_titel_filter=self._deutscher_titel_filter,
) )
return service.synchronisiere(suchbegriffe) return service.synchronisiere(suchbegriffe)
finally: finally:
@@ -338,15 +539,25 @@ class NeuheitenPlugin(BasePlugin):
finally: finally:
self._sync_sperre.release() self._sync_sperre.release()
def _quellen_sync_ausfuehren(self) -> list[QuellenLaufZeile]: def _quellen_sync_ausfuehren(
"""Ein Lauf über alle aktiven Web-Quellen (Fehler pro Quelle isoliert).""" self, nur_quellen: set[str] | None = None
dedup = ( ) -> list[QuellenLaufZeile]:
self.context.registry.get("dedup") """Ein Lauf über alle aktiven Web-Quellen (Fehler pro Quelle isoliert).
`nur_quellen`: optional nur diese Quellnamen (manueller Einzellauf,
ignoriert den An/Aus-Schalter der Quelle).
"""
registry = (
self.context.registry
if self.context is not None and self.context.registry is not None if self.context is not None and self.context.registry is not None
else None else None
) )
service = WebQuellenSyncService(self.context.session_factory, dedup=dedup) dedup = registry.get("dedup") if registry is not None else None
return service.laufe() audit = registry.get("audit-log") if registry is not None else None
service = WebQuellenSyncService(
self.context.session_factory, dedup=dedup, audit=audit
)
return service.laufe(nur_quellen=nur_quellen)
def _quellen_sync_job(self) -> None: def _quellen_sync_job(self) -> None:
"""Hintergrund-Job: holt regelmäßig die Web-Quellen ab.""" """Hintergrund-Job: holt regelmäßig die Web-Quellen ab."""

View File

@@ -70,9 +70,34 @@ class BggSpiel:
verlag: str | None verlag: str | None
autor: str | None autor: str | None
erscheinungsjahr: int | None erscheinungsjahr: int | None
#: URL des Coverbildes — bevorzugt das größere <image>, sonst das
#: <thumbnail>; None, wenn BGG keins liefert.
bild_url: str | None = None
typ: str = TYP_BRETTSPIEL typ: str = TYP_BRETTSPIEL
def _bild_url(element: ET.Element) -> str | None:
"""Extrahiert die Coverbild-URL aus einem Thing-Item.
Bevorzugt das große `<image>`, fällt zurück auf das kleinere
`<thumbnail>`. Protokoll-relative URLs (`//cf.geekdo-images.com/…`)
werden auf https normalisiert; leere/kaputte Angaben → None.
"""
for tag in ("image", "thumbnail"):
roh = element.findtext(tag)
if roh is None:
continue
url = roh.strip()
if not url:
continue
if url.startswith("//"):
url = f"https:{url}"
if url.startswith(("http://", "https://")):
return url
return None
return None
def _attribut_wert(element: ET.Element | None, tag: str) -> str | None: def _attribut_wert(element: ET.Element | None, tag: str) -> str | None:
"""Liest `<tag><value>…</value></tag>` tolerant aus.""" """Liest `<tag><value>…</value></tag>` tolerant aus."""
if element is None: if element is None:
@@ -161,6 +186,7 @@ def parse_things(xml_daten: bytes | str) -> list[BggSpiel]:
verlag=_verketten(element, "boardgamepublisher"), verlag=_verketten(element, "boardgamepublisher"),
autor=_verketten(element, "boardgamedesigner"), autor=_verketten(element, "boardgamedesigner"),
erscheinungsjahr=_jahr(_attribut_wert(element, "yearpublished")), erscheinungsjahr=_jahr(_attribut_wert(element, "yearpublished")),
bild_url=_bild_url(element),
typ=typ, typ=typ,
) )
) )

View File

@@ -30,6 +30,10 @@ class Neuheit(Base):
# BGG-ID und speichern NULL (Migration 0002 macht die Spalte nullable, # BGG-ID und speichern NULL (Migration 0002 macht die Spalte nullable,
# Duplikate werden dort über den Titel gematcht statt über die ID). # Duplikate werden dort über den Titel gematcht statt über die ID).
bgg_id: Mapped[int | None] = mapped_column(Integer, unique=True, index=True) bgg_id: Mapped[int | None] = mapped_column(Integer, unique=True, index=True)
# URL des Coverbildes (BGG-Image/Thumbnail bzw. Bild der Web-Quelle);
# wird direkt verlinkt (Hotlinking), kein Download (Migration 0004).
bild_url: Mapped[str | None] = mapped_column(String(500))
quellen_url: Mapped[str | None] = mapped_column(String(500))
status: Mapped[str] = mapped_column(String(50), default=STATUS_NEUHEIT, index=True) status: Mapped[str] = mapped_column(String(50), default=STATUS_NEUHEIT, index=True)
quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek") quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek")
erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
@@ -63,6 +67,9 @@ class QuellenStatus(Base):
anzahl_fehler: Mapped[int] = mapped_column(Integer, default=0) anzahl_fehler: Mapped[int] = mapped_column(Integer, default=0)
fehlermeldung: Mapped[str | None] = mapped_column(Text) fehlermeldung: Mapped[str | None] = mapped_column(Text)
unveraendert: Mapped[bool] = mapped_column(Boolean, default=False) unveraendert: Mapped[bool] = mapped_column(Boolean, default=False)
#: UI-Schalter: Quelle im Sync ein-/ausgeschaltet (None = Env-Standard).
#: Übersteuert den Env-Wert SPIELE_NEUHEITEN_QUELLE_<NAME>_AKTIV.
aktiv_schalter: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
#: JSON-Objekt URL → [etag, last_modified] #: JSON-Objekt URL → [etag, last_modified]
validatoren_json: Mapped[str | None] = mapped_column(Text) validatoren_json: Mapped[str | None] = mapped_column(Text)

View File

@@ -24,6 +24,7 @@ from .basis import (
jahr_aus_datumsangabe, jahr_aus_datumsangabe,
titel_aehnlichkeit, titel_aehnlichkeit,
titel_normalisieren, titel_normalisieren,
bild_aus_element,
) )
from .brettspielbox import BrettspielboxQuelle from .brettspielbox import BrettspielboxQuelle
from .cliquenabend import CliquenabendQuelle from .cliquenabend import CliquenabendQuelle
@@ -50,6 +51,7 @@ __all__ = [
"jahr_aus_datumsangabe", "jahr_aus_datumsangabe",
"titel_aehnlichkeit", "titel_aehnlichkeit",
"titel_normalisieren", "titel_normalisieren",
"bild_aus_element",
"SpielboxQuelle", "SpielboxQuelle",
"BrettspielboxQuelle", "BrettspielboxQuelle",
"SpielEssenQuelle", "SpielEssenQuelle",

View File

@@ -21,6 +21,7 @@ die Testsuite führt keine echten Netzwerkaufrufe durch.
""" """
from __future__ import annotations from __future__ import annotations
import logging
import re import re
import time import time
import xml.etree.ElementTree # noqa: F401 (dokumentiert: keine XML-Nutzung hier) import xml.etree.ElementTree # noqa: F401 (dokumentiert: keine XML-Nutzung hier)
@@ -31,6 +32,10 @@ from typing import ClassVar
import httpx import httpx
from rapidfuzz import fuzz from rapidfuzz import fuzz
from .ki_hilfe import KiHilfe
_logger = logging.getLogger(__name__)
#: Freundlicher User-Agent für alle Redaktions-Crawler (Courtesy-Regeln). #: Freundlicher User-Agent für alle Redaktions-Crawler (Courtesy-Regeln).
USER_AGENT = "SpieleRedaktionBot/1.0 (Redaktions-Tool; Kontakt siehe Repo)" USER_AGENT = "SpieleRedaktionBot/1.0 (Redaktions-Tool; Kontakt siehe Repo)"
@@ -55,6 +60,52 @@ class QuellenTreffer:
verlag: str | None = None verlag: str | None = None
autor: str | None = None autor: str | None = None
erscheinungsdatum_oder_quartal: str | None = None erscheinungsdatum_oder_quartal: str | None = None
#: Bild-URL der Quelle (img im Listenelement), sofern vorhanden; wird
#: direkt eingebettet (Hotlinking), nicht heruntergeladen.
bild_url: str | None = None
def _bild_url_normalisieren(url: str, basis_url: str | None) -> str | None:
"""Macht aus einer rohen Bildangabe eine absolute http(s)-URL.
Protokoll-relative URLs (`//host/bild.jpg`) erhalten https voran,
pfadrelative Angaben werden gegen die Seiten-URL aufgelöst; alles
andere (data:-URIs etc.) wird verworfen.
"""
url = url.strip()
if url.startswith("//"):
url = f"https:{url}"
elif not url.startswith(("http://", "https://")):
if basis_url is None:
return None
from urllib.parse import urljoin
url = urljoin(basis_url, url)
return url if url.startswith(("http://", "https://")) else None
def bild_aus_element(element, basis_url: str | None = None) -> str | None:
"""Liest die erste sinnvolle Bild-URL aus einem HTML-Element.
Akzeptiert `src` und gängige Lazy-Loading-Attribute (`data-src`,
`data-srcset`-Erster); protokoll- und pfadrelative URLs werden gegen
`basis_url` (die Seiten-URL) aufgelöst. Kein Bild → None.
"""
if element is None:
return None
bild = element.find("img") if hasattr(element, "find") else None
if bild is None:
return None
for attribut in ("src", "data-src"):
url = bild.get(attribut)
if url and url.strip():
return _bild_url_normalisieren(url, basis_url)
srcset = bild.get("data-srcset") or bild.get("srcset")
if srcset:
erster = srcset.split(",")[0].strip().split(" ")[0]
if erster:
return _bild_url_normalisieren(erster, basis_url)
return None
@dataclass @dataclass
@@ -76,6 +127,12 @@ class SammelErgebnis:
#: True = mindestens eine Seite kam per 304 als unverändert zurück; #: True = mindestens eine Seite kam per 304 als unverändert zurück;
#: der Lauf wurde dann abgekürzt (Quelle übersprungen, kein Fehler). #: der Lauf wurde dann abgekürzt (Quelle übersprungen, kein Fehler).
unveraendert: bool = False unveraendert: bool = False
#: KI-Feldkorrekturen dieses Laufs (Protokoll fürs Audit-Log); leer bei
#: rein regelbasiertem Lauf.
ki_eingriffe: list[dict] = field(default_factory=list)
#: Von der KI vorgeschlagene und tatsächlich verfolgte Folge-URLs
#: (Struktur-Erkennung); leer bei rein regelbasiertem Lauf.
ki_folge_urls: list[str] = field(default_factory=list)
def jahr_aus_datumsangabe(angabe: str | None) -> int | None: def jahr_aus_datumsangabe(angabe: str | None) -> int | None:
@@ -236,18 +293,40 @@ class QuellenAdapter:
def __init__(self, client: WebQuellenClient) -> None: def __init__(self, client: WebQuellenClient) -> None:
self.client = client self.client = client
#: Optionale LLM-Hilfsstufe (`ki_hilfe.KiHilfe` oder gleiches Protokoll).
#: None (Standard) = rein regelbasiert; KI-Fehler blockieren den Lauf nie.
self.ki_hilfe: KiHilfe | None = None
def sammle(self) -> SammelErgebnis: def sammle(self) -> SammelErgebnis:
"""Crawlt die Quelle: Startseiten + gefundene Folge-Links (BFS).""" """Crawlt die Quelle: Startseiten + gefundene Folge-Links (BFS).
Mit gesetzter `ki_hilfe`: Bei verdächtig leer geparsten Seiten darf
das LLM Pagination-/Filter-Folge-URLs vorschlagen (nur gleiche
Domain, max. 20), unsichere Treffer werden per LLM nachgebessert
(nur titel/verlag/autor, nie die URL). Jeder KI-Fehler fällt auf den
klassischen Befund zurück.
"""
ergebnis = SammelErgebnis() ergebnis = SammelErgebnis()
warteschlange = list(self.start_urls) warteschlange = list(self.start_urls)
besucht: set[str] = set() besucht: set[str] = set()
ki_vorschlaege: set[str] = set()
while warteschlange and len(besucht) < self.max_seiten: while warteschlange and len(besucht) < self.max_seiten:
url = warteschlange.pop(0) url = warteschlange.pop(0)
if url in besucht: if url in besucht:
continue continue
besucht.add(url) besucht.add(url)
try:
antwort = self.client.hole(url) antwort = self.client.hole(url)
except Exception:
if url in ki_vorschlaege:
# Eine nicht erreichbare KI-Suggestion darf den klassischen
# Lauf nie abbrechen — überspringen statt Fehler.
_logger.warning(
"KI-vorgeschlagene URL nicht erreichbar (%s) — übersprungen.",
url,
)
continue
raise
if antwort.status_code == 304: if antwort.status_code == 304:
# Unverändert → Quelle überspringen (kein Fehler). # Unverändert → Quelle überspringen (kein Fehler).
ergebnis.unveraendert = True ergebnis.unveraendert = True
@@ -258,8 +337,48 @@ class QuellenAdapter:
for link in folge_links: for link in folge_links:
if link not in besucht: if link not in besucht:
warteschlange.append(link) warteschlange.append(link)
if self.ki_hilfe is not None:
for link in self._ki_folge_urls(treffer, antwort.content, url, besucht):
if link not in besucht and link not in warteschlange:
warteschlange.append(link)
ki_vorschlaege.add(link)
ergebnis.ki_folge_urls.append(link)
if self.ki_hilfe is not None:
self._ki_nachbessern(ergebnis)
return ergebnis return ergebnis
def _ki_folge_urls(
self,
treffer: list[QuellenTreffer],
inhalt: bytes,
url: str,
besucht: set[str],
) -> list[str]:
"""Struktur-Erkennung der Hilfsstufe (best effort — wirft nicht)."""
try:
return self.ki_hilfe.folge_urls_bei_bedarf(
len(treffer), inhalt, url, besucht
)
except Exception:
_logger.exception(
"KI-Struktur-Erkennung fehlgeschlagen (%s) — klassischer Lauf bleibt.",
url,
)
return []
def _ki_nachbessern(self, ergebnis: SammelErgebnis) -> None:
"""Feld-Korrektur unsicherer Treffer (best effort — wirft nicht)."""
try:
korrigiert, eingriffe = self.ki_hilfe.treffer_nachbessern(ergebnis.treffer)
except Exception:
_logger.exception(
"KI-Feldkorrektur fehlgeschlagen — klassischer Parserbefund bleibt."
)
return
if korrigiert is not None:
ergebnis.treffer = korrigiert
ergebnis.ki_eingriffe = eingriffe or []
def seite_verarbeiten( def seite_verarbeiten(
self, inhalt: bytes, url: str self, inhalt: bytes, url: str
) -> tuple[list[QuellenTreffer], list[str]]: ) -> tuple[list[QuellenTreffer], list[str]]:

View File

@@ -15,7 +15,7 @@ import re
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from .basis import QuellenAdapter, QuellenTreffer from .basis import QuellenAdapter, QuellenTreffer, bild_aus_element
TITEL_VERLAG_MUSTER = re.compile(r"^\s*(.+?)\s*\(([^()]*)\)\s*$") TITEL_VERLAG_MUSTER = re.compile(r"^\s*(.+?)\s*\(([^()]*)\)\s*$")
@@ -50,6 +50,7 @@ class BrettspielboxQuelle(QuellenAdapter):
autor=None, autor=None,
erscheinungsdatum_oder_quartal=None, erscheinungsdatum_oder_quartal=None,
quellen_url=quellen_url, quellen_url=quellen_url,
bild_url=bild_aus_element(eintrag, url),
) )
) )
return treffer, [] return treffer, []

View File

@@ -10,11 +10,16 @@ Die Seite ist nicht paginiert.
""" """
from __future__ import annotations from __future__ import annotations
import logging
import re import re
from urllib.parse import urljoin
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from bs4.element import Tag
from .basis import QuellenAdapter, QuellenTreffer from .basis import QuellenAdapter, QuellenTreffer, bild_aus_element
_logger = logging.getLogger(__name__)
DATUM_MUSTER = re.compile(r"\d{2}\.\d{2}\.(\d{2})\b") DATUM_MUSTER = re.compile(r"\d{2}\.\d{2}\.(\d{2})\b")
BASIS_URL = "https://www.cliquenabend.de" BASIS_URL = "https://www.cliquenabend.de"
@@ -40,6 +45,74 @@ class CliquenabendQuelle(QuellenAdapter):
treffer.append(self._zeile(zellen, url)) treffer.append(self._zeile(zellen, url))
return [t for t in treffer if t is not None], [] return [t for t in treffer if t is not None], []
def _detailseite(self, spiel_url: str):
"""Lädt eine Spieldetailseite und gibt das BeautifulSoup-Dokument zurück.
Fehler (404, Strukturänderung) führen zu None und blockieren nie.
"""
try:
antwort = self.client.hole(spiel_url)
except Exception:
_logger.warning("Cliquenabend: Detailseite nicht erreichbar (%s)", spiel_url)
return None
if antwort.status_code != 200:
return None
return BeautifulSoup(antwort.content, "html.parser")
def _release_jahr(self, spiel_url: str) -> str | None:
"""Liest das Release-Feld von der Spieldetailseite.
Die Detailseite (…/spiele/….html) enthält ein Feld
`<b>Release:</b><br> JAHR <br>` — meist nur das Jahr (z. B.
„2025“), gelegentlich ein genaues Datum. Liefert die rohe Angabe
zurück; `jahr_aus_datumsangabe` leitet daraus später das Jahr ab.
"""
suppe = self._detailseite(spiel_url)
if suppe is None:
return None
return self._release_jahr_aus_suppe(suppe)
def _release_jahr_aus_suppe(self, suppe) -> str | None:
fett = suppe.find(
"b", string=lambda s: isinstance(s, str) and s.strip() == "Release:"
)
if not isinstance(fett, Tag):
return None
# Wert steht direkt hinter dem Label (<br>-getrennt); der nächste
# String-Knoten nach dem Label-Element (nicht darin) ist der Wert.
text = fett.next_sibling
while isinstance(text, Tag):
text = text.next_sibling
while isinstance(text, str) and not text.strip():
text = text.next_sibling
while isinstance(text, Tag):
text = text.next_sibling
if not isinstance(text, str):
return None
wert = text.strip()
return wert or None
def _cover_bild(self, spiel_url: str) -> str | None:
"""Liest das Cover-Bild von der Spieldetailseite."""
suppe = self._detailseite(spiel_url)
if suppe is None:
return None
return self._cover_bild_aus_suppe(suppe)
def _cover_bild_aus_suppe(self, suppe) -> str | None:
"""Die Detailseite zeigt das Cover in der rechten Leiste als
`<a href="[diese Spielseite]"><img src="/images/db/ID_360x240.jpg">`.
Liefert die absolute Bild-URL oder None.
"""
for bild in suppe.find_all("img", src=True):
src = str(bild["src"])
if "/images/db/" not in src:
continue # Template-Grafiken (Logo, Pfeile etc.) auslassen
eltern_link = bild.find_parent("a", href=re.compile(r"^/spiele/"))
if eltern_link is not None:
return urljoin(BASIS_URL, src)
return None
def _zeile(self, zellen, url: str) -> QuellenTreffer | None: def _zeile(self, zellen, url: str) -> QuellenTreffer | None:
spiel_zelle = zellen[3] spiel_zelle = zellen[3]
titel_link = spiel_zelle.find("a", href=re.compile(r"^/spiele/")) titel_link = spiel_zelle.find("a", href=re.compile(r"^/spiele/"))
@@ -50,8 +123,6 @@ class CliquenabendQuelle(QuellenAdapter):
titel = (fett or titel_link).get_text(" ", strip=True) titel = (fett or titel_link).get_text(" ", strip=True)
href = titel_link.get("href") href = titel_link.get("href")
if href: if href:
from urllib.parse import urljoin
quellen_url = urljoin(BASIS_URL, href) quellen_url = urljoin(BASIS_URL, href)
if not titel: if not titel:
return None return None
@@ -60,18 +131,36 @@ class CliquenabendQuelle(QuellenAdapter):
autor = zellen[4].get_text(" ", strip=True) or None autor = zellen[4].get_text(" ", strip=True) or None
datum = None datum = None
bild_url = bild_aus_element(spiel_zelle, quellen_url)
jahr_text = zellen[0].get_text(strip=True) jahr_text = zellen[0].get_text(strip=True)
fund = DATUM_MUSTER.search(jahr_text) fund = DATUM_MUSTER.search(jahr_text)
if fund: if fund:
# Datum so übernehmen, wie es in der Quelle steht (z. B. „15.10.25“). # Datum so übernehmen, wie es in der Quelle steht (z. B. „15.10.25“).
datum = fund.group(0) datum = fund.group(0)
# Release-Feld und Cover-Bild der Detailseite haben Vorrang: dort
# steht das echte Erscheinungsjahr/-datum bzw. das Cover statt des
# Update-Datums bzw. des Messe-Fotos aus der Tabelle.
if titel_link is not None:
href = titel_link.get("href")
if href:
detail_url = urljoin(BASIS_URL, href)
suppe_detail = self._detailseite(detail_url)
if suppe_detail is not None:
release = self._release_jahr_aus_suppe(suppe_detail)
if release:
datum = release
cover = self._cover_bild_aus_suppe(suppe_detail)
if cover:
bild_url = cover
return QuellenTreffer( return QuellenTreffer(
titel=titel, titel=titel,
verlag=verlag, verlag=verlag,
autor=autor, autor=autor,
erscheinungsdatum_oder_quartal=datum, erscheinungsdatum_oder_quartal=datum,
quellen_url=quellen_url, quellen_url=quellen_url,
bild_url=bild_url,
) )

View File

@@ -0,0 +1,575 @@
"""Optionale LLM-Hilfsstufe für den Web-Quellen-Crawler des Neuheiten-Plugins.
Der Crawler (`basis.py` + Adapter) bleibt maßgeblich und arbeitet rein
regelbasiert. Das LLM wird ausschließlich in zwei Situationen eingeschaltet:
1. **Struktur-Erkennung:** Liefert der Regel-Parser keine oder verdächtig
wenige Einträge (0 Treffer, oder wenige Treffer auf einer sehr link-
reichen Seite), darf das LLM Pagination-/Filter-Folge-URLs aus dem
gelieferten HTML vorschlagen. Der Vorschlag wird im Code hart gefiltert:
nur URLs derselben Domain wie die geparste Seite, maximal 20 je Seite —
Schutz vor Rate-Limit-Überlastung und Domain-Ausbruch.
2. **Feld-Extraktion:** Ein einzelner Treffer ist *unsicher* (fehlender
Verlag oder verdächtiger Titel). Dann darf das LLM höchstens `titel`,
`verlag` und `autor` korrigieren — niemals die URL.
Konfiguration konsistent zur dedup-KI über Umgebungsvariablen:
| Variable | Bedeutung |
|----------|-----------|
| `SPIELE_NEUHEITEN_KI_AKTIV` | `1` = Hilfsstufe an (Standard `0`) |
| `SPIELE_NEUHEITEN_KI_BASIS_URL` | Basis-URL, z. B. `https://api.openai.com/v1` |
| `SPIELE_NEUHEITEN_KI_API_KEY` | API-Key (wird als Bearer gesendet) |
| `SPIELE_NEUHEITEN_KI_MODELL` | Modellname, z. B. `gpt-4o-mini` |
| `SPIELE_NEUHEITEN_KI_KONFIDENZ_MIN` | Mindest-Konfidenz (Standard `0.7`) |
| `SPIELE_NEUHEITEN_KI_TIMEOUT_SEKUNDEN` | Timeout je Aufruf (Standard `20`) |
**Fallback-Pflicht:** Ohne Konfiguration oder bei jedem Fehler läuft EXAKT
der klassische Crawler weiter — ein KI-Fehler blockiert den Sync nie.
Ergebnisse unterhalb der Mindest-Konfidenz werden verworfen; der klassische
Parser-Befund bleibt dann unverändert bestehen.
Schnittstelle: OpenAI-kompatible Chat-Completions-API via httpx;
Netzwerk-/Server-Fehler werden genau einmal wiederholt (insgesamt zwei
Versuche), Antwort-Parsing-Fehler nicht.
"""
from __future__ import annotations
import dataclasses
import json
import logging
import os
import re
from dataclasses import dataclass
from typing import Any, Mapping
from urllib.parse import urljoin, urlsplit
import httpx
_logger = logging.getLogger("plugins.neuheiten")
#: Standard-Timeout je Chat-Completions-Aufruf in Sekunden.
STANDARD_TIMEOUT = 20.0
#: Gesamtzahl der Versuche (erster Versuch + genau ein Retry).
VERSUCHE = 2
#: HTTP-Statuscodes, die einen Retry rechtfertigen (zeitweilige Störungen).
RETRY_STATUS = frozenset({408, 429, 500, 502, 503, 504})
#: Mindest-Konfidenz, ab der ein KI-Befund übernommen wird.
STANDARD_KONFIDENZ_MIN = 0.7
#: Maximalzahl der KI-vorgeschlagenen Folge-URLs je Seite (Rate-Limit-Schutz).
MAX_FOLGE_URLS = 20
#: Obergrenze des HTML-Ausschnitts, der pro Struktur-Anfrage ans LLM geht.
HTML_MAX_ZEICHEN = 12000
# ---------------- Verdachts-Heuristiken (wann hilft das LLM?) ----------------
#: Weniger Treffer als das gilt als „verdächtig wenig“ …
STRUKTUR_MIN_TREFFER = 3
#: … aber nur, wenn die Seite zugleich sehr linkreich ist (Indiz für eine
#: nicht erkannte Liste/Pagination statt einer wirklich leeren Seite).
STRUKTUR_LINK_SCHWELLE = 30
_LINK_MUSTER = re.compile(r"<a\s", re.IGNORECASE)
#: Generische Navigations-/Platzhaltertexte, die als Spieltitel verdächtig sind.
GENERISCHE_TITEL = frozenset(
{
"hier", "mehr", "mehr infos", "details", "weiterlesen", "weiter",
"klick", "klick hier", "link", "produkt", "neuheit", "unbekannt",
"titel", "n/a", "-", "",
}
)
def _als_text(inhalt: bytes | str) -> str:
if isinstance(inhalt, bytes):
return inhalt.decode("utf-8", errors="replace")
return inhalt
def titel_ist_verdaechtig(titel: str | None) -> bool:
"""True bei fehlendem, zu kurzem oder generischem Navigationstext."""
if not titel or not titel.strip():
return True
gekuerzt = titel.strip()
if len(gekuerzt) < 3:
return True
return gekuerzt.rstrip(".!? ").lower() in GENERISCHE_TITEL
def treffer_ist_unsicher(treffer: Any) -> bool:
"""Ein Treffer ist unsicher, wenn der Verlag fehlt oder der Titel verdächtig ist.
Nur solche Treffer gehen in die KI-Feld-Extraktion; sichere Treffer
kosten kein Token und bleiben garantiert unangetastet.
"""
verlag = getattr(treffer, "verlag", None)
if not verlag or not str(verlag).strip():
return True
return titel_ist_verdaechtig(getattr(treffer, "titel", None))
def struktur_verdaechtig(anzahl_treffer: int, inhalt: bytes | str) -> bool:
"""True, wenn der Parser keine oder verdächtig wenige Einträge lieferte.
0 Treffer sind immer verdächtig. 12 Treffer nur dann, wenn die Seite
sehr viele Links enthält — sonst ist sie schlicht klein/leer.
"""
if anzahl_treffer <= 0:
return True
if anzahl_treffer >= STRUKTUR_MIN_TREFFER:
return False
links = len(_LINK_MUSTER.findall(_als_text(inhalt)))
return links > STRUKTUR_LINK_SCHWELLE
# ---------------- Konfiguration ----------------
@dataclass(frozen=True)
class KiKonfiguration:
"""Effektive Konfiguration der KI-Hilfsstufe (aus Env gelesen)."""
aktiv: bool = False
basis_url: str = ""
api_key: str = ""
modell: str = ""
konfidenz_min: float = STANDARD_KONFIDENZ_MIN
timeout: float = STANDARD_TIMEOUT
@property
def vollstaendig(self) -> bool:
"""True, wenn Aktiv-Schalter und Zugangsdaten vollständig sind."""
return bool(self.aktiv and self.basis_url and self.api_key and self.modell)
def lade_konfiguration(quelle: Mapping[str, str] | None = None) -> KiKonfiguration:
"""Liest die KI-Konfiguration aus der Umgebung (fehlertolerant).
Kaputte Zahlenwerte führen nicht zum Fehler, sondern zum Standardwert —
die Hilfsstufe darf den Crawler niemals blockieren.
"""
env = os.environ if quelle is None else quelle
aktiv = env.get("SPIELE_NEUHEITEN_KI_AKTIV", "0").strip() == "1"
try:
konfidenz_min = float(env.get("SPIELE_NEUHEITEN_KI_KONFIDENZ_MIN", ""))
except ValueError:
konfidenz_min = STANDARD_KONFIDENZ_MIN
konfidenz_min = min(1.0, max(0.0, konfidenz_min))
try:
timeout = float(env.get("SPIELE_NEUHEITEN_KI_TIMEOUT_SEKUNDEN", ""))
except ValueError:
timeout = STANDARD_TIMEOUT
timeout = max(1.0, timeout)
return KiKonfiguration(
aktiv=aktiv,
basis_url=env.get("SPIELE_NEUHEITEN_KI_BASIS_URL", "").strip().rstrip("/"),
api_key=env.get("SPIELE_NEUHEITEN_KI_API_KEY", "").strip(),
modell=env.get("SPIELE_NEUHEITEN_KI_MODELL", "").strip(),
konfidenz_min=konfidenz_min,
timeout=timeout,
)
# ---------------- Antwort-Parsing (robust gegen Code-Fences u. Ä.) ----------------
_FENCE_MUSTER = re.compile(r"```(?:json|JSON)?\s*(.*?)\s*```", re.DOTALL)
def extrahiere_json(text: str) -> dict | None:
"""Extrahiert das erste JSON-Objekt aus einer LLM-Antwort.
Toleriert Code-Fences und begleitenden Text; None, wenn nichts
Sinnvolles übrig bleibt.
"""
if not isinstance(text, str):
return None
text = text.strip()
if not text:
return None
versuche = [text]
versuche.extend(m.group(1).strip() for m in _FENCE_MUSTER.finditer(text))
erstes, letztes = text.find("{"), text.rfind("}")
if 0 <= erstes < letztes:
versuche.append(text[erstes : letztes + 1])
for kandidat in versuche:
try:
daten = json.loads(kandidat)
except (ValueError, TypeError):
continue
if isinstance(daten, dict):
return daten
return None
def _normalisiere_konfidenz(daten: Mapping[str, Any]) -> float | None:
"""Konfidenz aus der Antwort; Prozent-Skalen werden toleriert."""
try:
konfidenz = float(daten.get("konfidenz"))
except (TypeError, ValueError):
return None
if 10.0 <= konfidenz <= 100.0: # z. B. 92 → 0.92
konfidenz = konfidenz / 100.0
if not 0.0 <= konfidenz <= 1.0: # 1 < x < 10 ist auf keiner Skala plausibel
return None
return round(konfidenz, 4)
def _optional_text(wert: Any) -> str | None:
"""Normalisiert ein nullable String-Feld der Antwort."""
if isinstance(wert, str):
wert = wert.strip()
return wert or None
return None
def validiere_url_vorschlaege(daten: Any, seiten_url: str) -> dict | None:
"""Prüft die Struktur-Antwort und filtert hart auf gleiche Domain + max 20.
Erwartet `{folge_urls: [...], konfidenz: float}`. Relative URLs werden
gegen die Seiten-URL aufgelöst; fremde Domains, Nicht-HTTP-Schemata und
Duplikate (inkl. Fragment-Unterschiede) werden verworfen — auch wenn das
LLM sie vorschlägt.
"""
if not isinstance(daten, dict):
return None
roh = daten.get("folge_urls")
if not isinstance(roh, list):
return None
konfidenz = _normalisiere_konfidenz(daten)
if konfidenz is None:
return None
basis_domain = urlsplit(seiten_url).netloc.lower()
if not basis_domain:
return None
gefiltert: list[str] = []
gesehen: set[str] = set()
for eintrag in roh:
if not isinstance(eintrag, str) or not eintrag.strip():
continue
absolut = urljoin(seiten_url, eintrag.strip())
teile = urlsplit(absolut)
if teile.scheme not in ("http", "https"):
continue
if teile.netloc.lower() != basis_domain:
continue
ohne_fragment = absolut.split("#", 1)[0]
if ohne_fragment in gesehen:
continue
gesehen.add(ohne_fragment)
gefiltert.append(ohne_fragment)
if len(gefiltert) >= MAX_FOLGE_URLS:
break
return {"folge_urls": gefiltert, "konfidenz": konfidenz}
def validiere_feld_antwort(daten: Any) -> dict | None:
"""Prüft die Feld-Antwort gegen das Schema (titel/verlag/autor/konfidenz).
Die URL ist bewusst kein Teil des Schemas — eine Korrektur kann sie
strukturell nie ändern.
"""
if not isinstance(daten, dict):
return None
titel = daten.get("titel")
if not isinstance(titel, str) or not titel.strip():
return None
konfidenz = _normalisiere_konfidenz(daten)
if konfidenz is None:
return None
begruendung = daten.get("begruendung")
if not isinstance(begruendung, str):
begruendung = ""
return {
"titel": titel.strip(),
"verlag": _optional_text(daten.get("verlag")),
"autor": _optional_text(daten.get("autor")),
"konfidenz": konfidenz,
"begruendung": begruendung.strip(),
}
# ---------------- Prompt ----------------
_SYSTEM_STRUKTUR = (
"Du hilfst einem Crawler einer Spielemagazin-Redaktion, Brettspiel-"
"Neuheitenlisten im Web zu finden. Der Regel-Parser hat die folgende "
"Seite fast leer geparst — vermutlich nutzt die Liste Pagination, "
"Archiv- oder Filter-Links. Identifiziere aus dem HTML die URLs, unter "
"denen weitere Neuheiten-Einträge zu erwarten sind. Antworte "
"AUSSCHLIESSLICH mit einem JSON-Objekt nach exakt diesem Schema:\n"
'{"folge_urls": ["<absolute oder relative URL>", …], '
'"konfidenz": <float 0.0-1.0>}\n'
f"Höchstens {MAX_FOLGE_URLS} URLs und nur Links derselben Domain wie die "
"Seiten-URL. Wenn du nichts Sinnvolles findest, liefere eine leere Liste "
"mit niedriger Konfidenz. Kein weiterer Text, keine Code-Fences."
)
_SYSTEM_FELDER = (
"Du assistierst einer Spielemagazin-Redaktion beim Parsen von Brettspiel-"
"Neuheiten. Der folgende Treffer eines regelbasierten Crawlers ist "
"unsicher. Bereinige die Felder: trenne „Verlag: Titel“-Muster, entferne "
"Navigationstext und ergänze den Verlag, falls er sicher erkennbar ist. "
"Korrigiere ausschließlich titel, verlag und autor — ändere niemals URLs "
"und erfinde nichts. Ein Feld, das du nicht sicher bestimmen kannst, "
"bleibt null. Antworte AUSSCHLIESSLICH mit einem JSON-Objekt nach exakt "
"diesem Schema:\n"
'{"titel": "<bereinigter Titel>", "verlag": "<Verlagsname oder null>", '
'"autor": "<Autor oder null>", "konfidenz": <float 0.0-1.0>, '
'"begruendung": "<kurzer deutscher Satz>"}\n'
"Kein weiterer Text, keine Code-Fences."
)
def baue_struktur_prompt(seiten_url: str, html_text: str) -> str:
"""User-Nachricht für die Struktur-Erkennung (gekürzter HTML-Ausschnitt)."""
ausschnitt = html_text[:HTML_MAX_ZEICHEN]
return (
f"Seiten-URL: {seiten_url}\n\n"
"HTML-Ausschnitt der geparsten Seite:\n"
f"{ausschnitt}"
)
def baue_feld_prompt(titel: str, verlag: str | None, autor: str | None) -> str:
"""User-Nachricht mit dem unsicheren Treffer (ohne URL — bleibt fix)."""
zeilen = [f"Unsicherer Treffer:", f"titel: {titel}"]
zeilen.append(f"verlag: {verlag if verlag else '(leer)'}")
zeilen.append(f"autor: {autor if autor else '(leer)'}")
zeilen.append(
"Gib die bereinigten Felder zurück; die quellen_url selbst bleibt "
"nicht veränderbar und ist nicht Teil der Aufgabe."
)
return "\n".join(zeilen)
# ---------------- Client ----------------
class KiHilfe:
"""OpenAI-kompatibler Chat-Completions-Client für die Crawler-Hilfsstufe.
Bewusst genauso defensiv wie der dedup-KI-Client: Alle öffentlichen
Methoden werfen nicht (Ausnahmen werden intern geschluckt) — ohne
Konfiguration oder bei jedem Problem greift exakt der klassische
Crawler-Befund. Transport injizierbar, damit Tests netzwerkfrei bleiben.
"""
def __init__(
self,
konfiguration: KiKonfiguration,
*,
transport: httpx.BaseTransport | None = None,
) -> None:
self._konfiguration = konfiguration
self._client = httpx.Client(
base_url=konfiguration.basis_url,
timeout=konfiguration.timeout,
transport=transport,
headers={
"Authorization": f"Bearer {konfiguration.api_key}",
"Content-Type": "application/json",
},
)
@property
def modell(self) -> str:
return self._konfiguration.modell
def schliessen(self) -> None:
self._client.close()
# ---------- Struktur-Erkennung ----------
def folge_urls_bei_bedarf(
self,
anzahl_treffer: int,
inhalt: bytes | str,
seiten_url: str,
bereits_geprueft: set[str] | None = None,
) -> list[str]:
"""Pagination-/Filter-URLs bei verdächtig leerem Parser-Ergebnis.
Prüft selbst die Verdachts-Heuristik (sonst []), fragt dann das LLM
und filtert dessen Vorschläge hart: gleiche Domain, max. 20, bereits
geplante/besuchte URLs raus, Konfidenz unter der Schwelle → alles
verwerfen. Fehler → [] (klassischer Lauf bleibt).
"""
try:
if not struktur_verdaechtig(anzahl_treffer, inhalt):
return []
if not self._konfiguration.vollstaendig:
return []
nutzernachricht = baue_struktur_prompt(seiten_url, _als_text(inhalt))
daten = self._chat(_SYSTEM_STRUKTUR, nutzernachricht)
ergebnis = validiere_url_vorschlaege(daten, seiten_url)
if ergebnis is None:
return []
if ergebnis["konfidenz"] < self._konfiguration.konfidenz_min:
_logger.info(
"neuheiten/KI: URL-Vorschläge verworfen (Konfidenz %.2f < %.2f).",
ergebnis["konfidenz"], self._konfiguration.konfidenz_min,
)
return []
bekannt = bereits_geprueft or set()
return [
url for url in ergebnis["folge_urls"] if url not in bekannt
]
except Exception:
_logger.exception(
"neuheiten/KI: Struktur-Erkennung fehlgeschlagen — klassischer Lauf bleibt."
)
return []
# ---------- Feld-Extraktion ----------
def treffer_nachbessern(
self, treffer_liste: list
) -> tuple[list, list[dict]]:
"""Korrigiert ausschließlich unsichere Treffer (titel/verlag/autor).
Sichere Treffer werden nicht angefasst und kosten kein Token. Die
quellen_url bleibt immer unangetastet (kein Teil des Antwort-Schemas).
Rückgabe: (neue Trefferliste, Eingriffs-Protokoll fürs Audit-Log mit
Modell, Konfidenz und den jeweils korrigierten Feldern).
"""
if not treffer_liste:
return [], []
if not self._konfiguration.vollstaendig:
return list(treffer_liste), []
neue_liste: list = []
eingriffe: list[dict] = []
for einzel in treffer_liste:
if not treffer_ist_unsicher(einzel):
neue_liste.append(einzel)
continue
ergebnis = self._einzeln_korrigieren(einzel)
if ergebnis is None:
neue_liste.append(einzel) # klassischer Befund bleibt
continue
korrigiert, antwort = ergebnis
if korrigiert is einzel: # nichts tatsächlich geändert
neue_liste.append(einzel)
continue
neue_liste.append(korrigiert)
eingriffe.append(self._eingriff_protokoll(einzel, korrigiert, antwort))
return neue_liste, eingriffe
def _einzeln_korrigieren(self, treffer: Any) -> tuple[Any, dict] | None:
"""Eine Feld-Korrektur; Rückgabe (neuer Treffer, validierte Antwort).
None bzw. der unveränderte Treffer bedeutet: klassischer Befund bleibt.
"""
try:
nutzernachricht = baue_feld_prompt(
getattr(treffer, "titel", "") or "",
getattr(treffer, "verlag", None),
getattr(treffer, "autor", None),
)
daten = self._chat(_SYSTEM_FELDER, nutzernachricht)
antwort = validiere_feld_antwort(daten)
if antwort is None:
return None
if antwort["konfidenz"] < self._konfiguration.konfidenz_min:
_logger.info(
"neuheiten/KI: Feldkorrektur verworfen (Konfidenz %.2f < %.2f).",
antwort["konfidenz"], self._konfiguration.konfidenz_min,
)
return None
felder: dict[str, str | None] = {}
for feld in ("titel", "verlag", "autor"):
alter_wert = getattr(treffer, feld, None)
alter_norm = (alter_wert or "").strip() or None
neu_norm = (antwort[feld] or "").strip() or None
if neu_norm != alter_norm:
felder[feld] = neu_norm
if not felder:
return treffer, antwort # nichts tatsächlich korrigiert
try:
return dataclasses.replace(treffer, **felder), antwort
except Exception:
return None
except Exception:
_logger.exception(
"neuheiten/KI: Feldkorrektur fehlgeschlagen — klassischer Befund bleibt."
)
return None
def _eingriff_protokoll(self, alt: Any, neu: Any, antwort: dict) -> dict:
"""Audit-Zeile: was wurde korrigiert, mit welchem Modell/welcher Konfidenz."""
korrigiert: dict[str, str | None] = {}
for feld in ("titel", "verlag", "autor"):
vorher = getattr(alt, feld, None)
nachher = getattr(neu, feld, None)
if vorher != nachher:
korrigiert[feld] = nachher
return {
"quellen_url": getattr(alt, "quellen_url", ""),
"modell": self.modell,
"konfidenz": antwort["konfidenz"],
"begruendung": antwort.get("begruendung", ""),
"korrigiert": korrigiert,
"vorher": {f: getattr(alt, f, None) for f in korrigiert},
}
# ---------- Gemeinsamer Chat-Completions-Aufruf ----------
def _chat(self, system: str, nutzer: str) -> dict | None:
"""POST /chat/completions mit genau einem Retry; None bei jedem Problem.
Netzwerk-/Timeout-Fehler und zeitweilige Server-Störungen werden
genau einmal wiederholt (insgesamt zwei Versuche); andere 4xx und
kaputte Antworten nicht — dort hilft kein Retry.
"""
payload = {
"model": self._konfiguration.modell,
"temperature": 0,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": nutzer},
],
}
for versuch in range(1, VERSUCHE + 1):
try:
antwort = self._client.post("/chat/completions", json=payload)
except httpx.RequestError as exc:
_logger.warning(
"neuheiten/KI: Versuch %d/%d fehlgeschlagen (%s)",
versuch, VERSUCHE, exc,
)
continue
if antwort.status_code in RETRY_STATUS:
_logger.warning(
"neuheiten/KI: Versuch %d/%d mit Status %d — wiederholt.",
versuch, VERSUCHE, antwort.status_code,
)
continue
try:
antwort.raise_for_status() # andere 4xx: kein Retry hilft
except httpx.HTTPStatusError as exc:
_logger.warning("neuheiten/KI: Anfrage abgelehnt (%s).", exc)
return None
try:
inhalte = antwort.json()["choices"][0]["message"]["content"]
except Exception as exc: # kaputtes Antwort-Layout
_logger.warning("neuheiten/KI: unbrauchbare Antwort (%s)", exc)
return None
return extrahiere_json(inhalte)
_logger.warning(
"neuheiten/KI: alle %d Versuche fehlgeschlagen — klassischer Befund.", VERSUCHE
)
return None

View File

@@ -17,7 +17,7 @@ import re
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from .basis import QuellenAdapter, QuellenTreffer from .basis import QuellenAdapter, QuellenTreffer, bild_aus_element
DATUM_MUSTER = re.compile(r"(\d{2}\.\d{2}\.\d{4})") DATUM_MUSTER = re.compile(r"(\d{2}\.\d{2}\.\d{4})")
@@ -57,6 +57,7 @@ class SpielboxQuelle(QuellenAdapter):
autor=None, autor=None,
erscheinungsdatum_oder_quartal=datum, erscheinungsdatum_oder_quartal=datum,
quellen_url=quellen_url, quellen_url=quellen_url,
bild_url=bild_aus_element(element, url),
) )
) )

View File

@@ -121,9 +121,52 @@ class SpielEssenQuelle(QuellenAdapter):
autor=felder.get("Autor"), autor=felder.get("Autor"),
erscheinungsdatum_oder_quartal=felder.get("Erscheinungsdatum"), erscheinungsdatum_oder_quartal=felder.get("Erscheinungsdatum"),
quellen_url=OEFFENTLICHE_SEITE, quellen_url=OEFFENTLICHE_SEITE,
bild_url=_bild_aus_produkt(produkt),
) )
def _bild_aus_produkt(produkt: dict) -> str | None:
"""Erste nutzbare Bild-URL aus dem BILDER-Feld (tolerant).
Das Feld ist je nach Messejahr eine Liste von URLs/Pfaden, ein Objekt
oder ein String; nur absolute bzw. protokoll-relative http(s)-URLs
werden übernommen, alles andere bleibt leer.
"""
def _aus_wert(wert) -> str | None:
if isinstance(wert, str):
url = wert.strip()
if url.startswith("//"):
url = f"https:{url}"
if url.startswith(("http://", "https://")):
return url
return None
if isinstance(wert, dict):
for schluessel in ("URL", "BILD", "SRC", "PATH", "PFAD"):
fund = _aus_wert(wert.get(schluessel))
if fund:
return fund
for unterwert in wert.values():
fund = _aus_wert(unterwert)
if fund:
return fund
return None
if isinstance(wert, list):
for element in wert:
fund = _aus_wert(element)
if fund:
return fund
return None
bilder = produkt.get("BILDER")
if isinstance(bilder, list):
for einzel in bilder:
fund = _aus_wert(einzel)
if fund:
return fund
return None
return _aus_wert(bilder)
def _parse_info(info_html: str) -> dict[str, str]: def _parse_info(info_html: str) -> dict[str, str]:
"""Liest die INFO-Html-Tabelle (Zeilen „Feld: Wert“) tolerant aus.""" """Liest die INFO-Html-Tabelle (Zeilen „Feld: Wert“) tolerant aus."""
felder: dict[str, str] = {} felder: dict[str, str] = {}

View File

@@ -32,6 +32,7 @@ from typing import Callable
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from .quellen.ki_hilfe import KiHilfe, lade_konfiguration as lade_ki_konfiguration
from .models import STATUS_NEUHEIT, Neuheit, QuellenStatus from .models import STATUS_NEUHEIT, Neuheit, QuellenStatus
from .quellen import ( from .quellen import (
ADAPTER_KLASSEN, ADAPTER_KLASSEN,
@@ -108,20 +109,38 @@ class WebQuellenSyncService:
dedup=None, dedup=None,
client_fabrik: Callable[[dict], WebQuellenClient] | None = None, client_fabrik: Callable[[dict], WebQuellenClient] | None = None,
umgebung=None, umgebung=None,
ki_fabrik: Callable[[], KiHilfe] | None = None,
audit=None,
) -> None: ) -> None:
self.session_factory = session_factory self.session_factory = session_factory
self.adapter_klassen = adapter_klassen self.adapter_klassen = adapter_klassen
self.dedup = dedup self.dedup = dedup
self.audit = audit
self.umgebung = umgebung if umgebung is not None else os.environ self.umgebung = umgebung if umgebung is not None else os.environ
self._client_fabrik = client_fabrik or self._standard_client_fabrik self._client_fabrik = client_fabrik or self._standard_client_fabrik
# KI-Hilfsstufe: Fabrik für Tests; ohne vollständige Konfiguration wird
# nie ein Client gebaut (und damit nie ein LLM-Aufruf getätigt).
self._ki_fabrik = ki_fabrik
# ---------- Gesamtdurchlauf ---------- # ---------- Gesamtdurchlauf ----------
def laufe(self) -> list[QuellenLaufZeile]: def laufe(self, nur_quellen: set[str] | None = None) -> list[QuellenLaufZeile]:
"""Führt den Web-Quellen-Sync aus.
`nur_quellen`: optionaler Filter — nur diese Quellnamen laufen
(manueller Einzel-Quellen-Sync aus dem UI). Der An/Aus-Schalter
der Quelle wird dabei ignoriert, ein explizit angeforderter
Einzellauf läuft immer.
"""
zeilen: list[QuellenLaufZeile] = [] zeilen: list[QuellenLaufZeile] = []
ki = self._ki_hilfe()
for klasse in self.adapter_klassen: for klasse in self.adapter_klassen:
if nur_quellen is not None and klasse.name not in nur_quellen:
continue
try: try:
zeile = self._eine_quelle(klasse) zeile = self._eine_quelle(
klasse, ki, einzellauf=nur_quellen is not None
)
except Exception as exc: # letzte Verteidigungslinie pro Quelle except Exception as exc: # letzte Verteidigungslinie pro Quelle
_logger.exception("Quellen-Sync fehlgeschlagen für %s", klasse.name) _logger.exception("Quellen-Sync fehlgeschlagen für %s", klasse.name)
zeile = QuellenLaufZeile( zeile = QuellenLaufZeile(
@@ -134,6 +153,32 @@ class WebQuellenSyncService:
zeilen.append(zeile) zeilen.append(zeile)
return zeilen return zeilen
def schalter_gesetzt(self, name: str, wert: bool) -> None:
"""Setzt den UI-An/Aus-Schalter einer Quelle (persistiert).
Der Schalter übersteuert den Env-Standard; der Hintergrund-Sync
überspringt ausgeschaltete Quellen.
"""
with self.session_factory() as db:
eintrag = db.scalar(
select(QuellenStatus).where(QuellenStatus.quelle == name)
)
if eintrag is None:
eintrag = QuellenStatus(quelle=name, url="")
db.add(eintrag)
eintrag.aktiv_schalter = wert
db.commit()
def quelle_laeuft(self, name: str) -> bool:
"""Effektiver Aktiv-Status einer Quelle (Schalter übersteuert Env)."""
with self.session_factory() as db:
eintrag = db.scalar(
select(QuellenStatus).where(QuellenStatus.quelle == name)
)
if eintrag is not None and eintrag.aktiv_schalter is not None:
return eintrag.aktiv_schalter
return quelle_aktiv(name, self.umgebung)
def gesamt_text(self, zeilen: list[QuellenLaufZeile]) -> str: def gesamt_text(self, zeilen: list[QuellenLaufZeile]) -> str:
"""Kompakte deutschsprachige Zusammenfassung für Banner/Protokoll.""" """Kompakte deutschsprachige Zusammenfassung für Banner/Protokoll."""
teile = [zeile.als_text() for zeile in zeilen] teile = [zeile.als_text() for zeile in zeilen]
@@ -141,10 +186,35 @@ class WebQuellenSyncService:
# ---------- Eine Quelle ---------- # ---------- Eine Quelle ----------
def _eine_quelle(self, klasse: type[QuellenAdapter]) -> QuellenLaufZeile: def _ki_hilfe(self) -> KiHilfe | None:
"""KI-Hilfsstufe nur bei vollständiger Konfiguration (sonst None).
Die Aktivierungsprüfung gilt vor jeder injizierten Fabrik — ist die
Hilfsstufe aus oder unvollständig konfiguriert, wird nie ein Client
gebaut und damit nie ein LLM-Aufruf getätigt.
"""
try:
konfiguration = lade_ki_konfiguration(self.umgebung)
if not konfiguration.vollstaendig:
return None
if self._ki_fabrik is not None:
return self._ki_fabrik()
return KiHilfe(konfiguration)
except Exception:
# Auch ein kaputtes KI-Setup darf den Sync nie blockieren.
_logger.exception("neuheiten/KI: Hilfsstufe nicht verfügbar — klassischer Lauf.")
return None
def _eine_quelle(
self,
klasse: type[QuellenAdapter],
ki: KiHilfe | None = None,
einzellauf: bool = False,
) -> QuellenLaufZeile:
zeile = QuellenLaufZeile(klasse.name, klasse.anzeigename) zeile = QuellenLaufZeile(klasse.name, klasse.anzeigename)
zeile.url = klasse.start_urls[0] if klasse.start_urls else None zeile.url = klasse.start_urls[0] if klasse.start_urls else None
if not quelle_aktiv(klasse.name, self.umgebung): # Einzellauf (nur_quellen) läuft immer; sonst entscheidet der Schalter.
if not einzellauf and not self.quelle_laeuft(klasse.name):
zeile.aktiv = False zeile.aktiv = False
return zeile return zeile
@@ -152,6 +222,7 @@ class WebQuellenSyncService:
client = self._client_fabrik(self._validatoren_laden(klasse.name)) client = self._client_fabrik(self._validatoren_laden(klasse.name))
try: try:
adapter = klasse(client) adapter = klasse(client)
adapter.ki_hilfe = ki # None = rein regelbasiert (Fallback-Pflicht)
try: try:
ergebnis = adapter.sammle() ergebnis = adapter.sammle()
except Exception as exc: except Exception as exc:
@@ -160,6 +231,7 @@ class WebQuellenSyncService:
zeile.fehlermeldung = str(exc)[:300] zeile.fehlermeldung = str(exc)[:300]
return zeile return zeile
zeile.seiten = ergebnis.seiten zeile.seiten = ergebnis.seiten
self._ki_auditieren(klasse.name, ergebnis)
if ergebnis.unveraendert: if ergebnis.unveraendert:
zeile.unveraendert = True zeile.unveraendert = True
else: else:
@@ -202,6 +274,8 @@ class WebQuellenSyncService:
einzel.erscheinungsdatum_oder_quartal einzel.erscheinungsdatum_oder_quartal
), ),
bgg_id=None, bgg_id=None,
bild_url=einzel.bild_url,
quellen_url=einzel.quellen_url,
status=STATUS_NEUHEIT, status=STATUS_NEUHEIT,
quelle=quelle, quelle=quelle,
) )
@@ -227,6 +301,12 @@ class WebQuellenSyncService:
eintrag.erscheinungsjahr = jahr_aus_datumsangabe( eintrag.erscheinungsjahr = jahr_aus_datumsangabe(
treffer.erscheinungsdatum_oder_quartal treffer.erscheinungsdatum_oder_quartal
) )
# Coverbild + Quellen-URL mitpflegen; fehlen sie im Treffer,
# bleiben die bereits gespeicherten Werte erhalten.
if treffer.bild_url:
eintrag.bild_url = treffer.bild_url
if treffer.quellen_url:
eintrag.quellen_url = treffer.quellen_url
return True return True
return False return False
@@ -263,6 +343,37 @@ class WebQuellenSyncService:
return True return True
return not ergebnis.hat_konflikte return not ergebnis.hat_konflikte
# ---------- KI-Audit ----------
def _ki_auditieren(self, quelle: str, ergebnis) -> None:
"""KI-Eingriffe des Laufs ins audit-log schreiben (best effort).
Ein Eintrag je Quelle und Lauf mit Modell, Konfidenz, korrigierten
Feldern und den verfolgten KI-Folge-URLs. Ohne audit-log (Plugin
nicht geladen) oder bei Fehlern wird nur protokolliert — der Sync
wird nie blockiert.
"""
eingriffe = list(getattr(ergebnis, "ki_eingriffe", []) or [])
folge_urls = list(getattr(ergebnis, "ki_folge_urls", []) or [])
if not eingriffe and not folge_urls:
return
if self.audit is None:
_logger.info(
"Quelle %s: %d KI-Korrekturen, %d KI-Folge-URLs (kein audit-log geladen).",
quelle, len(eingriffe), len(folge_urls),
)
return
details = {
"quelle": quelle,
"modell": eingriffe[0].get("modell", "") if eingriffe else "",
"eingriffe": eingriffe,
"folge_urls": folge_urls,
}
try:
self.audit.log_sync(None, "korrigiert", "neuheiten_ki_crawler", quelle, details)
except Exception:
_logger.exception("neuheiten/KI: Audit-Eintrag konnte nicht geschrieben werden.")
# ---------- Statusprotokoll ---------- # ---------- Statusprotokoll ----------
def _status_speichern(self, zeile: QuellenLaufZeile) -> None: def _status_speichern(self, zeile: QuellenLaufZeile) -> None:

View File

@@ -3,16 +3,19 @@
- Update statt Duplikat: bestehende Einträge werden über die eindeutige - Update statt Duplikat: bestehende Einträge werden über die eindeutige
BGG-ID gefunden und aktualisiert (Status bleibt erhalten). BGG-ID gefunden und aktualisiert (Status bleibt erhalten).
- Erweiterungen werden im Client und hier auf Elementebene gefiltert. - Erweiterungen werden im Client und hier auf Elementebene gefiltert.
- Prototypen: Die BGG-XML-API2 liefert keinen verlässlichen Prototyp-Marker; - Prototypen: Titel mit Prototyp-Schlüsselwörtern werden ausgeschlossen.
als Heuristik werden Titel mit Prototyp-Schlüsselwörtern ausgeschlossen - Jahresfilter: optional nur Spiele mit Erscheinungsjahr ≥ aktuelles Jahr.
(dokumentierte Einschränkung, siehe README). - Deutsch-Filter: optional nur Spiele mit deutsch klingendem Titel.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import re import re
import unicodedata
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -28,12 +31,58 @@ PROTOTYP_MUSTER = re.compile(
THING_BATCH_GROESSE = 20 THING_BATCH_GROESSE = 20
#: Funktionswörter, die stark auf einen deutschen Spieltitel hindeuten.
_DEUTSCHE_INDIKATOREN = frozenset({
"der", "die", "das", "den", "dem", "des",
"ein", "eine", "einen", "einem", "einer", "eines",
"und", "für", "von", "vom", "zu", "zum", "zur",
"im", "am", "beim", "auf", "mit", "aus",
})
#: Typische Begriffe deutscher Spieltitel (+2 Punkte je Treffer).
_DEUTSCHE_SPIELWOERTER = frozenset({
"abenteuer", "berg", "blitz", "burg", "dorf", "drache", "drachen",
"erde", "feuer", "gärten", "haus", "himmel", "insel", "jagd",
"kaiser", "könig", "könige", "königreich", "land", "länder",
"meister", "mond", "norden", "osten", "pfad", "reich", "ritter",
"schiff", "schloss", "see", "spiel", "spiele", "spielen",
"stadt", "stern", "süden", "turm", "wald", "weg", "welt",
"westen", "wiese", "wilder", "wüste", "zauber", "zeit",
})
#: Wörter, die auf einen englischen Titel hindeuten (1 Punkt je Treffer).
_ENGLISCHE_INDIKATOREN = frozenset({
"the", "of", "and", "game", "games", "card", "cards", "dice", "board",
})
_UM_LAUTE = frozenset("äöüßÄÖÜ")
_WORT_MUSTER = re.compile(r"[^\W\d_]+", re.UNICODE)
def ist_deutscher_titel(titel: str) -> bool:
"""Heuristik: > 0 spricht für einen deutschen Titel.
Umlaute/ß zählen am stärksten (+3), deutsche Funktionswörter +1,
typische deutsche Spielbegriffe +2, englische Indikatoren 1.
"""
if not titel:
return False
score = 0
if any(zeichen in _UM_LAUTE for zeichen in titel):
score += 3
woerter = {wort.casefold() for wort in _WORT_MUSTER.findall(titel)}
score += sum(1 for wort in woerter if wort in _DEUTSCHE_INDIKATOREN)
score += sum(2 for wort in woerter if wort in _DEUTSCHE_SPIELWOERTER)
score -= sum(1 for wort in woerter if wort in _ENGLISCHE_INDIKATOREN)
return score > 0
def ist_moeglicher_prototyp(titel: str) -> bool: def ist_moeglicher_prototyp(titel: str) -> bool:
"""Heuristik: Titel klingt nach Prototyp/Protoversion.""" """Heuristik: Titel klingt nach Prototyp/Protoversion."""
return bool(PROTOTYP_MUSTER.search(titel)) return bool(PROTOTYP_MUSTER.search(titel))
def aktuelles_jahr() -> int:
return date.today().year
@dataclass @dataclass
class SyncErgebnis: class SyncErgebnis:
"""Zusammenfassung eines Sync-Laufs.""" """Zusammenfassung eines Sync-Laufs."""
@@ -51,7 +100,7 @@ class SyncErgebnis:
return self.abbruch return self.abbruch
text = ( text = (
f"{self.neu} neu, {self.aktualisiert} aktualisiert, " f"{self.neu} neu, {self.aktualisiert} aktualisiert, "
f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen)" f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen/Jahr/Sprache)"
) )
if self.fehler: if self.fehler:
text += f"; {len(self.fehler)} Fehler" text += f"; {len(self.fehler)} Fehler"
@@ -69,6 +118,7 @@ def speichere_oder_aktualisiere(db: Session, spiel: BggSpiel) -> bool:
autor=spiel.autor, autor=spiel.autor,
erscheinungsjahr=spiel.erscheinungsjahr, erscheinungsjahr=spiel.erscheinungsjahr,
bgg_id=spiel.bgg_id, bgg_id=spiel.bgg_id,
bild_url=spiel.bild_url,
status=STATUS_NEUHEIT, status=STATUS_NEUHEIT,
quelle="boardgamegeek", quelle="boardgamegeek",
) )
@@ -78,6 +128,10 @@ def speichere_oder_aktualisiere(db: Session, spiel: BggSpiel) -> bool:
vorhanden.verlag = spiel.verlag vorhanden.verlag = spiel.verlag
vorhanden.autor = spiel.autor vorhanden.autor = spiel.autor
vorhanden.erscheinungsjahr = spiel.erscheinungsjahr vorhanden.erscheinungsjahr = spiel.erscheinungsjahr
# Coverbild mitpflegen: BGG liefert es ggf. erst nachträglich; ein
# fehlendes Bild in der Antwort löscht kein bereits gespeichertes.
if spiel.bild_url:
vorhanden.bild_url = spiel.bild_url
return False return False
@@ -91,11 +145,16 @@ class SyncService:
*, *,
thing_batch_groesse: int = THING_BATCH_GROESSE, thing_batch_groesse: int = THING_BATCH_GROESSE,
max_treffer_pro_suchbegriff: int | None = None, max_treffer_pro_suchbegriff: int | None = None,
jahr_filter: bool = True,
deutscher_titel_filter: bool = True,
) -> None: ) -> None:
self.session_factory = session_factory self.session_factory = session_factory
self.client = client self.client = client
self.thing_batch_groesse = thing_batch_groesse self.thing_batch_groesse = thing_batch_groesse
self.max_treffer_pro_suchbegriff = max_treffer_pro_suchbegriff self.max_treffer_pro_suchbegriff = max_treffer_pro_suchbegriff
self.jahr_filter = jahr_filter
self.deutscher_titel_filter = deutscher_titel_filter
self._min_jahr = aktuelles_jahr()
def synchronisiere(self, suchbegriffe: Iterable[str]) -> SyncErgebnis: def synchronisiere(self, suchbegriffe: Iterable[str]) -> SyncErgebnis:
ergebnis = SyncErgebnis() ergebnis = SyncErgebnis()
@@ -134,6 +193,13 @@ class SyncService:
if ist_moeglicher_prototyp(spiel.titel): if ist_moeglicher_prototyp(spiel.titel):
ergebnis.gefiltert += 1 ergebnis.gefiltert += 1
continue continue
if self.jahr_filter and spiel.erscheinungsjahr is not None:
if spiel.erscheinungsjahr < self._min_jahr:
ergebnis.gefiltert += 1
continue
if self.deutscher_titel_filter and not ist_deutscher_titel(spiel.titel):
ergebnis.gefiltert += 1
continue
if speichere_oder_aktualisiere(db, spiel): if speichere_oder_aktualisiere(db, spiel):
ergebnis.neu += 1 ergebnis.neu += 1
else: else:

View File

@@ -0,0 +1,31 @@
{# Coverbild-Vorschau.
Erwartete Variablen: cover_bild (URL oder None), cover_titel,
cover_gross (optional: true für großformatige Ansichten).
Bilder werden direkt von der Quelle eingebettet (Hotlinking, kein
Download) — mit loading="lazy" und decoding="async". Ohne Bild zeigt
das Partial einen dezenten Platzhalter mit SVG-Würfel (in der großen
Ansicht zusätzlich mit dem Hinweis „Kein Cover"). #}
{% from "icons.html" import icon_wuerfel %}
{% set _gross = cover_gross | default(false) %}
{% if _gross %}
{% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async"
class="block h-auto w-auto max-w-[200px] max-h-[280px] rounded-lg border border-slate-200 bg-white shadow-sm">
{% else %}
<div role="img" aria-label="Kein Cover vorhanden" title="Kein Cover vorhanden"
class="flex h-[210px] w-[150px] max-w-[200px] flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 text-slate-300 select-none">
{{ icon_wuerfel("h-12 w-12") }}
<span class="text-xs font-medium text-slate-400">Kein Cover</span>
</div>
{% endif %}
{% else %}
{% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" width="48" height="68"
class="block h-[68px] w-12 shrink-0 rounded border border-slate-200 bg-slate-50 object-cover">
{% else %}
<div role="img" aria-label="Kein Cover vorhanden" title="Kein Cover vorhanden"
class="flex h-[68px] w-12 shrink-0 items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 text-slate-400 select-none">
{{ icon_wuerfel("h-5 w-5") }}
</div>
{% endif %}
{% endif %}

View File

@@ -1,40 +1,103 @@
<div id="neuheiten-liste"> <div id="neuheiten-liste">
<p class="text-xs text-slate-500 mb-2">{{ anzahl }} Eintr&auml;ge</p> <p class="text-xs text-slate-500 mb-2">{{ anzahl }} Eintr&auml;ge</p>
<div class="overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm">
<!-- Mobil (<768px): Karten-Ansicht — Cover links, Titel+Metadaten rechts, Status oben -->
<ul class="md:hidden space-y-3" role="list">
{% for eintrag in eintraege %}
<li class="bg-white rounded-xl border border-slate-200 shadow-sm p-3 flex gap-3">
<div class="pt-0.5">
{% set cover_bild = eintrag.bild_url %}
{% set cover_titel = eintrag.titel %}
{% include "neuheiten/_cover.html" %}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<h3 class="text-sm font-medium leading-snug break-words">
{{ eintrag.titel }}
{% if eintrag.bgg_id %}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
class="ml-0.5 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
{% elif eintrag.quelle and eintrag.quelle != 'boardgamegeek' %}
<a href="{{ eintrag.quellen_url or '#' }}" target="_blank" rel="noopener"
title="Zur Quelle ({{ eintrag.quelle }})"
class="ml-0.5 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }}&nbsp;</a>
{% endif %}
</h3>
{% from "status.html" import status_badge %}{{ status_badge(eintrag.status, none, "ml-auto shrink-0") }}
</div>
<dl class="mt-1.5 space-y-0.5 text-xs text-slate-500">
{% if eintrag.verlag %}<div><dt class="inline font-medium text-slate-400">Verlag: </dt><dd class="inline">{{ eintrag.verlag }}</dd></div>{% endif %}
{% if eintrag.autor %}<div><dt class="inline font-medium text-slate-400">Autor: </dt><dd class="inline">{{ eintrag.autor }}</dd></div>{% endif %}
{% if eintrag.erscheinungsjahr %}<div><dt class="inline font-medium text-slate-400">Jahr: </dt><dd class="inline">{{ eintrag.erscheinungsjahr }}</dd></div>{% endif %}
<div>
<dt class="inline font-medium text-slate-400">Aktualisiert: </dt>
<dd class="inline">{{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }}</dd>
</div>
</dl>
{% if eintrag.status == 'neuheit' %}
<form method="post" action="/planung/uebernehmen/{{ eintrag.id }}" class="mt-2">
<button type="submit" title="In die Planungsliste verschieben (läuft durch die dedup-Prüfung)"
class="w-full min-h-[44px] inline-flex items-center justify-center gap-1.5 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-3 py-2 rounded-lg text-sm whitespace-nowrap">{% from "icons.html" import icon_pfeil_rechts %}{{ icon_pfeil_rechts("h-4 w-4") }} Zur Planung</button>
</form>
{% endif %}
</div>
</li>
{% else %}
<li class="bg-white rounded-xl border border-slate-200 shadow-sm px-4 py-8 text-center text-sm text-slate-400">
Keine Eintr&auml;ge gefunden.
{% if ist_redaktion %}Nutze &bdquo;Jetzt synchronisieren&ldquo;, um die Liste aus BoardGameGeek zu befüllen.{% endif %}
</li>
{% endfor %}
</ul>
<!-- Desktop/Tablet (&ge;768px): Tabellenform -->
<div class="hidden md:block overflow-auto max-h-[70vh] bg-white rounded-xl border border-slate-200 shadow-sm">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="text-left text-slate-500 sticky top-0 z-10 [&>tr>th]:bg-slate-50 [&>tr>th]:shadow-[inset_0_-1px_0_0_#e2e8f0]">
<tr> <tr>
<th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Cover</th>
{% set basis = '/neuheiten?q=' ~ (q | urlencode) ~ '&status=' ~ (status_filter | urlencode) %} {% set basis = '/neuheiten?q=' ~ (q | urlencode) ~ '&status=' ~ (status_filter | urlencode) %}
{% for key, label in [('titel', 'Spieltitel'), ('verlag', 'Verlag'), ('autor', 'Autor'), ('jahr', 'Erscheinungsjahr'), ('status', 'Status'), ('aktualisiert', 'Aktualisiert')] %} {% for key, label in [('titel', 'Spieltitel'), ('verlag', 'Verlag'), ('autor', 'Autor'), ('jahr', 'Erscheinungsjahr'), ('status', 'Status'), ('aktualisiert', 'Aktualisiert')] %}
<th class="px-4 py-3 font-medium whitespace-nowrap"> <th class="px-4 py-3 font-medium whitespace-nowrap text-xs uppercase tracking-wider">
{% if sort == key %} {% if sort == key %}
<a href="{{ basis }}&sort={{ key }}&richtung={{ 'auf' if richtung == 'ab' else 'ab' }}" <a href="{{ basis }}&sort={{ key }}&richtung={{ 'auf' if richtung == 'ab' else 'ab' }}"
class="hover:text-emerald-700">{{ label }} {{ '&#9650;' if richtung == 'auf' else '&#9660;' }}</a> class="inline-flex items-center gap-1 hover:text-emerald-700">{{ label }}
{% from "icons.html" import icon_chevron_oben, icon_chevron_unten %}
{% if richtung == 'auf' %}{{ icon_chevron_oben("h-3 w-3") }}{% else %}{{ icon_chevron_unten("h-3 w-3") }}{% endif %}
<span class="sr-only">(sortiert {{ 'aufsteigend' if richtung == 'auf' else 'absteigend' }})</span>
</a>
{% else %} {% else %}
<a href="{{ basis }}&sort={{ key }}&richtung=auf" class="hover:text-emerald-700 text-slate-400">{{ label }}</a> <a href="{{ basis }}&sort={{ key }}&richtung=auf" class="hover:text-emerald-700 text-slate-400">{{ label }}</a>
{% endif %} {% endif %}
</th> </th>
{% endfor %} {% endfor %}
<th class="px-4 py-3 font-medium">Aktion</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Aktion</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
{% for eintrag in eintraege %} {% for eintrag in eintraege %}
<tr class="hover:bg-slate-50"> <tr class="hover:bg-slate-50">
<td class="px-4 py-3">
{% set cover_bild = eintrag.bild_url %}
{% set cover_titel = eintrag.titel %}
{% include "neuheiten/_cover.html" %}
</td>
<td class="px-4 py-3 font-medium"> <td class="px-4 py-3 font-medium">
{{ eintrag.titel }} {{ eintrag.titel }}
{% if eintrag.bgg_id %} {% if eintrag.bgg_id %}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener" <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> class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
{% elif eintrag.quelle and eintrag.quelle != 'boardgamegeek' %} {% elif eintrag.quelle and eintrag.quelle != 'boardgamegeek' %}
<span class="ml-1 text-[10px] uppercase tracking-wide text-sky-600/70 align-super">{{ eintrag.quelle }}</span> <a href="{{ eintrag.quellen_url or '#' }}" target="_blank" rel="noopener"
title="Zur Quelle ({{ eintrag.quelle }})"
class="ml-1 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }}&nbsp;</a>
{% endif %} {% endif %}
</td> </td>
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td> <td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
<td class="px-4 py-3">{{ eintrag.autor or '—' }}</td> <td class="px-4 py-3">{{ eintrag.autor or '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ eintrag.erscheinungsjahr if eintrag.erscheinungsjahr else '—' }}</td> <td class="px-4 py-3 whitespace-nowrap">{{ eintrag.erscheinungsjahr if eintrag.erscheinungsjahr else '—' }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<span class="inline-block px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 text-xs">{{ eintrag.status }}</span> {% from "status.html" import status_badge %}{{ status_badge(eintrag.status) }}
</td> </td>
<td class="px-4 py-3 whitespace-nowrap text-slate-500"> <td class="px-4 py-3 whitespace-nowrap text-slate-500">
{{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }} {{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }}
@@ -43,14 +106,14 @@
{% if eintrag.status == 'neuheit' %} {% if eintrag.status == 'neuheit' %}
<form method="post" action="/planung/uebernehmen/{{ eintrag.id }}"> <form method="post" action="/planung/uebernehmen/{{ eintrag.id }}">
<button type="submit" title="In die Planungsliste verschieben (läuft durch die dedup-Prüfung)" <button type="submit" title="In die Planungsliste verschieben (läuft durch die dedup-Prüfung)"
class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2 py-1 rounded-lg whitespace-nowrap">&#8594; Zur Planung</button> class="inline-flex items-center gap-1 text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2.5 py-2 rounded-lg whitespace-nowrap">{% from "icons.html" import icon_pfeil_rechts %}{{ icon_pfeil_rechts("h-3.5 w-3.5") }} Zur Planung</button>
</form> </form>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="7" class="px-4 py-8 text-center text-slate-400"> <td colspan="8" class="px-4 py-8 text-center text-slate-400">
Keine Eintr&auml;ge gefunden. Keine Eintr&auml;ge gefunden.
{% if ist_redaktion %}Nutze &bdquo;Jetzt synchronisieren&ldquo;, um die Liste aus BoardGameGeek zu befüllen.{% endif %} {% if ist_redaktion %}Nutze &bdquo;Jetzt synchronisieren&ldquo;, um die Liste aus BoardGameGeek zu befüllen.{% endif %}
</td> </td>
@@ -59,4 +122,6 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% from "seitennavigation.html" import seitennavigation %}
{{ seitennavigation(seiten_url, seite, gesamt_seiten) }}
</div> </div>

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1> <h1 class="text-2xl font-bold text-slate-900 mb-2">{{ titel }}</h1>
<p class="text-slate-600 mb-4"> <p class="text-slate-600 mb-4">
Neuheitenliste aus der <a href="https://boardgamegeek.com" class="text-emerald-700 underline">BoardGameGeek</a>-Datenbank. Neuheitenliste aus der <a href="https://boardgamegeek.com" class="text-emerald-700 underline">BoardGameGeek</a>-Datenbank.
Erweiterungen und Prototypen werden beim Sync automatisch gefiltert. Erweiterungen und Prototypen werden beim Sync automatisch gefiltert.
@@ -15,22 +15,22 @@
</div> </div>
{% endif %} {% endif %}
{% if fehler %} {% 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> <div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm" role="alert" tabindex="-1">{{ fehler }}</div>
{% endif %} {% endif %}
<div class="flex flex-wrap items-end gap-3 mb-4"> <div class="flex flex-wrap items-end gap-3 mb-4">
{% if ist_redaktion %} {% if ist_redaktion %}
<form method="post" action="/neuheiten/sync" class="flex items-end gap-2"> <form method="post" action="/neuheiten/sync" class="flex flex-col sm:flex-row items-stretch sm:items-end gap-2">
<div> <div class="sm:w-72">
<label for="suchbegriff" class="block text-xs font-medium text-slate-500 mb-1"> <label for="suchbegriff" class="block text-xs font-medium text-slate-500 mb-1">
Sofort auf BoardGameGeek suchen &amp; importieren (optional) Sofort auf BoardGameGeek suchen &amp; importieren (optional)
</label> </label>
<input type="text" id="suchbegriff" name="suchbegriff" placeholder="Spielname oder Suchbegriff …" <input type="text" id="suchbegriff" name="suchbegriff" placeholder="Spielname oder Suchbegriff …"
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"> class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<button type="submit" <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"> class="min-h-[44px] inline-flex items-center justify-center gap-1.5 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
&#8635; Jetzt synchronisieren {% from "icons.html" import icon_sync %}{{ icon_sync("h-4 w-4") }} Jetzt synchronisieren
</button> </button>
</form> </form>
{% endif %} {% endif %}
@@ -47,8 +47,8 @@
{% if ist_redaktion %} {% if ist_redaktion %}
<form method="post" action="/neuheiten/quellen-sync"> <form method="post" action="/neuheiten/quellen-sync">
<button type="submit" <button type="submit"
class="bg-sky-600 hover:bg-sky-700 text-white font-medium px-3 py-1.5 rounded-lg text-xs whitespace-nowrap"> class="min-h-[44px] md:min-h-0 inline-flex items-center gap-1.5 bg-sky-600 hover:bg-sky-700 active:bg-sky-800 text-white font-medium px-3 py-1.5 rounded-lg text-xs whitespace-nowrap">
&#8635; Web-Quellen synchronisieren {% from "icons.html" import icon_sync %}{{ icon_sync("h-3.5 w-3.5") }} Web-Quellen synchronisieren
</button> </button>
</form> </form>
{% endif %} {% endif %}
@@ -57,13 +57,13 @@
<table class="w-full text-xs"> <table class="w-full text-xs">
<thead class="text-left text-slate-400"> <thead class="text-left text-slate-400">
<tr> <tr>
<th class="px-2 py-1.5 font-medium">Quelle</th> <th class="px-2 py-1.5 font-medium text-xs uppercase tracking-wider">Quelle</th>
<th class="px-2 py-1.5 font-medium">Status</th> <th class="px-2 py-1.5 font-medium text-xs uppercase tracking-wider">Status</th>
<th class="px-2 py-1.5 font-medium">Letzter Lauf</th> <th class="px-2 py-1.5 font-medium text-xs uppercase tracking-wider">Letzter Lauf</th>
<th class="px-2 py-1.5 font-medium text-right">Neu</th> <th class="px-2 py-1.5 font-medium text-right text-xs uppercase tracking-wider">Neu</th>
<th class="px-2 py-1.5 font-medium text-right">Aktualisiert</th> <th class="px-2 py-1.5 font-medium text-right text-xs uppercase tracking-wider">Aktualisiert</th>
<th class="px-2 py-1.5 font-medium text-right">Gefiltert</th> <th class="px-2 py-1.5 font-medium text-right text-xs uppercase tracking-wider">Gefiltert</th>
<th class="px-2 py-1.5 font-medium">Hinweis</th> <th class="px-2 py-1.5 font-medium text-xs uppercase tracking-wider">Hinweis</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@@ -75,8 +75,24 @@
<span class="ml-1 text-[10px] text-slate-300">{{ quelle.name }}</span> <span class="ml-1 text-[10px] text-slate-300">{{ quelle.name }}</span>
</td> </td>
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
{% if quelle.aktiv %}<span class="inline-block px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700">an</span> <form method="post" action="/neuheiten/quellen/{{ quelle.name }}/schalter" class="inline-flex items-center gap-2">
{% else %}<span class="inline-block px-2 py-0.5 rounded-full bg-slate-100 text-slate-400">aus</span>{% endif %} {% if sync_aktiv %}
<input type="hidden" name="aktiv" value="{{ '0' if quelle.aktiv else '1' }}">
<button type="submit"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors {{ 'bg-emerald-500' if quelle.aktiv else 'bg-slate-300' }}"
title="{{ 'Quelle ausschalten' if quelle.aktiv else 'Quelle einschalten' }}">
<span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform {{ 'translate-x-4' if quelle.aktiv else 'translate-x-0.5' }}"></span>
</button>
{% else %}
<span class="text-xs text-slate-400">{{ 'an' if quelle.aktiv else 'aus' }} (Env)</span>
{% endif %}
</form>
<form method="post" action="/neuheiten/quellen-sync" class="inline">
<input type="hidden" name="quelle" value="{{ quelle.name }}">
<button type="submit"
class="ml-2 text-xs text-emerald-700 hover:text-emerald-900 underline decoration-dotted"
title="Nur diese Quelle jetzt synchronisieren">jetzt sync</button>
</form>
</td> </td>
<td class="px-2 py-1.5 whitespace-nowrap text-slate-500"> <td class="px-2 py-1.5 whitespace-nowrap text-slate-500">
{{ quelle.letzte_laufzeit.strftime('%d.%m.%Y %H:%M') if quelle.letzte_laufzeit else '—' }} {{ quelle.letzte_laufzeit.strftime('%d.%m.%Y %H:%M') if quelle.letzte_laufzeit else '—' }}
@@ -105,18 +121,25 @@
<form method="get" action="/neuheiten" <form method="get" action="/neuheiten"
hx-get="/neuheiten" hx-target="#neuheiten-liste" hx-swap="outerHTML" hx-get="/neuheiten" hx-target="#neuheiten-liste" hx-swap="outerHTML"
hx-trigger="input changed delay:300ms from:find input[name='q'], change from:find select[name='status']" hx-trigger="input changed delay:300ms from:find input[name='q'], change from:find select[name='status'], change from:find select[name='jahr']"
class="flex flex-wrap gap-3 mb-4"> class="grid grid-cols-1 sm:flex sm:flex-wrap gap-3 mb-4">
<input type="text" name="q" value="{{ q }}" placeholder="Suche in Titel, Verlag, Autor …" <input type="text" name="q" value="{{ q }}" placeholder="Suche in Titel, Verlag, Autor …"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-80 focus:outline-none focus:ring-2 focus:ring-emerald-500"> class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 sm:w-80">
<select name="status" class="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white"> <select name="status" class="w-full sm:w-auto min-h-[44px] md:min-h-0 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white">
<option value="">Alle Status</option> <option value="">Alle Status</option>
{% for option in status_optionen %} {% for option in status_optionen %}
<option value="{{ option }}" {% if option == status_filter %}selected{% endif %}>{{ option }}</option> <option value="{{ option }}" {% if option == status_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %} {% endfor %}
</select> </select>
<select name="jahr" class="w-full sm:w-auto min-h-[44px] md:min-h-0 border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white">
<option value="{{ jahr_standard }}">ab {{ jahr_standard }}</option>
{% for jahr in jahr_optionen %}
<option value="{{ jahr }}" {% if jahr|string == jahr_filter|string %}selected{% endif %}>{{ jahr }}</option>
{% endfor %}
<option value="" {% if not jahr_filter %}selected{% endif %}>Alle Jahre</option>
</select>
<noscript> <noscript>
<button type="submit" class="bg-slate-200 hover:bg-slate-300 px-4 py-2 rounded-lg text-sm">Filtern</button> <button type="submit" class="bg-slate-200 hover:bg-slate-300 px-4 py-2 rounded-lg text-sm min-h-[44px] md:min-h-0">Filtern</button>
</noscript> </noscript>
</form> </form>

View File

@@ -24,16 +24,17 @@ der Eintrage-Vorgang bricht.
from __future__ import annotations from __future__ import annotations
import logging import logging
from urllib.parse import quote from math import ceil
from urllib.parse import quote, urlencode
from fastapi import Depends, Form, Request from fastapi import Depends, Form, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from sqlalchemy import select, update from sqlalchemy import func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from redaktionskern.auth.deps import AccessDenied, get_db, require_user from redaktionskern.auth.deps import AccessDenied, get_db, require_user
from redaktionskern.auth.models import Role, User from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry from redaktionskern.contracts import BasePlugin, DashboardKarte, Migration, NavEntry
from redaktionskern.db import Base from redaktionskern.db import Base
from .models import ( from .models import (
@@ -83,11 +84,106 @@ class PlanungPlugin(BasePlugin):
# ---------- Plugin-Vertrag ---------- # ---------- Plugin-Vertrag ----------
def migrations(self) -> list[Migration]: def migrations(self) -> list[Migration]:
return [Migration(version="0001_planungsliste", up=_planungs_tabelle)] def bild_url_spalte(conn) -> None:
"""Migration 0002: Spalte bild_url für Coverbilder (nullable).
Portabel (SQLite/Postgres): `ALTER TABLE … ADD COLUMN` mit
nullable VARCHAR ohne Default; bestehende Zeilen bleiben erhalten.
"""
if conn.dialect.name == "sqlite":
info = conn.exec_driver_sql(
"PRAGMA table_info(planungsliste)"
).fetchall()
if any(zeile[1] == "bild_url" for zeile in info):
return
else:
existiert = conn.exec_driver_sql(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'planungsliste' AND column_name = 'bild_url'"
).scalar()
if existiert is not None:
return
conn.exec_driver_sql(
"ALTER TABLE planungsliste ADD COLUMN bild_url VARCHAR(500)"
)
def quellen_url_spalte(conn) -> None:
"""Migration 0003: Spalte quellen_url (Link zur Herkunftsquelle)."""
if conn.dialect.name == "sqlite":
info = conn.exec_driver_sql(
"PRAGMA table_info(planungsliste)"
).fetchall()
if any(zeile[1] == "quellen_url" for zeile in info):
return
else:
existiert = conn.exec_driver_sql(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'planungsliste' AND column_name = 'quellen_url'"
).scalar()
if existiert is not None:
return
conn.exec_driver_sql(
"ALTER TABLE planungsliste ADD COLUMN quellen_url VARCHAR(500)"
)
return [
Migration(version="0001_planungsliste", up=_planungs_tabelle),
Migration(version="0002_bild_url", up=bild_url_spalte),
Migration(version="0003_quellen_url", up=quellen_url_spalte),
]
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/planung")] return [NavEntry(label=self.title, url="/planung")]
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: Planungs-Stats — Rezensenten ihre eigenen, die
Redaktion die Gesamtzahlen je Status."""
with self.context.session_factory() as db:
zaehler = dict(
db.execute(
select(Planungseintrag.status, func.count())
.group_by(Planungseintrag.status)
).all()
)
if user.role in REDAKTION:
return [
DashboardKarte(
titel="Planung",
wert=str(zaehler.get("offen", 0)),
beschreibung="offene Einträge",
zeilen=tuple(
f"{STATUS_ANZEIGE[s]}: {zaehler.get(s, 0)}"
for s in GUELTIGE_STATUS
),
link_url="/planung",
link_label="Zur Planung",
ton="neutral",
)
]
eigene = dict(
db.execute(
select(Planungseintrag.status, func.count())
.where(Planungseintrag.rezensent_id == user.id)
.group_by(Planungseintrag.status)
).all()
)
return [
DashboardKarte(
titel="Meine offenen Planungseinträge",
wert=str(eigene.get("offen", 0)),
link_url="/planung?status=offen",
link_label="Anzeigen",
ton="neutral",
),
DashboardKarte(
titel="Meine Einträge in Bearbeitung",
wert=str(eigene.get("in_bearbeitung", 0)),
link_url="/planung?status=in_bearbeitung",
link_label="Anzeigen",
ton="neutral",
),
]
# ---------- Hilfsfunktionen ---------- # ---------- Hilfsfunktionen ----------
@staticmethod @staticmethod
@@ -161,6 +257,7 @@ class PlanungPlugin(BasePlugin):
aktion: str, aktion: str,
versteckt: dict[str, str], versteckt: dict[str, str],
abbrechen_url: str = "/planung", abbrechen_url: str = "/planung",
bild_url: str | None = None,
): ):
"""Auswahl-Dialog bei Verlags-Konflikt: Welcher Verlag wird geführt?""" """Auswahl-Dialog bei Verlags-Konflikt: Welcher Verlag wird geführt?"""
return self.context.templates.TemplateResponse( return self.context.templates.TemplateResponse(
@@ -170,6 +267,7 @@ class PlanungPlugin(BasePlugin):
"user": user, "user": user,
"titel_headline": f"Dedup-Prüfung — {titel}", "titel_headline": f"Dedup-Prüfung — {titel}",
"spiel_titel": titel, "spiel_titel": titel,
"bild_url": bild_url,
"konflikte": ergebnis.konflikte, "konflikte": ergebnis.konflikte,
"konflikt_anzeige": KONFLIKT_ANZEIGE, "konflikt_anzeige": KONFLIKT_ANZEIGE,
"titel_empfehlung": ergebnis.titel_empfehlung, "titel_empfehlung": ergebnis.titel_empfehlung,
@@ -192,16 +290,32 @@ class PlanungPlugin(BasePlugin):
status: str = "", status: str = "",
meldung: str = "", meldung: str = "",
fehler: str = "", fehler: str = "",
seite_nr: int = 1,
): ):
"""Planungsliste: alle Einträge mit Zuordnung und Statuswechsel.""" """Planungsliste: Einträge paginiert (50/Seite) mit Zuordnung."""
pro_seite = 50
abfrage = select(Planungseintrag) abfrage = select(Planungseintrag)
if status in GUELTIGE_STATUS: if status in GUELTIGE_STATUS:
abfrage = abfrage.where(Planungseintrag.status == status) abfrage = abfrage.where(Planungseintrag.status == status)
gesamt = db.scalar(
select(func.count()).select_from(abfrage.subquery())
) or 0
gesamt_seiten = max(1, ceil(gesamt / pro_seite))
seite_aktuell = min(max(seite_nr, 1), gesamt_seiten)
eintraege = db.scalars( eintraege = db.scalars(
abfrage.order_by( abfrage.order_by(
Planungseintrag.aktualisiert_am.desc(), Planungseintrag.id.desc() Planungseintrag.aktualisiert_am.desc(), Planungseintrag.id.desc()
) )
.offset((seite_aktuell - 1) * pro_seite)
.limit(pro_seite)
).all() ).all()
def _seiten_url(nr: int) -> str:
parameter = {"seite_nr": str(nr)}
if status in GUELTIGE_STATUS:
parameter["status"] = status
return f"/planung?{urlencode(parameter)}"
namen = { namen = {
benutzer.id: (benutzer.display_name or benutzer.username) benutzer.id: (benutzer.display_name or benutzer.username)
for benutzer in db.scalars(select(User)).all() for benutzer in db.scalars(select(User)).all()
@@ -221,7 +335,10 @@ class PlanungPlugin(BasePlugin):
"ist_redaktion": user.role in REDAKTION, "ist_redaktion": user.role in REDAKTION,
"meldung": meldung[:300], "meldung": meldung[:300],
"fehler": fehler[:300], "fehler": fehler[:300],
"anzahl": len(eintraege), "anzahl": gesamt,
"seite": seite_aktuell,
"gesamt_seiten": gesamt_seiten,
"seiten_url": _seiten_url,
}, },
) )
@@ -354,6 +471,7 @@ class PlanungPlugin(BasePlugin):
aktion=f"/planung/uebernehmen/{neuheit_id}", aktion=f"/planung/uebernehmen/{neuheit_id}",
versteckt={"neuheit_id": str(neuheit_id), "verlag_bestaetigt": "1"}, versteckt={"neuheit_id": str(neuheit_id), "verlag_bestaetigt": "1"},
abbrechen_url="/neuheiten", abbrechen_url="/neuheiten",
bild_url=zeile.bild_url,
) )
gewaehlter_verlag = (verlag_wahl.strip() or (zeile.verlag or "")) or None gewaehlter_verlag = (verlag_wahl.strip() or (zeile.verlag or "")) or None
@@ -362,6 +480,8 @@ class PlanungPlugin(BasePlugin):
verlag=gewaehlter_verlag, verlag=gewaehlter_verlag,
autor=zeile.autor, autor=zeile.autor,
bgg_id=zeile.bgg_id, bgg_id=zeile.bgg_id,
bild_url=zeile.bild_url,
quellen_url=zeile.quellen_url,
ausgabe="", ausgabe="",
rezensent_id=user.id, rezensent_id=user.id,
status="offen", status="offen",

View File

@@ -48,6 +48,9 @@ class Planungseintrag(Base):
autor: Mapped[str | None] = mapped_column(String(300)) autor: Mapped[str | None] = mapped_column(String(300))
#: BoardGameGeek-ID, sofern bekannt (Match-Kriterium der dedup-Prüfung). #: BoardGameGeek-ID, sofern bekannt (Match-Kriterium der dedup-Prüfung).
bgg_id: Mapped[int | None] = mapped_column(Integer) bgg_id: Mapped[int | None] = mapped_column(Integer)
#: URL des Coverbildes (aus der Neuheitenliste übernommen oder leer).
bild_url: Mapped[str | None] = mapped_column(String(500))
quellen_url: Mapped[str | None] = mapped_column(String(500))
#: Magazin-Ausgabe, z. B. „3/2025“ (mehrere Ausgaben parallel möglich). #: Magazin-Ausgabe, z. B. „3/2025“ (mehrere Ausgaben parallel möglich).
ausgabe: Mapped[str] = mapped_column(String(100), default="") ausgabe: Mapped[str] = mapped_column(String(100), default="")
rezensent_id: Mapped[int] = mapped_column(ForeignKey("users.id")) rezensent_id: Mapped[int] = mapped_column(ForeignKey("users.id"))

View File

@@ -0,0 +1,31 @@
{# Coverbild-Vorschau.
Erwartete Variablen: cover_bild (URL oder None), cover_titel,
cover_gross (optional: true für großformatige Ansichten).
Bilder werden direkt von der Quelle eingebettet (Hotlinking, kein
Download) — mit loading="lazy" und decoding="async". Ohne Bild zeigt
das Partial einen dezenten Platzhalter mit SVG-Würfel (in der großen
Ansicht zusätzlich mit dem Hinweis „Kein Cover"). #}
{% from "icons.html" import icon_wuerfel %}
{% set _gross = cover_gross | default(false) %}
{% if _gross %}
{% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async"
class="block h-auto w-auto max-w-[200px] max-h-[280px] rounded-lg border border-slate-200 bg-white shadow-sm">
{% else %}
<div role="img" aria-label="Kein Cover vorhanden" title="Kein Cover vorhanden"
class="flex h-[210px] w-[150px] max-w-[200px] flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 text-slate-300 select-none">
{{ icon_wuerfel("h-12 w-12") }}
<span class="text-xs font-medium text-slate-400">Kein Cover</span>
</div>
{% endif %}
{% else %}
{% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" width="48" height="68"
class="block h-[68px] w-12 shrink-0 rounded border border-slate-200 bg-slate-50 object-cover">
{% else %}
<div role="img" aria-label="Kein Cover vorhanden" title="Kein Cover vorhanden"
class="flex h-[68px] w-12 shrink-0 items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 text-slate-400 select-none">
{{ icon_wuerfel("h-5 w-5") }}
</div>
{% endif %}
{% endif %}

View File

@@ -1,16 +1,24 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">Eintrag bearbeiten</h1> <h1 class="text-2xl font-bold text-slate-900 mb-2">Eintrag bearbeiten</h1>
<p class="text-slate-600 mb-4">„{{ eintrag.titel }}“ &middot; Rezensent: <p class="text-slate-600 mb-4">„{{ eintrag.titel }}“ &middot; Rezensent:
{% if user.id == eintrag.rezensent_id %}du{% else %}{{ user.display_name }}{% endif %} {% if user.id == eintrag.rezensent_id %}du{% else %}{{ user.display_name }}{% endif %}
</p> </p>
<div class="flex flex-col sm:flex-row gap-5 items-start mb-4">
<div class="shrink-0 mx-auto sm:mx-0">
{% set cover_bild = eintrag.bild_url %}
{% set cover_titel = eintrag.titel %}
{% set cover_gross = true %}
{% include "planung/_cover.html" %}
</div>
<form method="post" action="/planung/{{ eintrag.id }}/bearbeiten" <form method="post" action="/planung/{{ eintrag.id }}/bearbeiten"
class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4"> class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4 w-full">
<div> <div>
<label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label> <label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label>
<input type="text" id="titel" name="titel" required value="{{ eintrag.titel }}" <input type="text" id="titel" name="titel" required aria-required="true" value="{{ eintrag.titel }}"
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"> 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>
<div class="grid md:grid-cols-2 gap-4"> <div class="grid md:grid-cols-2 gap-4">
@@ -38,4 +46,5 @@
<a href="{{ abbrechen_url }}" class="text-sm text-slate-500 hover:text-slate-800 underline">Abbrechen</a> <a href="{{ abbrechen_url }}" class="text-sm text-slate-500 hover:text-slate-800 underline">Abbrechen</a>
</div> </div>
</form> </form>
</div>
{% endblock %} {% endblock %}

View File

@@ -1,7 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1> <div x-data="{ loeschFormular: null, loeschTitel: '' }">
<h1 class="text-2xl font-bold text-slate-900 mb-2">{{ titel }}</h1>
<p class="text-slate-600 mb-4"> <p class="text-slate-600 mb-4">
Planungsliste der Redaktion: wer reviewt welches Spiel f&uuml;r welche Ausgabe. Planungsliste der Redaktion: wer reviewt welches Spiel f&uuml;r welche Ausgabe.
Jede Eintragung &mdash; aus den Neuheiten verschoben oder h&auml;ndisch nachgetragen &mdash; Jede Eintragung &mdash; aus den Neuheiten verschoben oder h&auml;ndisch nachgetragen &mdash;
@@ -12,34 +13,35 @@
<div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div> <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 %} {% endif %}
{% if fehler %} {% 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> <div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm" role="alert" tabindex="-1">{{ fehler }}</div>
{% endif %} {% endif %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6"> <div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<h2 class="font-semibold mb-3">Titel h&auml;ndisch nachtragen</h2> <h2 class="text-base font-semibold text-slate-800 mb-3">Titel h&auml;ndisch nachtragen</h2>
<form method="post" action="/planung/neu" class="flex flex-wrap items-end gap-3"> <!-- Mobil: Felder untereinander vollbreit; ab sm: flexibel nebeneinander -->
<div> <form method="post" action="/planung/neu" class="grid grid-cols-1 gap-3 sm:flex sm:flex-wrap sm:items-end">
<div class="sm:w-64 lg:w-72">
<label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label> <label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label>
<input type="text" id="titel" name="titel" required <input type="text" id="titel" name="titel" required aria-required="true"
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"> class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<div> <div class="sm:w-48 lg:w-52">
<label for="verlag" class="block text-xs font-medium text-slate-500 mb-1">Verlag</label> <label for="verlag" class="block text-xs font-medium text-slate-500 mb-1">Verlag</label>
<input type="text" id="verlag" name="verlag" <input type="text" id="verlag" name="verlag"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-52 focus:outline-none focus:ring-2 focus:ring-emerald-500"> class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<div> <div class="sm:w-32">
<label for="ausgabe" class="block text-xs font-medium text-slate-500 mb-1">Ausgabe</label> <label for="ausgabe" class="block text-xs font-medium text-slate-500 mb-1">Ausgabe</label>
<input type="text" id="ausgabe" name="ausgabe" placeholder="z. B. 3/2025" <input type="text" id="ausgabe" name="ausgabe" placeholder="z. B. 3/2025"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-32 focus:outline-none focus:ring-2 focus:ring-emerald-500"> class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<div> <div class="sm:w-32">
<label for="bgg_id" class="block text-xs font-medium text-slate-500 mb-1">BGG-ID (optional)</label> <label for="bgg_id" class="block text-xs font-medium text-slate-500 mb-1">BGG-ID (optional)</label>
<input type="text" id="bgg_id" name="bgg_id" <input type="text" id="bgg_id" name="bgg_id" inputmode="numeric"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-32 focus:outline-none focus:ring-2 focus:ring-emerald-500"> class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<button type="submit" <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"> class="w-full sm:w-auto min-h-[44px] inline-flex items-center justify-center bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
Pr&uuml;fen &amp; eintragen Pr&uuml;fen &amp; eintragen
</button> </button>
</form> </form>
@@ -47,56 +49,132 @@
<div class="flex items-center mb-3 gap-3"> <div class="flex items-center mb-3 gap-3">
<form method="get" action="/planung" class="flex items-center gap-2"> <form method="get" action="/planung" class="flex items-center gap-2">
<select name="status" class="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white" <select name="status" class="border border-slate-300 rounded-lg px-3 py-2 text-sm bg-white min-h-[44px] md:min-h-0"
onchange="this.form.submit()"> onchange="this.form.submit()">
<option value="">Alle Status</option> <option value="">Alle Status</option>
{% for wert in status_optionen %} {% for wert in status_optionen %}
<option value="{{ wert }}" {% if wert == status_filter %}selected{% endif %}>{{ status_anzeige[wert] }}</option> <option value="{{ wert }}" {% if wert == status_filter %}selected{% endif %}>{{ status_anzeige[wert] }}</option>
{% endfor %} {% endfor %}
</select> </select>
<noscript><button type="submit" class="bg-slate-200 hover:bg-slate-300 px-3 py-2 rounded-lg text-sm">Filtern</button></noscript> <noscript><button type="submit" class="bg-slate-200 hover:bg-slate-300 px-3 py-2 rounded-lg text-sm min-h-[44px] md:min-h-0">Filtern</button></noscript>
</form> </form>
<p class="text-xs text-slate-400 ml-auto">{{ anzahl }} Eintr&auml;ge</p> <p class="text-xs text-slate-400 ml-auto">{{ anzahl }} Eintr&auml;ge</p>
</div> </div>
<div class="overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm"> <!-- Mobil (<768px): Karten-Ansicht — Cover links, Titel+Metadaten rechts, Status oben -->
<ul class="md:hidden space-y-3" role="list">
{% for eintrag in eintraege %}
<li class="bg-white rounded-xl border border-slate-200 shadow-sm p-3 flex gap-3">
<div class="pt-0.5">
{% set cover_bild = eintrag.bild_url %}
{% set cover_titel = eintrag.titel %}
{% include "planung/_cover.html" %}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<h3 class="text-sm font-medium leading-snug break-words">
{{ eintrag.titel }}
{% if eintrag.bgg_id %}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener"
class="ml-0.5 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
{% elif eintrag.quellen_url %}
<a href="{{ eintrag.quellen_url }}" target="_blank" rel="noopener"
title="Zur Quelle ({{ eintrag.quelle }})"
class="ml-0.5 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }}&nbsp;</a>
{% endif %}
{% if eintrag.pruefung %}
<span class="ml-0.5 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs align-middle"
title="Die dedup-Prüfung hatte beim Eintragen Treffer.">{% from "icons.html" import icon_warnung %}{{ icon_warnung("h-3.5 w-3.5") }} Pr&uuml;fhinweis</span>
{% endif %}
</h3>
{% from "status.html" import status_badge %}{{ status_badge(eintrag.status, status_anzeige[eintrag.status], "ml-auto shrink-0") }}
</div>
<dl class="mt-1.5 space-y-0.5 text-xs text-slate-500">
{% if eintrag.verlag %}<div><dt class="inline font-medium text-slate-400">Verlag: </dt><dd class="inline">{{ eintrag.verlag }}</dd></div>{% endif %}
{% if eintrag.ausgabe %}<div><dt class="inline font-medium text-slate-400">Ausgabe: </dt><dd class="inline">{{ eintrag.ausgabe }}</dd></div>{% endif %}
<div><dt class="inline font-medium text-slate-400">Rezensent: </dt><dd class="inline">{{ namen.get(eintrag.rezensent_id, '—') }}</dd></div>
<div>
<dt class="inline font-medium text-slate-400">Aktualisiert: </dt>
<dd class="inline">{{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }}</dd>
</div>
</dl>
{% if kann_bearbeiten(eintrag) %}
<div class="mt-2 grid grid-cols-1 gap-2">
{% if eintrag.status == 'offen' %}
<form method="post" action="/planung/{{ eintrag.id }}/status">
<input type="hidden" name="status" value="in_bearbeitung">
<button type="submit" class="w-full min-h-[44px] inline-flex items-center justify-center bg-sky-600 hover:bg-sky-700 active:bg-sky-800 text-white px-3 py-2 rounded-lg text-sm whitespace-nowrap">In Bearbeitung</button>
</form>
{% endif %}
{% if eintrag.status != 'abgeschlossen' %}
<form method="post" action="/planung/{{ eintrag.id }}/status">
<input type="hidden" name="status" value="abgeschlossen">
<button type="submit" class="w-full min-h-[44px] inline-flex items-center justify-center bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-3 py-2 rounded-lg text-sm whitespace-nowrap">Abgeschlossen</button>
</form>
{% endif %}
<a href="/planung/{{ eintrag.id }}/bearbeiten"
class="min-h-[44px] inline-flex items-center justify-center border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-3 py-2 rounded-lg text-sm whitespace-nowrap">Bearbeiten</a>
<form method="post" action="/planung/{{ eintrag.id }}/loeschen"
data-titel="{{ eintrag.titel }}"
@submit.prevent="loeschTitel = $event.target.dataset.titel; loeschFormular = $event.target">
<button type="submit" class="w-full min-h-[44px] inline-flex items-center justify-center border border-red-200 text-red-600 hover:bg-red-50 px-3 py-2 rounded-lg text-sm whitespace-nowrap">L&ouml;schen</button>
</form>
</div>
{% else %}
<p class="mt-2 text-xs text-slate-400">nur {{ namen.get(eintrag.rezensent_id, '') }}</p>
{% endif %}
</div>
</li>
{% else %}
<li class="bg-white rounded-xl border border-slate-200 shadow-sm px-4 py-8 text-center text-sm text-slate-400">
Noch keine Eintr&auml;ge — schiebe einen Titel aus den Neuheiten hierher oder trage ihn oben h&auml;ndisch nach.
</li>
{% endfor %}
</ul>
<!-- Desktop/Tablet (&ge;768px): Tabellenform -->
<div class="hidden md:block overflow-auto max-h-[70vh] bg-white rounded-xl border border-slate-200 shadow-sm">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="text-left text-slate-500 sticky top-0 z-10 [&>tr>th]:bg-slate-50 [&>tr>th]:shadow-[inset_0_-1px_0_0_#e2e8f0]">
<tr> <tr>
<th class="px-4 py-3 font-medium">Spieltitel</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Cover</th>
<th class="px-4 py-3 font-medium">Verlag</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Spieltitel</th>
<th class="px-4 py-3 font-medium">Ausgabe</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Verlag</th>
<th class="px-4 py-3 font-medium">Rezensent</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Ausgabe</th>
<th class="px-4 py-3 font-medium">Status</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Rezensent</th>
<th class="px-4 py-3 font-medium">Aktualisiert</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Status</th>
<th class="px-4 py-3 font-medium">Aktionen</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Aktualisiert</th>
<th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
{% for eintrag in eintraege %} {% for eintrag in eintraege %}
<tr class="hover:bg-slate-50"> <tr class="hover:bg-slate-50">
<td class="px-4 py-3">
{% set cover_bild = eintrag.bild_url %}
{% set cover_titel = eintrag.titel %}
{% include "planung/_cover.html" %}
</td>
<td class="px-4 py-3 font-medium"> <td class="px-4 py-3 font-medium">
{{ eintrag.titel }} {{ eintrag.titel }}
{% if eintrag.bgg_id %} {% if eintrag.bgg_id %}
<a href="https://boardgamegeek.com/boardgame/{{ eintrag.bgg_id }}" target="_blank" rel="noopener" <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> class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG&nbsp;{{ eintrag.bgg_id }}</a>
{% elif eintrag.quellen_url %}
<a href="{{ eintrag.quellen_url }}" target="_blank" rel="noopener"
title="Zur Quelle ({{ eintrag.quelle }})"
class="ml-1 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }}&nbsp;</a>
{% endif %} {% endif %}
{% if eintrag.pruefung %} {% if eintrag.pruefung %}
<span class="ml-1 inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs" <span class="ml-1 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs"
title="Die dedup-Prüfung hatte beim Eintragen Treffer.">&#9888; Pr&uuml;fhinweis</span> title="Die dedup-Prüfung hatte beim Eintragen Treffer.">{% from "icons.html" import icon_warnung %}{{ icon_warnung("h-3.5 w-3.5") }} Pr&uuml;fhinweis</span>
{% endif %} {% endif %}
</td> </td>
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td> <td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ eintrag.ausgabe or '—' }}</td> <td class="px-4 py-3 whitespace-nowrap">{{ eintrag.ausgabe or '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ namen.get(eintrag.rezensent_id, '—') }}</td> <td class="px-4 py-3 whitespace-nowrap">{{ namen.get(eintrag.rezensent_id, '—') }}</td>
<td class="px-4 py-3 whitespace-nowrap"> <td class="px-4 py-3 whitespace-nowrap">
{% if eintrag.status == 'offen' %} {% from "status.html" import status_badge %}{{ status_badge(eintrag.status, status_anzeige[eintrag.status]) }}
<span class="inline-block px-2 py-0.5 rounded-full bg-slate-200 text-slate-700 text-xs">{{ status_anzeige[eintrag.status] }}</span>
{% elif eintrag.status == 'in_bearbeitung' %}
<span class="inline-block px-2 py-0.5 rounded-full bg-sky-100 text-sky-800 text-xs">{{ status_anzeige[eintrag.status] }}</span>
{% else %}
<span class="inline-block px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 text-xs">{{ status_anzeige[eintrag.status] }}</span>
{% endif %}
</td> </td>
<td class="px-4 py-3 whitespace-nowrap text-slate-500"> <td class="px-4 py-3 whitespace-nowrap text-slate-500">
{{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }} {{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }}
@@ -107,19 +185,21 @@
{% if eintrag.status == 'offen' %} {% if eintrag.status == 'offen' %}
<form method="post" action="/planung/{{ eintrag.id }}/status"> <form method="post" action="/planung/{{ eintrag.id }}/status">
<input type="hidden" name="status" value="in_bearbeitung"> <input type="hidden" name="status" value="in_bearbeitung">
<button type="submit" class="text-xs bg-sky-600 hover:bg-sky-700 text-white px-2 py-1 rounded-lg whitespace-nowrap">In Bearbeitung</button> <button type="submit" class="text-xs bg-sky-600 hover:bg-sky-700 text-white px-2.5 py-2 rounded-lg whitespace-nowrap">In Bearbeitung</button>
</form> </form>
{% endif %} {% endif %}
{% if eintrag.status != 'abgeschlossen' %} {% if eintrag.status != 'abgeschlossen' %}
<form method="post" action="/planung/{{ eintrag.id }}/status"> <form method="post" action="/planung/{{ eintrag.id }}/status">
<input type="hidden" name="status" value="abgeschlossen"> <input type="hidden" name="status" value="abgeschlossen">
<button type="submit" class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2 py-1 rounded-lg whitespace-nowrap">Abgeschlossen</button> <button type="submit" class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2.5 py-2 rounded-lg whitespace-nowrap">Abgeschlossen</button>
</form> </form>
{% endif %} {% endif %}
<a href="/planung/{{ eintrag.id }}/bearbeiten" <a href="/planung/{{ eintrag.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> class="text-xs border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-2.5 py-2 rounded-lg whitespace-nowrap">Bearbeiten</a>
<form method="post" action="/planung/{{ eintrag.id }}/loeschen"> <form method="post" action="/planung/{{ eintrag.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> data-titel="{{ eintrag.titel }}"
@submit.prevent="loeschTitel = $event.target.dataset.titel; loeschFormular = $event.target">
<button type="submit" class="text-xs border border-red-200 text-red-600 hover:bg-red-50 px-2.5 py-2 rounded-lg whitespace-nowrap">L&ouml;schen</button>
</form> </form>
</div> </div>
{% else %} {% else %}
@@ -129,7 +209,7 @@
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="7" class="px-4 py-8 text-center text-slate-400"> <td colspan="8" class="px-4 py-8 text-center text-slate-400">
Noch keine Eintr&auml;ge — schiebe einen Titel aus den Neuheiten hierher oder trage ihn oben h&auml;ndisch nach. Noch keine Eintr&auml;ge — schiebe einen Titel aus den Neuheiten hierher oder trage ihn oben h&auml;ndisch nach.
</td> </td>
</tr> </tr>
@@ -137,4 +217,27 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% from "seitennavigation.html" import seitennavigation %}
{{ seitennavigation(seiten_url, seite, gesamt_seiten) }}
<!-- Lösch-Bestätigungsdialog (Progressive Enhancement: ohne JavaScript
sendet das Formular sofort, mit Alpine öffnet sich dieser Dialog). -->
<div x-show="loeschFormular !== null" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog" aria-modal="true" aria-labelledby="loesch-dialog-titel">
<div class="absolute inset-0 bg-slate-900/50" @click="loeschFormular = null"></div>
<div class="relative bg-white rounded-xl shadow-xl max-w-sm w-full p-5">
<h2 id="loesch-dialog-titel" class="text-base font-semibold text-slate-800 mb-1">Eintrag löschen?</h2>
<p class="text-sm text-slate-600 mb-4">
&bdquo;<span x-text="loeschTitel"></span>&ldquo; wird endgültig gelöscht.
Das lässt sich nicht rückgängig machen.
</p>
<div class="flex justify-end gap-2">
<button type="button" @click="loeschFormular = null"
class="px-4 py-2 rounded-lg border border-slate-300 text-sm hover:bg-slate-50">Abbrechen</button>
<button type="button" @click="if (loeschFormular) { loeschFormular.submit(); }"
class="px-4 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium">Löschen</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -1,14 +1,21 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel_headline }}</h1> <h1 class="text-2xl font-bold text-slate-900 mb-2">{{ titel_headline }}</h1>
<p class="text-slate-600 mb-4"> <p class="text-slate-600 mb-4">
Die automatische Dedup-Pr&uuml;fung hat Treffer ergeben. Bitte pr&uuml;fen, bevor Die automatische Dedup-Pr&uuml;fung hat Treffer ergeben. Bitte pr&uuml;fen, bevor
der Eintrag angelegt wird. der Eintrag angelegt wird.
</p> </p>
<div class="bg-amber-50 border border-amber-300 rounded-xl p-4 mb-6 text-sm"> <div class="flex flex-col sm:flex-row gap-4 items-start">
<p class="font-medium text-amber-800 mb-2">&#9888; {{ konflikte|length }} Treffer f&uuml;r „{{ spiel_titel }}“</p> <div class="shrink-0 mx-auto sm:mx-0">
{% set cover_bild = bild_url %}
{% set cover_titel = spiel_titel %}
{% set cover_gross = true %}
{% include "planung/_cover.html" %}
</div>
<div class="bg-amber-50 border border-amber-300 rounded-xl p-4 mb-0 sm:mb-6 text-sm w-full">
<p class="font-medium text-amber-800 mb-2 flex items-center gap-1.5">{% from "icons.html" import icon_warnung %}{{ icon_warnung("h-4 w-4") }} {{ konflikte|length }} Treffer f&uuml;r „{{ spiel_titel }}“</p>
<ul class="list-disc list-inside space-y-1 text-slate-700"> <ul class="list-disc list-inside space-y-1 text-slate-700">
{% for konflikt in konflikte %} {% for konflikt in konflikte %}
<li> <li>
@@ -27,13 +34,14 @@
<p class="mt-2 text-slate-600">Empfohlener Titel: <span class="font-medium">{{ titel_empfehlung }}</span></p> <p class="mt-2 text-slate-600">Empfohlener Titel: <span class="font-medium">{{ titel_empfehlung }}</span></p>
{% endif %} {% endif %}
</div> </div>
</div>
<form method="post" action="{{ aktion }}" class="bg-white border border-slate-200 rounded-xl shadow-sm p-4"> <form method="post" action="{{ aktion }}" class="bg-white border border-slate-200 rounded-xl shadow-sm p-4">
{% for name, wert in versteckt.items() %} {% for name, wert in versteckt.items() %}
<input type="hidden" name="{{ name }}" value="{{ wert }}"> <input type="hidden" name="{{ name }}" value="{{ wert }}">
{% endfor %} {% endfor %}
<h2 class="font-semibold mb-3">Welcher Verlag soll gef&uuml;hrt werden?</h2> <h2 class="text-base font-semibold text-slate-800 mb-3">Welcher Verlag soll gef&uuml;hrt werden?</h2>
<div class="space-y-2 mb-4 text-sm"> <div class="space-y-2 mb-4 text-sm">
{% for option in verlags_optionen %} {% for option in verlags_optionen %}
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">

View File

@@ -16,9 +16,11 @@ from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape
from sqlalchemy import func, select from sqlalchemy import func, select
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from redaktionskern.auth import routen as auth_routen from redaktionskern.auth import routen as auth_routen
@@ -109,6 +111,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
redoc_url=None, redoc_url=None,
openapi_url=None, openapi_url=None,
) )
# Statische Kern-Dateien (z. B. „Powered by BGG“-Logo, Pflicht für
# öffentliche BGG-XML-API-Anwendungen).
statisch = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=statisch), name="statisch")
app.state.settings = settings app.state.settings = settings
app.state.engine = engine app.state.engine = engine
app.state.session_factory = session_factory app.state.session_factory = session_factory
@@ -129,6 +135,33 @@ def create_app(settings: Settings | None = None) -> FastAPI:
for plugin in plugins: for plugin in plugins:
app.include_router(plugin.router) app.include_router(plugin.router)
class NavBadgeMiddleware(BaseHTTPMiddleware):
"""Sammelt optionale Zähler-Badges der Plugins für die Navigation.
Läuft INNERHALB der SessionMiddleware (wurde vor ihr registriert),
greift also nur lesend auf die Session zu. Fehler eines Plugins
dürfen die Anfrage nie brechen.
"""
async def dispatch(self, request: Request, call_next):
badges: dict[str, int] = {}
user_id = request.session.get("user_id")
if user_id is not None:
for plugin in registry.all():
try:
for url, anzahl in (plugin.nav_badges(user_id) or {}).items():
if anzahl and anzahl > 0:
badges[url] = badges.get(url, 0) + anzahl
except Exception: # pragma: no cover - defensiv
import logging
logging.getLogger("redaktionskern").exception(
"nav_badges von Plugin %r fehlgeschlagen.", plugin.name
)
request.state.nav_badges = badges
return await call_next(request)
app.add_middleware(NavBadgeMiddleware)
app.add_middleware( app.add_middleware(
SessionMiddleware, SessionMiddleware,
secret_key=settings.session_secret, secret_key=settings.session_secret,

View File

@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_current_user, get_db, require_roles, require_user from redaktionskern.auth.deps import get_current_user, get_db, require_roles, require_user
from redaktionskern.auth.models import ROLEN, Role, User from redaktionskern.auth.models import ROLEN, Role, User
from redaktionskern.auth.security import hash_password, verify_password from redaktionskern.auth.security import hash_password, verify_password
from redaktionskern.contracts import DashboardKarte
router = APIRouter() router = APIRouter()
admin_router = APIRouter( admin_router = APIRouter(
@@ -24,9 +25,44 @@ def _render(request: Request, name: str, status_code: int = 200, **kontext):
# ---------------- Dashboard ---------------- # ---------------- Dashboard ----------------
def _plugin_dashboard_karten(request: Request, user: User) -> list[DashboardKarte]:
"""Sammelt die Dashboard-Karten aller geladenen Plugins ein.
Fachlogik bleibt in den Plugins (Plugin-Vertrag) — der Kern ruft nur
den optionalen Hook auf und duldet Fehler einzelner Plugins.
"""
karten: list[DashboardKarte] = []
for plugin in request.app.state.registry.all():
try:
karten.extend(plugin.dashboard_karten(user))
except Exception: # pragma: no cover - defensiv
import logging
logging.getLogger("redaktionskern").exception(
"Dashboard-Karten von Plugin %r fehlgeschlagen.", plugin.name
)
return karten
@router.get("/") @router.get("/")
def dashboard(request: Request, user: User = Depends(require_user)): def dashboard(
return _render(request, "dashboard.html", user=user) request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
karten = _plugin_dashboard_karten(request, user)
if user.role == Role.ADMIN.value:
benutzer_anzahl = db.scalar(select(func.count()).select_from(User)) or 0
karten.append(
DashboardKarte(
titel="Benutzer",
wert=str(benutzer_anzahl),
beschreibung="Angelegte Benutzerkonten",
link_url="/admin/benutzer",
link_label="Benutzerverwaltung",
)
)
return _render(request, "dashboard.html", user=user, karten=karten)
# ---------------- Login / Logout ---------------- # ---------------- Login / Logout ----------------

View File

@@ -33,6 +33,24 @@ class NavEntry:
url: str url: str
@dataclass(frozen=True)
class DashboardKarte:
"""Eine Karte auf dem Dashboard (von Plugins beigesteuert).
`ton` steuert die Farbgebung: neutral | ok | warnung | fehler.
`zeilen` sind optionale Kurzzeilen unter dem Hauptwert (z. B. die
nächsten Redaktionsschlüsse).
"""
titel: str
wert: str = ""
beschreibung: str = ""
zeilen: tuple[str, ...] = ()
link_url: str | None = None
link_label: str = "Öffnen"
ton: str = "neutral"
@dataclass(frozen=True) @dataclass(frozen=True)
class Migration: class Migration:
"""Eine Schema-Migration des Plugins.""" """Eine Schema-Migration des Plugins."""
@@ -88,6 +106,23 @@ class BasePlugin:
"""Einträge für die Hauptnavigation (leer = keiner).""" """Einträge für die Hauptnavigation (leer = keiner)."""
return [] return []
def nav_badges(self, user_id: int) -> dict[str, int]:
"""Zähler-Badges für Navigations-Einträge: {URL: Anzahl}.
Der Kern ruft diesen Hook pro Anfrage für den angemeldeten
Benutzer auf (leer = kein Badge). Werte <= 0 werden ignoriert.
"""
return {}
def dashboard_karten(self, user) -> list[DashboardKarte]:
"""Dashboard-Karten des Plugins für den angemeldeten Benutzer.
Das Plugin entscheidet selbst anhand von `user.role`, welche
Karten es zeigt (leer = keine). Fehler werden vom Kern geduldet
und übersprungen, damit eine defekte Karte nie das Dashboard bricht.
"""
return []
def templates_dir(self) -> Path | None: def templates_dir(self) -> Path | None:
"""Template-Ordner des Plugins (<Paket>/templates), falls vorhanden.""" """Template-Ordner des Plugins (<Paket>/templates), falls vorhanden."""
modul = sys.modules.get(type(self).__module__) modul = sys.modules.get(type(self).__module__)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -7,19 +7,60 @@
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
<style>[x-cloak] { display: none !important; }</style>
<style type="text/tailwindcss">
/* Einheitlicher Fokus-Ring für Formularelemente und Button-Links */
.focus-ring { @apply focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500; }
</style>
<style>
/* Fallback ohne Klassen-Anpassung: sichtbarer Fokus auf allen
Bedienelementen (Tailwind-Ring überlagert diesen, wo vorhanden). */
input:focus-visible, select:focus-visible, textarea:focus-visible,
button:focus-visible, summary:focus-visible, a:focus-visible {
outline: 2px solid #10b981;
outline-offset: 1px;
}
input:focus-visible, select:focus-visible, textarea:focus-visible {
border-color: #10b981;
}
</style>
</head> </head>
<body class="bg-slate-100 text-slate-800 min-h-screen flex flex-col"> <body class="bg-slate-100 text-slate-800 min-h-screen flex flex-col">
<header class="bg-white border-b border-slate-200"> <header class="bg-white border-b border-slate-200" x-data="{ menue: false }">
<nav class="max-w-6xl mx-auto px-4 py-3 flex items-center gap-5" aria-label="Hauptnavigation"> <nav class="max-w-6xl mx-auto px-4 py-2 md:py-3 flex items-center gap-5" aria-label="Hauptnavigation">
<a href="/" class="font-bold text-emerald-700 whitespace-nowrap">&#127923; {{ app_name }}</a> <a href="/" class="font-bold text-emerald-700 whitespace-nowrap inline-flex items-center gap-1.5">
{% from "icons.html" import icon_wuerfelbecher %}{{ icon_wuerfelbecher("h-5 w-5") }} {{ app_name }}
</a>
<!-- Desktop: horizontale Navigationsleiste -->
<span class="hidden md:flex items-center gap-5 flex-1">
{% for eintrag in plugin_nav %} {% for eintrag in plugin_nav %}
<a href="{{ eintrag.url }}" class="text-sm text-slate-600 hover:text-emerald-700">{{ eintrag.label }}</a> {% set aktiv = request.url.path == eintrag.url or request.url.path.startswith(eintrag.url ~ '/') %}
{% set badge_n = request.state.nav_badges.get(eintrag.url, 0) %}
<a href="{{ eintrag.url }}" {% if aktiv %}aria-current="page"{% endif %}
class="text-sm pb-0.5 border-b-2 inline-flex items-center {% if aktiv %}text-emerald-700 font-semibold border-emerald-600{% else %}text-slate-600 border-transparent hover:text-emerald-700 hover:border-emerald-300{% endif %}">{{ eintrag.label }}
{%- if badge_n %}<span class="ml-1.5 inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full bg-red-600 text-white text-[11px] font-semibold" aria-label="{{ badge_n }} ungelesen">{{ badge_n }}</span>{% endif -%}
</a>
{% endfor %} {% endfor %}
<div class="ml-auto relative" x-data="{ offen: false }"> </span>
<!-- Mobil: Burger-Button (Alpine.js), Touch-Ziel &ge;&ge; 44px -->
<button type="button" @click="menue = !menue" :aria-expanded="menue.toString()"
aria-controls="hauptmenue-mobil" aria-label="Navigation umschalten"
class="md:hidden ml-auto inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-lg text-slate-600 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-emerald-500">
<svg x-show="!menue" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
<svg x-show="menue" x-cloak xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<div class="relative" x-data="{ offen: false }">
{% if user %} {% if user %}
<button type="button" @click="offen = !offen" <button type="button" @click="offen = !offen"
class="text-sm font-medium px-2 py-1 rounded hover:bg-slate-100"> class="text-sm font-medium px-2 py-1 rounded hover:bg-slate-100 inline-flex items-center min-h-[44px] md:min-h-0">
{{ user.display_name }} &#9662; {{ user.display_name }}
{% from "icons.html" import icon_chevron_unten %}{{ icon_chevron_unten("h-3.5 w-3.5") }}
</button> </button>
<div x-show="offen" x-cloak @click.outside="offen = false" <div x-show="offen" x-cloak @click.outside="offen = false"
class="absolute right-0 mt-2 w-56 bg-white border border-slate-200 rounded-lg shadow-lg p-3 text-sm z-10"> class="absolute right-0 mt-2 w-56 bg-white border border-slate-200 rounded-lg shadow-lg p-3 text-sm z-10">
@@ -33,16 +74,39 @@
</form> </form>
</div> </div>
{% else %} {% else %}
<a href="/login" class="text-sm font-medium text-emerald-700">Anmelden</a> <a href="/login" class="text-sm font-medium text-emerald-700 inline-flex items-center min-h-[44px] md:min-h-0 px-2">Anmelden</a>
{% endif %} {% endif %}
</div> </div>
</nav> </nav>
<!-- Mobil: ausklappbare Navigation (Burger-Menü) -->
<div id="hauptmenue-mobil" x-show="menue" x-cloak @click.outside="menue = false"
class="md:hidden border-t border-slate-200 bg-white px-4 pb-3 pt-2">
{% for eintrag in plugin_nav %}
{% set aktiv = request.url.path == eintrag.url or request.url.path.startswith(eintrag.url ~ '/') %}
{% set badge_n = request.state.nav_badges.get(eintrag.url, 0) %}
<a href="{{ eintrag.url }}" @click="menue = false" {% if aktiv %}aria-current="page"{% endif %}
class="flex items-center py-3 text-sm border-b border-slate-100 last:border-0 {% if aktiv %}font-semibold text-emerald-700{% else %}font-medium text-slate-700 hover:text-emerald-700{% endif %}">
{{ eintrag.label }}
{%- if badge_n %}<span class="ml-2 inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full bg-red-600 text-white text-[11px] font-semibold" aria-label="{{ badge_n }} ungelesen">{{ badge_n }}</span>{% endif -%}
</a>
{% endfor %}
{% if not user %}
<a href="/login" @click="menue = false"
class="block py-3 text-sm font-medium text-emerald-700 border-t border-slate-100 mt-1">Anmelden</a>
{% endif %}
</div>
</header> </header>
<main class="flex-1 max-w-6xl w-full mx-auto px-4 py-6"> <main class="flex-1 max-w-6xl w-full mx-auto px-4 py-6">
{% block inhalt %}{% endblock %} {% block inhalt %}{% endblock %}
</main> </main>
<footer class="text-center text-xs text-slate-400 py-4"> <footer class="text-center text-xs text-slate-400 py-4">
{{ app_name }} &middot; Plugin-Anzahl: {{ geladene_plugins|length }} {{ app_name }} &middot; Plugin-Anzahl: {{ geladene_plugins|length }}
<div class="mt-2">
<a href="https://boardgamegeek.com" target="_blank" rel="noopener noreferrer">
<img src="/static/powered-by-bgg.png" alt="Powered by BoardGameGeek"
width="120" height="35" class="inline-block opacity-80 hover:opacity-100">
</a>
</div>
</footer> </footer>
</body> </body>
</html> </html>

View File

@@ -1,12 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer bearbeiten{% endif %} &mdash; {{ app_name }}{% endblock %} {% block titel %}{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer bearbeiten{% endif %} &mdash; {{ app_name }}{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-4"> <h1 class="text-2xl font-bold text-slate-900 mb-4">
{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer &bdquo;{{ ziel.username }}&ldquo; bearbeiten{% endif %} {% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer &bdquo;{{ ziel.username }}&ldquo; bearbeiten{% endif %}
</h1> </h1>
{% if fehler %} {% if fehler %}
<div class="mb-4 max-w-lg rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2" role="alert"> <div class="mb-4 max-w-lg rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2" role="alert" tabindex="-1" id="formular-fehler">
{{ fehler }} {{ fehler }}
</div> </div>
{% endif %} {% endif %}
@@ -17,9 +17,9 @@
<div> <div>
<label for="username" class="block text-sm font-medium mb-1">Benutzername</label> <label for="username" class="block text-sm font-medium mb-1">Benutzername</label>
<input id="username" name="username" value="{{ form.username }}" required minlength="3" <input id="username" name="username" value="{{ form.username }}" required aria-required="true" minlength="3"
{% if aktion != 'neu' %}disabled{% endif %} {% if aktion != 'neu' %}disabled{% endif %}
class="w-full border border-slate-300 rounded-lg px-3 py-2 {% if aktion != 'neu' %}bg-slate-100{% endif %}"> class="w-full border border-slate-300 rounded-lg px-3 py-2 focus-ring {% if aktion != 'neu' %}bg-slate-100{% endif %}">
{% if aktion != 'neu' %} {% if aktion != 'neu' %}
<p class="text-xs text-slate-400 mt-1">Der Benutzername kann nicht ge&auml;ndert werden.</p> <p class="text-xs text-slate-400 mt-1">Der Benutzername kann nicht ge&auml;ndert werden.</p>
{% endif %} {% endif %}
@@ -28,7 +28,7 @@
<div> <div>
<label for="display_name" class="block text-sm font-medium mb-1">Anzeigename</label> <label for="display_name" class="block text-sm font-medium mb-1">Anzeigename</label>
<input id="display_name" name="display_name" value="{{ form.display_name }}" <input id="display_name" name="display_name" value="{{ form.display_name }}"
class="w-full border border-slate-300 rounded-lg px-3 py-2"> class="w-full border border-slate-300 rounded-lg px-3 py-2 focus-ring">
</div> </div>
<div> <div>
@@ -41,7 +41,7 @@
<input type="hidden" name="role" value="{{ form.role }}"> <input type="hidden" name="role" value="{{ form.role }}">
{% else %} {% else %}
<select id="role" name="role" <select id="role" name="role"
class="w-full border border-slate-300 rounded-lg px-3 py-2 bg-white"> class="w-full border border-slate-300 rounded-lg px-3 py-2 bg-white focus-ring">
{% for rollen_wert, rollen_text in rollen_anzeige.items() %} {% for rollen_wert, rollen_text in rollen_anzeige.items() %}
<option value="{{ rollen_wert }}" {% if form.role == rollen_wert %}selected{% endif %}>{{ rollen_text }}</option> <option value="{{ rollen_wert }}" {% if form.role == rollen_wert %}selected{% endif %}>{{ rollen_text }}</option>
{% endfor %} {% endfor %}
@@ -62,17 +62,17 @@
<label for="password" class="block text-sm font-medium mb-1"> <label for="password" class="block text-sm font-medium mb-1">
{% if aktion == 'neu' %}Passwort{% else %}Neues Passwort (leer = unver&auml;ndert){% endif %} {% if aktion == 'neu' %}Passwort{% else %}Neues Passwort (leer = unver&auml;ndert){% endif %}
</label> </label>
<input id="password" name="password" type="password" {% if aktion == 'neu' %}required{% endif %} minlength="8" <input id="password" name="password" type="password" {% if aktion == 'neu' %}required aria-required="true"{% endif %} minlength="8"
class="w-full border border-slate-300 rounded-lg px-3 py-2"> class="w-full border border-slate-300 rounded-lg px-3 py-2 focus-ring">
</div> </div>
<div class="flex gap-3 pt-2"> <div class="flex flex-col sm:flex-row gap-3 pt-2">
<button type="submit" <button type="submit"
class="bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-lg px-4 py-2"> class="min-h-[44px] inline-flex items-center justify-center bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium rounded-lg px-4 py-2">
Speichern Speichern
</button> </button>
<a href="/admin/benutzer" <a href="/admin/benutzer"
class="border border-slate-300 rounded-lg px-4 py-2 text-sm hover:bg-slate-50">Abbrechen</a> class="min-h-[44px] inline-flex items-center justify-center border border-slate-300 rounded-lg px-4 py-2 text-sm hover:bg-slate-50">Abbrechen</a>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -2,9 +2,9 @@
{% block titel %}Benutzerverwaltung &mdash; {{ app_name }}{% endblock %} {% block titel %}Benutzerverwaltung &mdash; {{ app_name }}{% endblock %}
{% block inhalt %} {% block inhalt %}
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold">Benutzerverwaltung</h1> <h1 class="text-2xl font-bold text-slate-900">Benutzerverwaltung</h1>
<a href="/admin/benutzer/neu" <a href="/admin/benutzer/neu"
class="bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg px-3 py-2 text-sm font-medium"> class="bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white rounded-lg px-3 py-2 text-sm font-medium min-h-[44px] inline-flex items-center">
+ Neuer Benutzer + Neuer Benutzer
</a> </a>
</div> </div>
@@ -13,12 +13,12 @@
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="bg-slate-50 text-left text-slate-500">
<tr> <tr>
<th class="px-4 py-2 font-medium">Benutzername</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Benutzername</th>
<th class="px-4 py-2 font-medium">Anzeigename</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Anzeigename</th>
<th class="px-4 py-2 font-medium">Rolle</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Rolle</th>
<th class="px-4 py-2 font-medium">Status</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Status</th>
<th class="px-4 py-2 font-medium">Erstellt am</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Erstellt am</th>
<th class="px-4 py-2"></th> <th class="px-4 py-2 text-xs uppercase tracking-wider"></th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@@ -28,10 +28,11 @@
<td class="px-4 py-2">{{ b.display_name }}</td> <td class="px-4 py-2">{{ b.display_name }}</td>
<td class="px-4 py-2">{{ rollen_anzeige[b.role] or b.role }}</td> <td class="px-4 py-2">{{ rollen_anzeige[b.role] or b.role }}</td>
<td class="px-4 py-2"> <td class="px-4 py-2">
{% from "status.html" import status_badge %}
{% if b.active %} {% if b.active %}
<span class="text-emerald-700">aktiv</span> {{ status_badge("aktiv", "aktiv") }}
{% else %} {% else %}
<span class="text-red-600">deaktiviert</span> {{ status_badge("deaktiviert", "deaktiviert") }}
{% endif %} {% endif %}
</td> </td>
<td class="px-4 py-2 text-slate-500">{{ b.created_at.strftime('%d.%m.%Y') if b.created_at else '&ndash;' }}</td> <td class="px-4 py-2 text-slate-500">{{ b.created_at.strftime('%d.%m.%Y') if b.created_at else '&ndash;' }}</td>

View File

@@ -1,17 +1,56 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}&Uuml;bersicht &mdash; {{ app_name }}{% endblock %} {% block titel %}&Uuml;bersicht &mdash; {{ app_name }}{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-1">Willkommen, {{ user.display_name }}!</h1> <h1 class="text-2xl font-bold text-slate-900 mb-1">Willkommen, {{ user.display_name }}!</h1>
<p class="text-slate-600 mb-6"> <p class="text-slate-600 mb-6">
Rolle: <span class="font-medium">{{ rollen_anzeige[user.role] or user.role }}</span> Rolle: <span class="font-medium">{{ rollen_anzeige[user.role] or user.role }}</span>
</p> </p>
<section class="mb-8"> {% if karten %}
<h2 class="text-lg font-semibold mb-1">Geladene Plugins</h2> <section aria-label="Übersicht" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 mb-8">
<p class="text-sm text-slate-500 mb-3"> {% for karte in karten %}
Der Kern ist schlank &mdash; alle Funktionen kommen aus Plugins. {% set ton_rand = {
</p> 'neutral': 'border-slate-200',
<div class="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100"> 'ok': 'border-emerald-200',
'warnung': 'border-amber-300',
'fehler': 'border-red-300',
}[karte.ton] | default('border-slate-200') %}
{% set ton_wert = {
'neutral': 'text-slate-900',
'ok': 'text-emerald-700',
'warnung': 'text-amber-700',
'fehler': 'text-red-700',
}[karte.ton] | default('text-slate-900') %}
<div class="bg-white rounded-xl border {{ ton_rand }} shadow-sm p-4 flex flex-col">
<h2 class="text-base font-semibold text-slate-800 mb-1">{{ karte.titel }}</h2>
{% if karte.wert %}
<p class="text-3xl font-bold {{ ton_wert }} leading-tight mb-1">{{ karte.wert }}</p>
{% endif %}
{% if karte.beschreibung %}
<p class="text-sm text-slate-500 mb-1">{{ karte.beschreibung }}</p>
{% endif %}
{% if karte.zeilen %}
<ul class="text-sm text-slate-600 space-y-0.5 mt-1">
{% for zeile in karte.zeilen %}
<li class="truncate" title="{{ zeile }}">{{ zeile }}</li>
{% endfor %}
</ul>
{% endif %}
{% if karte.link_url %}
<a href="{{ karte.link_url }}"
class="mt-auto pt-3 text-sm font-medium text-emerald-700 hover:text-emerald-900">{{ karte.link_label }} &rarr;</a>
{% endif %}
</div>
{% endfor %}
</section>
{% endif %}
{% if user.role == 'admin' %}
<details class="mb-8">
<summary class="text-sm text-slate-500 cursor-pointer hover:text-emerald-700">
Technik: geladene Plugins ({{ geladene_plugins|length }})
</summary>
<div class="mt-3 bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
{% for plugin in geladene_plugins %} {% for plugin in geladene_plugins %}
<div class="px-4 py-3 flex flex-wrap items-baseline gap-x-3 gap-y-1"> <div class="px-4 py-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span class="font-medium">{{ plugin.title or plugin.name }}</span> <span class="font-medium">{{ plugin.title or plugin.name }}</span>
@@ -20,5 +59,6 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</section> </details>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,101 @@
{# Zentrale Inline-SVG-Icons (Heroicons-Stil, Outline).
Alle Icons sind dekorativ: aria-hidden + focusable=false, Farbe über
currentColor. Aufruf: {% from "icons.html" import icon_refresh %}
{{ icon_refresh("h-4 w-4") }} #}
{% macro _svg(klasse, pfad) -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.8" stroke="currentColor" class="{{ klasse }}"
aria-hidden="true" focusable="false">
<path stroke-linecap="round" stroke-linejoin="round" d="{{ pfad }}"/>
</svg>
{%- endmacro %}
{% macro icon_wuerfelbecher(klasse="h-5 w-5") -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.8" stroke="currentColor" class="{{ klasse }}"
aria-hidden="true" focusable="false">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.5 3.5h11l-1.6 13.2a2.4 2.4 0 0 1-2.4 2.2h-3a2.4 2.4 0 0 1-2.4-2.2L6.5 3.5z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 7.5h12"/>
<circle cx="10.2" cy="12" r="0.4" fill="currentColor"/>
<circle cx="13.8" cy="12" r="0.4" fill="currentColor"/>
<circle cx="12" cy="14.8" r="0.4" fill="currentColor"/>
</svg>
{%- endmacro %}
{% macro icon_wuerfel(klasse="h-6 w-6") -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.8" stroke="currentColor" class="{{ klasse }}"
aria-hidden="true" focusable="false">
<rect x="3.5" y="3.5" width="17" height="17" rx="3.5"/>
<circle cx="8.4" cy="8.4" r="0.5" fill="currentColor"/>
<circle cx="15.6" cy="8.4" r="0.5" fill="currentColor"/>
<circle cx="12" cy="12" r="0.5" fill="currentColor"/>
<circle cx="8.4" cy="15.6" r="0.5" fill="currentColor"/>
<circle cx="15.6" cy="15.6" r="0.5" fill="currentColor"/>
</svg>
{%- endmacro %}
{% macro icon_sync(klasse="h-4 w-4") -%}
{{ _svg(klasse, "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99") }}
{%- endmacro %}
{% macro icon_pfeil_rechts(klasse="h-4 w-4") -%}
{{ _svg(klasse, "M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3") }}
{%- endmacro %}
{% macro icon_warnung(klasse="h-4 w-4") -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.8" stroke="currentColor" class="{{ klasse }}"
aria-hidden="true" focusable="false">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
{%- endmacro %}
{% macro icon_check(klasse="h-4 w-4") -%}
{{ _svg(klasse, "M4.5 12.75l6 6 9-13.5") }}
{%- endmacro %}
{% macro icon_gebaeude(klasse="h-5 w-5") -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.8" stroke="currentColor" class="{{ klasse }}"
aria-hidden="true" focusable="false">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z"/>
</svg>
{%- endmacro %}
{% macro icon_tag(klasse="h-5 w-5") -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.8" stroke="currentColor" class="{{ klasse }}"
aria-hidden="true" focusable="false">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z"/>
</svg>
{%- endmacro %}
{% macro icon_buch(klasse="h-5 w-5") -%}
{{ _svg(klasse, "M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25") }}
{%- endmacro %}
{% macro icon_dokument(klasse="h-5 w-5") -%}
{{ _svg(klasse, "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z") }}
{%- endmacro %}
{% macro icon_chevron_unten(klasse="h-3.5 w-3.5") -%}
{{ _svg(klasse, "M19.5 8.25l-7.5 7.5-7.5-7.5") }}
{%- endmacro %}
{% macro icon_chevron_oben(klasse="h-3.5 w-3.5") -%}
{{ _svg(klasse, "M4.5 15.75l7.5-7.5 7.5 7.5") }}
{%- endmacro %}
{% macro icon_chevron_links(klasse="h-4 w-4") -%}
{{ _svg(klasse, "M15.75 19.5L8.25 12l7.5-7.5") }}
{%- endmacro %}
{% macro icon_chevron_rechts(klasse="h-4 w-4") -%}
{{ _svg(klasse, "M8.25 4.5l7.5 7.5-7.5 7.5") }}
{%- endmacro %}

View File

@@ -5,32 +5,52 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anmelden &mdash; {{ app_name }}</title> <title>Anmelden &mdash; {{ app_name }}</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
<style>[x-cloak] { display: none !important; }</style>
</head> </head>
<body class="bg-slate-100 text-slate-800 min-h-screen flex items-center justify-center px-4"> <body class="bg-slate-100 text-slate-800 min-h-screen flex items-center justify-center px-4 py-8">
<div class="bg-white shadow-md rounded-xl p-8 w-full max-w-sm"> <div class="w-full max-w-sm flex flex-col items-center">
<h1 class="text-xl font-bold mb-1">&#127923; {{ app_name }}</h1> <div class="bg-white shadow-md rounded-xl p-8 w-full">
<h1 class="text-xl font-bold mb-1 flex items-center gap-2">{% from "icons.html" import icon_wuerfelbecher %}{{ icon_wuerfelbecher("h-6 w-6 text-emerald-700") }} {{ app_name }}</h1>
<p class="text-sm text-slate-500 mb-6">Bitte anmelden.</p> <p class="text-sm text-slate-500 mb-6">Bitte anmelden.</p>
{% if fehler %} {% if fehler %}
<div class="mb-4 rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2" role="alert"> <div id="login-fehler" class="mb-4 rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2" role="alert" tabindex="-1">
{{ fehler }} {{ fehler }}
</div> </div>
{% endif %} {% endif %}
<form method="post" action="/login" class="space-y-4"> <form method="post" action="/login" class="space-y-4" x-data="{ passwortSichtbar: false }">
<div> <div>
<label for="username" class="block text-sm font-medium mb-1">Benutzername</label> <label for="username" class="block text-sm font-medium mb-1">Benutzername</label>
<input id="username" name="username" value="{{ username }}" required autocomplete="username" <input id="username" name="username" value="{{ username }}" required autocomplete="username"
class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500"> {% if fehler %}aria-invalid="true" aria-describedby="login-fehler"{% endif %}
class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
</div> </div>
<div> <div>
<label for="password" class="block text-sm font-medium mb-1">Passwort</label> <label for="password" class="block text-sm font-medium mb-1">Passwort</label>
<input id="password" name="password" type="password" required autocomplete="current-password" <div class="relative">
class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500"> <input id="password" name="password" type="password" :type="passwortSichtbar ? 'text' : 'password'" required autocomplete="current-password"
{% if fehler %}aria-invalid="true" aria-describedby="login-fehler"{% endif %}
class="w-full border border-slate-300 rounded-lg px-3 py-2 pr-16 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<button type="button" @click="passwortSichtbar = !passwortSichtbar"
:aria-pressed="passwortSichtbar.toString()"
class="absolute inset-y-0 right-0 my-1 mr-1 px-3 rounded-lg text-xs font-medium text-slate-500 hover:bg-slate-100 hover:text-slate-700">
<span x-show="!passwortSichtbar">Anzeigen</span>
<span x-show="passwortSichtbar" x-cloak>Verbergen</span>
</button>
</div>
</div> </div>
<button type="submit" <button type="submit"
class="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-lg px-3 py-2"> class="w-full min-h-[44px] bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2">
Anmelden Anmelden
</button> </button>
</form> </form>
</div> </div>
<div class="mt-6 text-center">
<a href="https://boardgamegeek.com" target="_blank" rel="noopener noreferrer">
<img src="/static/powered-by-bgg.png" alt="Powered by BoardGameGeek"
width="120" height="35" class="inline-block opacity-80 hover:opacity-100">
</a>
</div>
</div>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,61 @@
{# Klassische Seitennavigation: Zurück/Weiter + Seitenzahlen.
Parameter: seiten_url (Funktion nr → URL), seite, gesamt_seiten.
Funktioniert ohne JavaScript (einfache Links). #}
{% from "icons.html" import icon_chevron_links, icon_chevron_rechts %}
{% macro seitennavigation(seiten_url, seite, gesamt_seiten) %}
{% if gesamt_seiten > 1 %}
<nav class="flex flex-wrap items-center justify-between gap-3 mt-4" aria-label="Seitennavigation">
<div>
{% if seite > 1 %}
<a href="{{ seiten_url(seite - 1) }}" rel="prev"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg border border-slate-300 text-sm font-medium text-slate-700 hover:bg-slate-50">
{{ icon_chevron_links("h-4 w-4") }} Zurück
</a>
{% else %}
<span aria-disabled="true"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg border border-slate-200 text-sm font-medium text-slate-300 cursor-not-allowed">
{{ icon_chevron_links("h-4 w-4") }} Zurück
</span>
{% endif %}
</div>
<div class="flex flex-wrap items-center gap-1">
<span class="hidden sm:inline text-sm text-slate-500 mr-2">Seite {{ seite }} von {{ gesamt_seiten }}</span>
<span class="sm:hidden sr-only">Seite {{ seite }} von {{ gesamt_seiten }}</span>
{% set von = [seite - 2, 1] | max %}
{% set bis = [seite + 2, gesamt_seiten] | min %}
{% if von > 1 %}
<a href="{{ seiten_url(1) }}"
class="px-3 py-2 rounded-lg border border-slate-300 text-sm text-slate-700 hover:bg-slate-50">1</a>
{% if von > 2 %}<span class="px-1 text-slate-400" aria-hidden="true">&hellip;</span>{% endif %}
{% endif %}
{% for nr in range(von, bis + 1) %}
{% if nr == seite %}
<span aria-current="page"
class="px-3 py-2 rounded-lg bg-emerald-600 text-white text-sm font-semibold">{{ nr }}</span>
{% else %}
<a href="{{ seiten_url(nr) }}"
class="px-3 py-2 rounded-lg border border-slate-300 text-sm text-slate-700 hover:bg-slate-50">{{ nr }}</a>
{% endif %}
{% endfor %}
{% if bis < gesamt_seiten %}
{% if bis < gesamt_seiten - 1 %}<span class="px-1 text-slate-400" aria-hidden="true">&hellip;</span>{% endif %}
<a href="{{ seiten_url(gesamt_seiten) }}"
class="px-3 py-2 rounded-lg border border-slate-300 text-sm text-slate-700 hover:bg-slate-50">{{ gesamt_seiten }}</a>
{% endif %}
</div>
<div>
{% if seite < gesamt_seiten %}
<a href="{{ seiten_url(seite + 1) }}" rel="next"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg border border-slate-300 text-sm font-medium text-slate-700 hover:bg-slate-50">
Weiter {{ icon_chevron_rechts("h-4 w-4") }}
</a>
{% else %}
<span aria-disabled="true"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg border border-slate-200 text-sm font-medium text-slate-300 cursor-not-allowed">
Weiter {{ icon_chevron_rechts("h-4 w-4") }}
</span>
{% endif %}
</div>
</nav>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,26 @@
{# Einheitliche Status-Badges (semantisches Farbsystem).
Aufruf: {% from "status.html" import status_badge %}
{{ status_badge(eintrag.status) }} oder mit abweichendem Text:
{{ status_badge(eintrag.status, status_anzeige[eintrag.status]) }} #}
{% from "icons.html" import icon_warnung %}
{% macro status_badge(status, text=none, extra="") -%}
{% set farben = {
'neuheit': 'bg-emerald-100 text-emerald-800',
'neu': 'bg-emerald-100 text-emerald-800',
'offen': 'bg-slate-200 text-slate-800',
'in_bearbeitung': 'bg-sky-100 text-sky-800',
'planung': 'bg-sky-100 text-sky-800',
'abgeschlossen': 'bg-emerald-100 text-emerald-800',
'aktiv': 'bg-emerald-100 text-emerald-800',
'warnung': 'bg-amber-100 text-amber-800',
'konflikt': 'bg-amber-100 text-amber-800',
'pruefung': 'bg-amber-100 text-amber-800',
'fehler': 'bg-red-100 text-red-800',
'deaktiviert': 'bg-slate-200 text-slate-600',
'archiviert': 'bg-slate-200 text-slate-600',
} -%}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs whitespace-nowrap {{ farben.get(status, 'bg-slate-100 text-slate-700') }} {{ extra }}">
{%- if status in ('warnung', 'konflikt', 'pruefung') %}{{ icon_warnung("h-3.5 w-3.5") }} {% endif -%}
{{ text if text is not none else status }}
</span>
{%- endmacro %}

View File

@@ -59,6 +59,18 @@ jahr_aus_datumsangabe = quellen_basis.jahr_aus_datumsangabe
titel_aehnlichkeit = quellen_basis.titel_aehnlichkeit titel_aehnlichkeit = quellen_basis.titel_aehnlichkeit
titel_normalisieren = quellen_basis.titel_normalisieren titel_normalisieren = quellen_basis.titel_normalisieren
ki_hilfe_modul = _neuheiten.quellen.ki_hilfe
KiHilfe = ki_hilfe_modul.KiHilfe
KiKonfiguration = ki_hilfe_modul.KiKonfiguration
lade_ki_konfiguration = ki_hilfe_modul.lade_konfiguration
extrahiere_ki_json = ki_hilfe_modul.extrahiere_json
struktur_verdaechtig = ki_hilfe_modul.struktur_verdaechtig
treffer_ist_unsicher = ki_hilfe_modul.treffer_ist_unsicher
titel_ist_verdaechtig = ki_hilfe_modul.titel_ist_verdaechtig
validiere_url_vorschlaege = ki_hilfe_modul.validiere_url_vorschlaege
validiere_feld_antwort = ki_hilfe_modul.validiere_feld_antwort
MAX_FOLGE_URLS = ki_hilfe_modul.MAX_FOLGE_URLS
SpielboxQuelle = _neuheiten.quellen.spielbox.SpielboxQuelle SpielboxQuelle = _neuheiten.quellen.spielbox.SpielboxQuelle
BrettspielboxQuelle = _neuheiten.quellen.brettspielbox.BrettspielboxQuelle BrettspielboxQuelle = _neuheiten.quellen.brettspielbox.BrettspielboxQuelle
SpielEssenQuelle = _neuheiten.quellen.spielessen.SpielEssenQuelle SpielEssenQuelle = _neuheiten.quellen.spielessen.SpielEssenQuelle

484
tests/test_covers.py Normal file
View File

@@ -0,0 +1,484 @@
"""Tests für die Coverbilder-Erweiterung (bild_url).
Abgedeckt:
- Migrationen: Spalte bild_url existiert in allen vier Tabellen
(neuheiten, planungsliste, archiv_neuheiten, archiv_planung);
bestehende Zeilen bleiben beim ALTER TABLE erhalten.
- BGG-Parsing: `<image>` wird bevorzugt, sonst `<thumbnail>`;
protokoll-relative URLs werden auf https normalisiert.
- Sync-Upsert: bild_url wird angelegt und beim Update mitgepflegt;
ein fehlendes Bild in der Antwort löscht kein gespeichertes.
- Web-Quellen: img im Listenelement wird übernommen und gespeichert.
- Templates: img-Tag mit loading="lazy"/decoding="async" bei Bild,
Platzhalter-Div ohne Bild; große Ansicht in Detailseiten.
Kein echter Netzwerkverkehr — XML/HTML sind gemockt.
"""
from __future__ import annotations
import sys
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from redaktionskern.db import Base
from redaktionskern.migrationen import run_migrations
from tests._neuheiten import (
BggSpiel,
Neuheit,
QuellenTreffer,
SpielboxQuelle,
WebQuellenClient,
WebQuellenSyncService,
parse_things,
)
from tests.conftest import melde_an
# ---------- Hilfsbausteine ----------
class FakeUhr:
def __init__(self) -> None:
self.zeit = 0.0
def __call__(self) -> float:
return self.zeit
@pytest.fixture
def session_factory():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
fabrik = sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
yield fabrik
engine.dispose()
def _spalten(conn, tabelle: str) -> set[str]:
info = conn.exec_driver_sql(f"PRAGMA table_info({tabelle})").fetchall()
return {zeile[1] for zeile in info}
def plugin_modul(app, name: str):
"""Das Modul des geladenen Plugins (Registry bevorzugt)."""
plugin_instanz = app.state.registry.get(name) or sys.modules.get(
f"spiele_redaktion_plugins.{name}"
)
assert plugin_instanz is not None, f"Plugin {name} nicht geladen"
return sys.modules[type(plugin_instanz).__module__]
# ---------- Migrationen ----------
def test_migrations_legen_bild_url_in_allen_tabellen_an(app):
with app.state.engine.connect() as conn:
for tabelle in ("neuheiten", "planungsliste", "archiv_neuheiten",
"archiv_planung"):
assert "bild_url" in _spalten(conn, tabelle), \
f"bild_url fehlt in {tabelle}"
def test_bild_url_migration_erhaelt_bestehende_daten(app, tmp_path):
"""ALTER-TABLE-Pfad: alte DB ohne bild_url → Migration fügt die Spalte
hinzu und erhält alle bestehenden Zeilen."""
engine = create_engine(f"sqlite:///{tmp_path / 'mig.db'}")
# Alte Migrationen (ohne die bild_url-Schritte) je Plugin ausführen.
alte_schritte = {
"neuheiten": [m for m in app.state.registry.get("neuheiten").migrations()
if not m.version.startswith("0004")],
"planung": [m for m in app.state.registry.get("planung").migrations()
if not m.version.startswith("0002")],
"archiv": [m for m in app.state.registry.get("archiv").migrations()
if not m.version.startswith("0002")],
}
with engine.begin() as conn:
for plugin_name, schritte in alte_schritte.items():
run_migrations(conn, plugin_name, schritte)
# Alten Stand simulieren: die aktuellen Modelle erzeugen die Spalte
# schon mit, daher für den Test wieder entfernen.
for tabelle in ("neuheiten", "planungsliste",
"archiv_neuheiten", "archiv_planung"):
conn.exec_driver_sql(f"ALTER TABLE {tabelle} DROP COLUMN bild_url")
assert "bild_url" not in _spalten(conn, tabelle)
# Bestehende Zeilen einfügen (NOT-NULL-Pflichtspalten gesetzt; die
# Defaults der Modelle sind Python-seitig, daher hier explizit).
with engine.begin() as conn:
conn.exec_driver_sql(
"INSERT INTO neuheiten (titel, status, quelle)"
" VALUES ('Alter BGG-Titel', 'neuheit', 'boardgamegeek')"
)
conn.exec_driver_sql(
"INSERT INTO planungsliste"
" (titel, rezensent_id, status, quelle, ausgabe)"
" VALUES ('Alter Plan', 1, 'offen', 'manuell', '')"
)
conn.exec_driver_sql(
"INSERT INTO archiv_neuheiten"
" (quell_id, titel, status, quelle, archiviert_von, grund)"
" VALUES (1, 'Alt Neuheit', 'neuheit', 'boardgamegeek', 'System', '')"
)
conn.exec_driver_sql(
"INSERT INTO archiv_planung"
" (quell_id, titel, rezensent_id, status, quelle, ausgabe,"
" archiviert_von, grund)"
" VALUES (2, 'Alt Planung', 1, 'offen', 'manuell', '', 'System', '')"
)
# Neue bild_url-Migrationen anwenden (nur diese sind noch offen).
neue_schritte = {
"neuheiten": [m for m in app.state.registry.get("neuheiten").migrations()
if m.version == "0004_bild_url"],
"planung": [m for m in app.state.registry.get("planung").migrations()
if m.version == "0002_bild_url"],
"archiv": [m for m in app.state.registry.get("archiv").migrations()
if m.version == "0002_bild_url"],
}
with engine.begin() as conn:
for plugin_name, schritte in neue_schritte.items():
run_migrations(conn, plugin_name, schritte)
# Spalte vorhanden, Daten vollständig erhalten.
with engine.connect() as conn:
for tabelle in ("neuheiten", "planungsliste",
"archiv_neuheiten", "archiv_planung"):
assert "bild_url" in _spalten(conn, tabelle)
titel = {
zeile[0]
for zeile in conn.exec_driver_sql(
"SELECT titel FROM neuheiten"
).fetchall()
}
assert titel == {"Alter BGG-Titel"}
assert conn.exec_driver_sql(
"SELECT COUNT(*) FROM planungsliste"
).scalar() == 1
assert conn.exec_driver_sql(
"SELECT COUNT(*) FROM archiv_neuheiten"
).scalar() == 1
assert conn.exec_driver_sql(
"SELECT COUNT(*) FROM archiv_planung"
).scalar() == 1
# ---------- BGG-Parsing ----------
THING_MIT_BEIDEN = """<?xml version="1.0"?>
<items termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
<item type="boardgame" id="13">
<thumbnail>https://cf.geekdo-images.com/klein_thumb.jpg</thumbnail>
<image>https://cf.geekdo-images.com/gross.png</image>
<name type="primary" value="Catan"/>
</item>
</items>
"""
THING_NUR_THUMBNAIL = """<?xml version="1.0"?>
<items termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
<item type="boardgame" id="13">
<thumbnail>https://cf.geekdo-images.com/nur_thumb.jpg</thumbnail>
<name type="primary" value="Catan"/>
</item>
</items>
"""
THING_PROTOKOLL_RELATIV = """<?xml version="1.0"?>
<items termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
<item type="boardgame" id="13">
<image>//cf.geekdo-images.com/relativ.jpg</image>
<name type="primary" value="Catan"/>
</item>
</items>
"""
THING_OHNE_BILD = """<?xml version="1.0"?>
<items termsofuse="https://boardgamegeek.com/xmlapi/termsofuse">
<item type="boardgame" id="13">
<name type="primary" value="Catan"/>
</item>
</items>
"""
def test_parse_things_bevorzugt_grosses_image():
spiele = parse_things(THING_MIT_BEIDEN)
assert len(spiele) == 1
assert spiele[0].bild_url == "https://cf.geekdo-images.com/gross.png"
def test_parse_things_faellt_auf_thumbnail_zurueck():
spiele = parse_things(THING_NUR_THUMBNAIL)
assert spiele[0].bild_url == "https://cf.geekdo-images.com/nur_thumb.jpg"
def test_parse_things_normalisiert_protokoll_relative_urls():
spiele = parse_things(THING_PROTOKOLL_RELATIV)
assert spiele[0].bild_url == "https://cf.geekdo-images.com/relativ.jpg"
def test_parse_things_ohne_bild_liefert_none():
spiele = parse_things(THING_OHNE_BILD)
assert spiele[0].bild_url is None
# ---------- Sync-Upsert pflegt bild_url ----------
def test_upsert_legt_und_pflegt_bild_url(session_factory):
from tests._neuheiten import speichere_oder_aktualisiere
spiel = BggSpiel(
bgg_id=13, titel="Catan", verlag="KOSMOS", autor="Klaus Teuber",
erscheinungsjahr=1995, bild_url="https://cf.geekdo-images.com/a.jpg",
)
with session_factory() as db:
assert speichere_oder_aktualisiere(db, spiel) is True
db.commit()
# Update: neues Bild wird übernommen …
update = BggSpiel(
bgg_id=13, titel="Catan", verlag="KOSMOS", autor="Klaus Teuber",
erscheinungsjahr=1995, bild_url="https://cf.geekdo-images.com/b.jpg",
)
assert speichere_oder_aktualisiere(db, update) is False
db.commit()
eintrag = db.scalar(select(Neuheit).where(Neuheit.bgg_id == 13))
assert eintrag.bild_url == "https://cf.geekdo-images.com/b.jpg"
# … ein fehlendes Bild in der Antwort löscht das gespeicherte nicht.
ohne = BggSpiel(
bgg_id=13, titel="Catan", verlag="KOSMOS", autor="Klaus Teuber",
erscheinungsjahr=1995, bild_url=None,
)
speichere_oder_aktualisiere(db, ohne)
db.commit()
db.expire_all()
eintrag = db.scalar(select(Neuheit).where(Neuheit.bgg_id == 13))
assert eintrag.bild_url == "https://cf.geekdo-images.com/b.jpg"
# ---------- Web-Quellen: Bild aus Listenelement übernehmen ----------
SPIELBOX_MIT_BILD = b"""
<div class="blog">
<div class="item column-1">
<div class="page-header"><h2><a href="/beitrag/x">Pegasus Spiele: Next Station Berlin</a></h2></div>
<img src="/media/next-station.jpg" alt="Next Station Berlin">
<p><strong>24.07.2026</strong> - Vorstellung.</p>
</div>
<div class="item column-2">
<div class="page-header"><h2><a href="/beitrag/y">MICROMACRO Kids</a></h2></div>
<p><strong>02.03.2026</strong> - Neu.</p>
</div>
</div>
"""
def test_spielbox_adapter_uebernimmt_img_url():
quelle = SpielboxQuelle(WebQuellenClient(schlaf=lambda s: None, uhr=FakeUhr()))
treffer, folge_links = quelle.seite_verarbeiten(
SPIELBOX_MIT_BILD, "https://www.spielbox.de/neuheiten-neue-spiele"
)
assert [t.titel for t in treffer] == [
"Pegasus Spiele: Next Station Berlin", "MICROMACRO Kids",
]
# Erstes Element liefert ein Bild, das zweite keins → leer lassen.
assert treffer[0].bild_url == "https://www.spielbox.de/media/next-station.jpg"
assert treffer[1].bild_url is None
assert folge_links == []
class _StatischerAdapter:
"""Adapter-Ersatz mit fester Trefferliste (für Service-Tests)."""
name = "spielbox"
anzeigename = "spielbox Test"
start_urls = ("https://spielbox.example.org/liste",)
def __init__(self, treffer):
self._treffer = treffer
def __call__(self, client):
return self
def sammle(self):
from tests._neuheiten import SammelErgebnis
return SammelErgebnis(treffer=list(self._treffer), seiten=1)
def test_quellen_sync_speichert_und_pflegt_bild_url(session_factory):
quelle = _StatischerAdapter([
QuellenTreffer(
titel="Ringträger", verlag="Schmidt Spiele",
quellen_url="https://x.example.org/r",
bild_url="https://bilder.example.org/ringtraeger.jpg",
),
])
service = WebQuellenSyncService(
session_factory,
adapter_klassen=(quelle,),
client_fabrik=lambda validatoren: WebQuellenClient(
schlaf=lambda s: None, uhr=FakeUhr(), validatoren=validatoren
),
)
zeilen = service.laufe()
assert zeilen[0].neu == 1
with session_factory() as db:
eintrag = db.query(Neuheit).one()
assert eintrag.quelle == "spielbox"
assert eintrag.bild_url == "https://bilder.example.org/ringtraeger.jpg"
# Zweiter Lauf aktualisiert das Bild statt es zu duplizieren.
quelle._treffer = [
QuellenTreffer(
titel="Ringträger", verlag="Schmidt Spiele",
quellen_url="https://x.example.org/r",
bild_url="https://bilder.example.org/ringtraeger-neu.jpg",
),
]
zeilen = service.laufe()
assert zeilen[0].aktualisiert == 1 and zeilen[0].neu == 0
with session_factory() as db:
assert db.query(Neuheit).count() == 1
assert db.query(Neuheit).one().bild_url == \
"https://bilder.example.org/ringtraeger-neu.jpg"
def test_spielessen_extrahiert_bild_aus_bilder_feld(app):
klasse = plugin_modul(app, "neuheiten").quellen.spielessen.SpielEssenQuelle
adapter = klasse(client=None) # Parsing-Hilfen brauchen keinen Client
produkt = {
"TITEL": "-60°",
"BILDER": [{"URL": "//maps.eyeled-services.de/media/bild.jpg"}],
}
treffer = adapter._produkt(produkt, {})
assert treffer is not None
assert treffer.bild_url == "https://maps.eyeled-services.de/media/bild.jpg"
treffer_ohne = adapter._produkt({"TITEL": "X", "BILDER": []}, {})
assert treffer_ohne is not None
assert treffer_ohne.bild_url is None
# ---------- Planung: Übernahme trägt das Bild mit ----------
def test_uebernehmen_kopiert_bild_url_in_die_planung(app, client):
with app.state.session_factory() as db:
db.add(
Neuheit(
titel="Cascadia",
bgg_id=295947,
status="neuheit",
bild_url="https://cf.geekdo-images.com/cascadia.jpg",
)
)
db.commit()
melde_an(client)
antwort = client.post("/planung/uebernehmen/1", follow_redirects=False)
assert antwort.status_code in (303, 200)
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
eintrag = db.scalars(select(Planungseintrag)).one()
assert eintrag.titel == "Cascadia"
assert eintrag.bild_url == "https://cf.geekdo-images.com/cascadia.jpg"
# Die Neuheit selbst ist jetzt „planung“ und behält ihr Bild.
neuheit = db.scalar(select(Neuheit).where(Neuheit.titel == "Cascadia"))
assert neuheit.status == "planung"
assert neuheit.bild_url == "https://cf.geekdo-images.com/cascadia.jpg"
# ---------- Template-Darstellung ----------
def test_neuheitenliste_rendert_img_mit_lazy_loading(app, client):
with app.state.session_factory() as db:
db.add(
Neuheit(
titel="Catan", bgg_id=13, status="neuheit",
bild_url="https://cf.geekdo-images.com/catan.jpg",
)
)
db.commit()
melde_an(client)
antwort = client.get("/neuheiten")
assert antwort.status_code == 200
text = antwort.text
assert '<img src="https://cf.geekdo-images.com/catan.jpg"' in text
assert 'loading="lazy"' in text
assert 'decoding="async"' in text
assert 'alt="Cover: Catan"' in text
# Fallback-Zeichen darf daneben nicht auftauchen, wenn Bilder da sind …
# (Doch: andere Einträge könnten bildlos sein — hier nur einer.)
# Mobil-Kartenansicht und Desktop-Tabelle sind beide im Markup.
assert 'md:hidden' in text
assert 'hidden md:block' in text
def test_neuheitenliste_rendert_platzhalter_ohne_bild(app, client):
with app.state.session_factory() as db:
db.add(Neuheit(titel="Ohne Bild", status="neuheit"))
db.commit()
melde_an(client)
antwort = client.get("/neuheiten")
assert antwort.status_code == 200
liste = antwort.text.split('<div id="neuheiten-liste"')[1].split("</main>")[0]
assert "<img" not in liste
# SVG-Platzhalter statt Emoji: Würfel-Icon + Hinweistext
assert 'aria-label="Kein Cover vorhanden"' in liste
assert "<svg" in liste
def test_planungsliste_zeigt_cover(app, client):
from redaktionskern.auth.models import User
Planungseintrag = plugin_modul(app, "planung").Planungseintrag
with app.state.session_factory() as db:
benutzer = db.query(User).filter_by(username="admin").first()
db.add(
Planungseintrag(
titel="Cascadia",
rezensent_id=benutzer.id,
status="offen",
bild_url="https://cf.geekdo-images.com/cascadia.jpg",
)
)
db.commit()
melde_an(client)
antwort = client.get("/planung")
assert antwort.status_code == 200
assert 'src="https://cf.geekdo-images.com/cascadia.jpg"' in antwort.text
assert 'loading="lazy"' in antwort.text
assert 'decoding="async"' in antwort.text
def test_archiv_zeigt_cover(app, client):
ArchivNeuheit = plugin_modul(app, "archiv").ArchivNeuheit
with app.state.session_factory() as db:
db.add(
ArchivNeuheit(
quell_id=1, titel="Altspiel",
bild_url="https://cf.geekdo-images.com/altspiel.jpg",
)
)
db.commit()
melde_an(client)
antwort = client.get("/archiv")
assert antwort.status_code == 200
assert 'src="https://cf.geekdo-images.com/altspiel.jpg"' in antwort.text
assert 'loading="lazy"' in antwort.text

View File

@@ -389,3 +389,25 @@ def test_bgg_pruefclient_toleroert_fehler():
assert client.vorgaenger(99) == [] assert client.vorgaenger(99) == []
finally: finally:
client.schliessen() client.schliessen()
def test_bgg_pruefclient_sendet_token_header():
from plugins.dedup.bgg import BggPruefClient
gesehen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
gesehen.append(request)
return httpx.Response(200, content=THING_XML.encode())
client = BggPruefClient(
mindestabstand_sekunden=0.0,
token="mein-token",
transport=httpx.MockTransport(handler),
)
try:
assert client.alternativen(99) == ["Catan Expansion", "Siedler-Erweiterung"]
finally:
client.schliessen()
assert gesehen
assert gesehen[0].headers["Authorization"] == "Bearer mein-token"

View File

@@ -0,0 +1,544 @@
"""Tests: Optionale LLM-Hilfsstufe des Web-Quellen-Crawlers (neuheiten).
Alle HTTP- und LLM-Antworten sind gemockt (httpx.MockTransport bzw.
Fake-Transports) — es gibt niemals echte Netzwerk- oder LLM-Aufrufe.
Abgedeckt: Fallback-Fälle (aktiv=0, fehlender Key, Timeout mit genau einem
Retry, kaputtes JSON), Konfidenz-Schwelle, Struktur-Erkennung nur mit
gleiche-Domain-URLs (max. 20), Feld-Korrektur ausschließlich unsicherer
Treffer (URL bleibt fix) und die Audit-Protokollierung der KI-Eingriffe.
"""
from __future__ import annotations
import json
import httpx
import pytest
from tests._neuheiten import (
MAX_FOLGE_URLS,
KiHilfe,
KiKonfiguration,
Neuheit,
QuellenFehler,
QuellenTreffer,
SpielboxQuelle,
SammelErgebnis,
WebQuellenClient,
WebQuellenSyncService,
extrahiere_ki_json,
lade_ki_konfiguration,
struktur_verdaechtig,
titel_ist_verdaechtig,
treffer_ist_unsicher,
)
BASIS_URL = "https://www.spielbox.de/neuheiten-neue-spiele"
KONFIG = dict(
aktiv=True,
basis_url="https://llm.example.test/v1",
api_key="test-key",
modell="test-modell",
)
class FakeUhr:
def __init__(self) -> None:
self.zeit = 0.0
def __call__(self) -> float:
return self.zeit
# ---------------- Gemockte Seiten ----------------
SEITE_LEER_VIELE_LINKS = (
"<html><body><nav>"
+ "".join(
f'<a href="/rubrik/{i}">Rubrik {i}</a> ' for i in range(40)
)
+ "</nav><p>Keine Neuheiten erkannt.</p></body></html>"
)
SEITE_MIT_TREFFERN = """
<div class="item">
<div class="page-header"><h2><a href="/x/1">MICROMACRO Kids</a></h2></div>
<p><strong>02.03.2026</strong> - kurz.</p>
</div>
"""
def _antwort(inhalt: bytes | str) -> httpx.Response:
return httpx.Response(
200,
content=inhalt.encode("utf-8") if isinstance(inhalt, str) else inhalt,
request=httpx.Request("GET", BASIS_URL),
)
def _crawler_client(seiten: dict[str, bytes | str]) -> tuple[WebQuellenClient, KartenTransport, FakeUhr]:
uhr = FakeUhr()
transport = KartenTransport({u: _antwort(i) for u, i in seiten.items()}, uhr)
client = WebQuellenClient(
transport=transport, schlaf=lambda s: None, uhr=uhr
)
return client, transport, uhr
class KartenTransport(httpx.BaseTransport):
"""Antworten je URL; zeichnet angefragte URLs auf."""
def __init__(self, antworten: dict[str, httpx.Response], uhr: FakeUhr):
self.antworten = antworten
self.anfragen: list[str] = []
def handle_request(self, request: httpx.Request) -> httpx.Response:
url = str(request.url)
self.anfragen.append(url)
antwort = self.antworten.get(url)
if antwort is None and "?" in url:
antwort = self.antworten.get(url.split("?")[0])
if antwort is None:
raise QuellenFehler(f"Keine gemockte Antwort für {url}")
return antwort
# ---------------- LLM-Mocks ----------------
def llm_antwort(objekt: dict, status: int = 200) -> httpx.Response:
body = {"choices": [{"message": {"role": "assistant", "content": json.dumps(objekt)}}]}
return httpx.Response(status, content=json.dumps(body).encode())
class LlmTransport(httpx.BaseTransport):
"""Gestaffelte Antworten für Chat-Completions; zählt Aufrufe.
Elemente: dict → JSON-Inhalt, int → HTTP-Status, Exception → wird geworfen.
"""
def __init__(self, antworten: list):
self.antworten = list(antworten)
self.aufrufe = 0
self.prompts: list[dict] = []
def handle_request(self, request: httpx.Request) -> httpx.Response:
index = min(self.aufrufe, len(self.antworten) - 1)
spezifikation = self.antworten[index]
self.aufrufe += 1
self.prompts.append(json.loads(request.content.decode()))
if isinstance(spezifikation, Exception):
raise spezifikation
if isinstance(spezifikation, int):
return httpx.Response(spezifikation, json={"error": "kaputt"})
return llm_antwort(spezifikation)
def ki_hilfe(transport: httpx.BaseTransport, konfidenz_min: float = 0.7) -> KiHilfe:
return KiHilfe(
KiKonfiguration(konfidenz_min=konfidenz_min, **KONFIG),
transport=transport,
)
TREFFER_UNSICHER = lambda: QuellenTreffer( # noqa: E731
titel="Pegasus Spiele: Next Station Berlin",
quellen_url="https://www.spielbox.de/x/1",
)
TREFFER_SICHER = lambda: QuellenTreffer( # noqa: E731
titel="Galactic Cruise", verlag="Dranda Games",
quellen_url="https://www.spielbox.de/x/2",
)
GUTE_FELDANTWORT = {
"titel": "Next Station Berlin",
"verlag": "Pegasus Spiele",
"autor": None,
"konfidenz": 0.9,
"begruendung": "Verlag stand als Präfix im Titel.",
}
# ---------------- Heuristiken ----------------
def test_struktur_verdaechtig_ohne_treffer_immmer():
assert struktur_verdaechtig(0, "<p>kurz</p>") is True
def test_struktur_verdaechtig_wenige_treffer_nur_bei_linkreicher_seite():
viele_links = "".join('<a href="/x">x</a>' for _ in range(40))
assert struktur_verdaechtig(2, viele_links) is True
assert struktur_verdaechtig(2, "<a href='/x'>x</a>") is False
assert struktur_verdaechtig(5, viele_links) is False
def test_unsichere_treffer_fehlender_verlag_oder_generischer_titel():
assert treffer_ist_unsicher(QuellenTreffer(titel="X", quellen_url="u")) is True
assert treffer_ist_unsicher(TREFFER_UNSICHER()) is True # kein Verlag
assert treffer_ist_unsicher(TREFFER_SICHER()) is False
assert titel_ist_verdaechtig("Mehr") is True
assert titel_ist_verdaechtig("ab") is True
assert titel_ist_verdaechtig("Fadenfroh") is False
def test_extrahiere_json_toleriert_code_fences():
text = "Antwort:\n```json\n{\"folge_urls\": []}\n```\nEnde."
assert extrahiere_ki_json(text) == {"folge_urls": []}
assert extrahiere_ki_json("völlig kaputt") is None
# ---------------- Konfiguration & Fallback ----------------
def test_konfiguration_standard_aus():
k = lade_ki_konfiguration({})
assert not k.aktiv and not k.vollstaendig
assert k.konfidenz_min == 0.7
assert k.timeout >= 1.0
def test_service_ohne_konfiguration_baut_keinen_ki_client(session_factory, monkeypatch):
"""Fallback aktiv=0: kein KI-Client, exakt der klassische Crawler."""
monkeypatch.setenv("SPIELE_NEUHEITEN_KI_AKTIV", "0")
class Streng:
def __call__(self): # pragma: no cover — darf nie aufgerufen werden
raise AssertionError("KI-Client ohne Aktivierung gebaut")
def schliessen(self): pass
quelle = StatischeQuelle("spielbox", [TREFFER_SICHER()])
service = WebQuellenSyncService(
session_factory, adapter_klassen=(quelle,),
client_fabrik=lambda v: _ohne_netz_client(),
ki_fabrik=Streng(),
)
zeilen = service.laufe()
assert zeilen[0].neu == 1 and zeilen[0].fehlermeldung is None
with session_factory() as db:
assert db.query(Neuheit).count() == 1
def test_service_mit_aktiv_ohne_key_laeuft_klassisch(session_factory, monkeypatch):
"""Fallback: AKTIV=1, aber Key fehlt → vollständige Konfiguration fehlt."""
monkeypatch.setenv("SPIELE_NEUHEITEN_KI_AKTIV", "1")
monkeypatch.setenv("SPIELE_NEUHEITEN_KI_BASIS_URL", "https://llm.example.test/v1")
monkeypatch.delenv("SPIELE_NEUHEITEN_KI_API_KEY", raising=False)
monkeypatch.setenv("SPIELE_NEUHEITEN_KI_MODELL", "test-modell")
class Streng:
def __call__(self): # pragma: no cover
raise AssertionError("KI-Client ohne Key gebaut")
def schliessen(self): pass
quelle = StatischeQuelle("spielbox", [TREFFER_SICHER()])
service = WebQuellenSyncService(
session_factory, adapter_klassen=(quelle,),
client_fabrik=lambda v: _ohne_netz_client(),
ki_fabrik=Streng(),
)
zeilen = service.laufe()
assert zeilen[0].neu == 1
def test_timeout_genau_ein_retry_dann_klassischer_befund():
"""Timeout: insgesamt zwei Versuche, danach klassischer Treffer unverändert."""
transport = LlmTransport([httpx.ReadTimeout("zu lang"), httpx.ReadTimeout("nochmal")])
hilfe = ki_hilfe(transport)
treffer_liste, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
assert transport.aufrufe == 2 # erster Versuch + genau ein Retry
assert eingriffe == []
original = treffer_liste[0]
assert original.verlag is None # klassischer Befund bleibt
assert original.quellen_url == "https://www.spielbox.de/x/1"
def test_kaputtes_json_kein_retry_kein_eingriff():
transport = LlmTransport(["das ist {kaputtes JSON ohne Sinn"])
hilfe = ki_hilfe(transport)
treffer_liste, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
assert transport.aufrufe == 1 # Parsing-Fehler werden nicht wiederholt
assert eingriffe == []
assert treffer_liste[0].verlag is None
def test_serverfehler_nach_retry_faellt_zurueck():
transport = LlmTransport([503, 503])
hilfe = ki_hilfe(transport)
_, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
folge_urls = hilfe.folge_urls_bei_bedarf(0, SEITE_LEER_VIELE_LINKS, BASIS_URL, set())
assert transport.aufrufe == 4 # je Aufrufart zwei Versuche
assert eingriffe == [] and folge_urls == []
# ---------------- Konfidenz-Schwelle ----------------
def test_feldkorrektur_unter_konfidenz_min_wird_verworfen():
schwach = dict(GUTE_FELDANTWORT, konfidenz=0.5)
transport = LlmTransport([schwach])
hilfe = ki_hilfe(transport, konfidenz_min=0.7)
treffer_liste, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
assert transport.aufrufe == 1
assert eingriffe == []
assert treffer_liste[0].titel == "Pegasus Spiele: Next Station Berlin"
assert treffer_liste[0].verlag is None
def test_url_vorschlaege_unter_konfidenz_min_werden_verworfen():
vorschlaege = {
"folge_urls": ["/neuheiten-neue-spiele?start=6"],
"konfidenz": 0.3,
}
transport = LlmTransport([vorschlaege])
hilfe = ki_hilfe(transport)
urls = hilfe.folge_urls_bei_bedarf(0, SEITE_LEER_VIELE_LINKS, BASIS_URL, set())
assert urls == []
# ---------------- Struktur-Erkennung ----------------
def test_struktur_erkennung_nur_gleiche_domain_urls_werden_verfolgt():
"""Fremde Domains und Nicht-HTTP-Schemata werden hart herausgefiltert."""
gemischt = {
"folge_urls": [
"/neuheiten-neue-spiele?start=6", # gleiche Domain ✓
"https://www.spielbox.de/neuheiten?page=2", # gleiche Domain ✓
"https://boese.example.org/neuheiten", # fremde Domain ✗
"ftp://www.spielbox.de/liste", # Schema ✗
"/neuheiten-neue-spiele?start=6#oben", # Duplikat ✗
"javascript:zeigeListe()", # Schema ✗
],
"konfidenz": 0.9,
}
crawler_client, transport, _ = _crawler_client({BASIS_URL: SEITE_LEER_VIELE_LINKS})
transport.antworten["https://www.spielbox.de/neuheiten-neue-spiele?start=6"] = _antwort(SEITE_MIT_TREFFERN)
transport.antworten["https://www.spielbox.de/neuheiten?page=2"] = _antwort(SEITE_MIT_TREFFERN)
adapter = SpielboxQuelle(crawler_client)
adapter.ki_hilfe = ki_hilfe(LlmTransport([gemischt]))
ergebnis = adapter.sammle()
gefolgt = [u for u in transport.anfragen if u != BASIS_URL]
assert "https://www.spielbox.de/neuheiten-neue-spiele?start=6" in gefolgt
assert all(u.startswith("https://www.spielbox.de") for u in gefolgt)
assert not any("boese" in u or u.startswith("ftp") for u in gefolgt)
assert len(ergebnis.treffer) == 2 # aus den beiden nachgeschobenen Seiten
assert ergebnis.ki_folge_urls == gefolgt
def test_struktur_erkennung_maximal_20_folge_urls():
"""Rate-Limit-Schutz: auch bei 30 Vorschlägen werden höchstens 20 verfolgt."""
vorschlaege = {
"folge_urls": [f"/liste?seite={i}" for i in range(30)],
"konfidenz": 0.9,
}
crawler_client, transport, _ = _crawler_client({BASIS_URL: SEITE_LEER_VIELE_LINKS})
for i in range(30):
transport.antworten[f"https://www.spielbox.de/liste?seite={i}"] = _antwort(SEITE_MIT_TREFFERN)
adapter = SpielboxQuelle(crawler_client)
adapter.ki_hilfe = ki_hilfe(LlmTransport([vorschlaege]))
adapter.sammle()
gefolgt = [u for u in transport.anfragen if u != BASIS_URL]
assert len(gefolgt) <= MAX_FOLGE_URLS
assert len(gefolgt) == 20
assert "https://www.spielbox.de/liste?seite=25" not in gefolgt
def test_struktur_erkennung_greift_nicht_bei_normalen_ergebnissen():
"""Drei sichere Treffer → keine Verdachts-Heuristik, kein LLM-Aufruf."""
seite = SEITE_MIT_TREFFERN * 3
crawler_client, transport, _ = _crawler_client({BASIS_URL: seite})
adapter = SpielboxQuelle(crawler_client)
adapter.ki_hilfe = ki_hilfe(LlmTransport([])) # würde bei Aufruf zählen
ergebnis = adapter.sammle()
assert len(ergebnis.treffer) == 3
assert len(transport.anfragen) == 1 # nur die Startseite
# ---------------- Feld-Korrektur ----------------
def test_feldkorrektur_nur_bei_unsicheren_treffern_url_bleibt_fix():
"""Sichere Treffer kosten kein Token; korrigiert wird nie die URL."""
transport = LlmTransport([GUTE_FELDANTWORT])
hilfe = ki_hilfe(transport)
sicher = TREFFER_SICHER()
unsicher = TREFFER_UNSICHER()
treffer_liste, eingriffe = hilfe.treffer_nachbessern([sicher, unsicher])
assert transport.aufrufe == 1 # nur der unsichere Treffer ging ans LLM
assert treffer_liste[0] is sicher # unangetastet
korrigiert = treffer_liste[1]
assert korrigiert.titel == "Next Station Berlin"
assert korrigiert.verlag == "Pegasus Spiele"
assert korrigiert.quellen_url == unsicher.quellen_url # URL bleibt fix
assert len(eingriffe) == 1
protokoll = eingriffe[0]
assert protokoll["modell"] == "test-modell"
assert protokoll["konfidenz"] == 0.9
assert protokoll["korrigiert"] == {
"titel": "Next Station Berlin", "verlag": "Pegasus Spiele",
}
assert protokoll["vorher"]["titel"] == "Pegasus Spiele: Next Station Berlin"
def test_feldkorrektur_ohne_aenderung_erzeugt_keinen_eingriff():
identisch = {
"titel": "Pegasus Spiele: Next Station Berlin",
"verlag": None, "autor": None, "konfidenz": 0.9, "begruendung": "passt",
}
transport = LlmTransport([identisch])
hilfe = ki_hilfe(transport)
treffer_liste, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
assert eingriffe == []
assert treffer_liste[0].quellen_url == "https://www.spielbox.de/x/1"
# ---------------- Sync-Integration + Audit ----------------
class StatischeQuelle:
"""Adapter-Ersatz mit fester Trefferliste (Service-Tests ohne Netz)."""
def __init__(self, name: str = "spielbox", treffer: list[QuellenTreffer] | None = None):
self.name = name
self.anzeigename = name.title()
self.start_urls = (f"https://{name}.example.org/liste",)
self._treffer = treffer if treffer is not None else []
def __call__(self, client):
return self
def sammle(self) -> SammelErgebnis:
return SammelErgebnis(treffer=list(self._treffer), seiten=1)
def _ohne_netz_client() -> WebQuellenClient:
return WebQuellenClient(transport=None, schlaf=lambda s: None, uhr=FakeUhr())
class FakeAudit:
def __init__(self):
self.eintraege: list[tuple] = []
def log_sync(self, actor, action, objekt_typ, objekt_id, details, **kwargs):
self.eintraege.append((actor, action, objekt_typ, objekt_id, details))
def test_sync_auditert_ki_eingriffe_mit_modell_und_konfidenz():
"""End-to-End: Crawler-Seite mit unsicherem Treffer → KI-Korrektur → Audit.
„Ringträger“ ohne Doppelpunkt: der Parser findet keinen Verlag → Treffer
ist unsicher → das LLM darf titel/verlag korrigieren.
"""
seite = (
'<div class="item"><div class="page-header"><h2>'
'<a href="/x/1">Ringträger</a></h2></div>'
"<p><strong>15.08.2026</strong> - Text.</p></div>"
)
crawler_client, _, _ = _crawler_client({BASIS_URL: seite})
audit = FakeAudit()
fabrik = _session_factory()
service = WebQuellenSyncService(
fabrik,
adapter_klassen=(SpielboxQuelle,),
client_fabrik=lambda validatoren: crawler_client,
ki_fabrik=lambda: ki_hilfe(LlmTransport([GUTE_FELDANTWORT])),
umgebung={
"SPIELE_NEUHEITEN_KI_AKTIV": "1",
"SPIELE_NEUHEITEN_KI_BASIS_URL": "https://llm.example.test/v1",
"SPIELE_NEUHEITEN_KI_API_KEY": "k",
"SPIELE_NEUHEITEN_KI_MODELL": "m",
},
audit=audit,
)
zeilen = service.laufe()
assert zeilen[0].neu == 1
with fabrik() as db:
eintrag = db.query(Neuheit).one()
assert eintrag.titel == "Next Station Berlin"
assert eintrag.verlag == "Pegasus Spiele"
assert eintrag.erscheinungsjahr == 2026
assert len(audit.eintraege) == 1
actor, action, objekt_typ, objekt_id, details = audit.eintraege[0]
assert action == "korrigiert" and objekt_typ == "neuheiten_ki_crawler"
assert objekt_id == "spielbox"
assert details["modell"] == "test-modell"
assert details["eingriffe"][0]["konfidenz"] == 0.9
assert details["eingriffe"][0]["korrigiert"]["verlag"] == "Pegasus Spiele"
def _session_factory():
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from redaktionskern.db import Base
engine = create_engine("sqlite://", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
fabrik = sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
fabrik.engine_zum_aufraeumen = engine # type: ignore[attr-defined]
return fabrik
def test_sync_ohne_ki_eingriff_schreibt_kein_audit():
audit = FakeAudit()
service = WebQuellenSyncService(
_session_factory(),
adapter_klassen=(StatischeQuelle("spielbox", [TREFFER_SICHER()]),),
client_fabrik=lambda v: _ohne_netz_client(),
ki_fabrik=None,
audit=audit,
)
zeilen = service.laufe()
assert zeilen[0].neu == 1
assert audit.eintraege == []
def test_ki_ausfall_blockiert_den_sync_nie():
"""KI-Fabrik crasht → Sync läuft trotzdem komplett klassisch."""
def kaputte_fabrik():
raise RuntimeError("LLM nicht erreichbar")
service = WebQuellenSyncService(
_session_factory(),
adapter_klassen=(StatischeQuelle("spielbox", [TREFFER_SICHER()]),),
client_fabrik=lambda v: _ohne_netz_client(),
ki_fabrik=kaputte_fabrik,
umgebung={
"SPIELE_NEUHEITEN_KI_AKTIV": "1",
"SPIELE_NEUHEITEN_KI_BASIS_URL": "u",
"SPIELE_NEUHEITEN_KI_API_KEY": "k",
"SPIELE_NEUHEITEN_KI_MODELL": "m",
},
)
zeilen = service.laufe()
assert zeilen[0].neu == 1 and zeilen[0].fehlermeldung is None
@pytest.fixture
def session_factory():
fabrik = _session_factory()
yield fabrik
fabrik.engine_zum_aufraeumen.dispose() # type: ignore[attr-defined]

View File

@@ -124,10 +124,25 @@ CLIQUENABEND_SEITE = """
<tr><td>26.09.25</td><td>Piatnik Profil ,</td><td>6-G200</td> <tr><td>26.09.25</td><td>Piatnik Profil ,</td><td>6-G200</td>
<td><div><a href="/spiele/790700-Elixirus.html"><b>Elixirus</b></a></div></td> <td><div><a href="/spiele/790700-Elixirus.html"><b>Elixirus</b></a></div></td>
<td>Dickie Chapin</td></tr> <td>Dickie Chapin</td></tr>
<tr><td>01.10.25</td><td>ohne Link</td><td></td><td>Nur Texttitel</td><td>Unbekannt</td></tr> <tr><td>01.10.25</td><td>ohne Link</td><td>&mdash;</td><td>Nur Texttitel</td><td>Unbekannt</td></tr>
</table> </table>
""" """
CLIQUENABEND_DETAIL_1 = """
<html><body>
<h1>Galactic Cruise</h1>
<div class="right_bar"><center>
<a href="/spiele/790610-Galactic-Cruise.html"><img src="/images/db/62452_360x240.jpg" border="0"></a><br>
</center></div>
<b>Release:</b><br>
2025 <br>
<b>Anzahl der Spieler:</b><br>
1 bis 4 Spieler<br>
</body></html>
"""
CLIQUENABEND_DETAIL_FEHLT = "<html><body><p>Seite ohne Release-Feld</p></body></html>"
class FakeUhr: class FakeUhr:
"""Steuerbare Monoton-Uhr.""" """Steuerbare Monoton-Uhr."""
@@ -306,7 +321,11 @@ def test_parser_spielessen_nutzt_json_api_endpunkt():
def test_parser_cliquenabend_liest_tabellenspalten(): def test_parser_cliquenabend_liest_tabellenspalten():
adapter, _ = _adapter( adapter, _ = _adapter(
CliquenabendQuelle, CliquenabendQuelle,
{"https://www.cliquenabend.de/events/130000-SPIEL-2025-Essen.html": CLIQUENABEND_SEITE}, {
"https://www.cliquenabend.de/events/130000-SPIEL-2025-Essen.html": CLIQUENABEND_SEITE,
"https://www.cliquenabend.de/spiele/790610-Galactic-Cruise.html": CLIQUENABEND_DETAIL_1,
"https://www.cliquenabend.de/spiele/790700-Elixirus.html": CLIQUENABEND_DETAIL_FEHLT,
},
) )
ergebnis = adapter.sammle() ergebnis = adapter.sammle()
@@ -314,9 +333,28 @@ def test_parser_cliquenabend_liest_tabellenspalten():
erster = ergebnis.treffer[0] erster = ergebnis.treffer[0]
assert erster.verlag == "Dranda Games, PD Verlag" assert erster.verlag == "Dranda Games, PD Verlag"
assert erster.autor == "T.K. King und Koltin Thompson" assert erster.autor == "T.K. King und Koltin Thompson"
assert erster.erscheinungsdatum_oder_quartal == "15.10.25" # Release-Feld der Detailseite schlägt das Update-Datum der Tabelle:
assert erster.erscheinungsdatum_oder_quartal == "2025"
assert erster.quellen_url == "https://www.cliquenabend.de/spiele/790610-Galactic-Cruise.html" assert erster.quellen_url == "https://www.cliquenabend.de/spiele/790610-Galactic-Cruise.html"
# Cover-Bild aus der Detailseite (statt Messe-Foto):
assert erster.bild_url == "https://www.cliquenabend.de/images/db/62452_360x240.jpg"
assert ergebnis.treffer[1].verlag == "Piatnik" assert ergebnis.treffer[1].verlag == "Piatnik"
# Ohne Release-Feld auf der Detailseite fällt der Parser auf das
# Update-Datum zurück:
assert ergebnis.treffer[1].erscheinungsdatum_oder_quartal == "26.09.25"
def test_parser_cliquenabend_detailseite_fehler_blockiert_nicht():
adapter, _ = _adapter(
CliquenabendQuelle,
{
"https://www.cliquenabend.de/events/130000-SPIEL-2025-Essen.html": CLIQUENABEND_SEITE,
# Detailseiten nicht erreichbar → Update-Datum bleibt erhalten.
},
)
ergebnis = adapter.sammle()
assert len(ergebnis.treffer) == 2
assert ergebnis.treffer[0].erscheinungsdatum_oder_quartal == "15.10.25"
def test_parser_cliquenabend_toleriert_fehlende_tabelle(): def test_parser_cliquenabend_toleriert_fehlende_tabelle():
@@ -615,6 +653,46 @@ def test_env_schalter_deaktiviert_einzelne_quelle(session_factory, monkeypatch):
assert quelle_aktiv("brettspielbox") is True # Standard „1“ assert quelle_aktiv("brettspielbox") is True # Standard „1“
def test_ui_schalter_uebersteuert_env(session_factory, monkeypatch):
"""Der DB-Schalter schaltet eine per Env aktive Quelle ab (und umgekehrt)."""
monkeypatch.delenv("SPIELE_NEUHEITEN_QUELLE_SPIELBOX_AKTIV", raising=False)
spielbox = StatischerAdapter("spielbox", [QuellenTreffer(titel="X", quellen_url="u")])
service = _service(session_factory, (spielbox,))
# Standard: an
assert service.quelle_laeuft("spielbox") is True
# Schalter „aus“ → Sync überspringt die Quelle
service.schalter_gesetzt("spielbox", False)
assert service.quelle_laeuft("spielbox") is False
zeilen = service.laufe()
assert zeilen[0].aktiv is False and zeilen[0].neu == 0
# Schalter „an“ → läuft wieder
service.schalter_gesetzt("spielbox", True)
assert service.quelle_laeuft("spielbox") is True
zeilen = service.laufe()
assert zeilen[0].aktiv is True and zeilen[0].neu == 1
def test_einzellauf_ignoriert_schalter(session_factory, monkeypatch):
"""`laufе(nur_quellen=…)` läuft auch bei ausgeschalteter Quelle."""
monkeypatch.delenv("SPIELE_NEUHEITEN_QUELLE_SPIELBOX_AKTIV", raising=False)
spielbox = StatischerAdapter("spielbox", [QuellenTreffer(titel="X", quellen_url="u")])
andere = StatischerAdapter("cliquenabend", [QuellenTreffer(titel="Y", quellen_url="u")])
service = _service(session_factory, (spielbox, andere))
service.schalter_gesetzt("spielbox", False)
zeilen = service.laufe(nur_quellen={"spielbox"})
assert len(zeilen) == 1
assert zeilen[0].quelle == "spielbox"
assert zeilen[0].aktiv is True
assert zeilen[0].neu == 1
def test_validatoren_werden_persistiert_und_wieder_verwendet(session_factory): def test_validatoren_werden_persistiert_und_wieder_verwendet(session_factory):
"""304-Behandlung über Läufe hinweg: ETag wird gespeichert und gesendet.""" """304-Behandlung über Läufe hinweg: ETag wird gespeichert und gesendet."""
import tests._neuheiten as h import tests._neuheiten as h

View File

@@ -94,7 +94,9 @@ def test_sync_legt_eintraege_an(session_factory):
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]}, suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()}, details_pro_id={13: _spiel()},
) )
service = SyncService(session_factory, client) service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["catan"]) ergebnis = service.synchronisiere(["catan"])
@@ -117,7 +119,9 @@ def test_sync_aktualisiert_bestehende_eintraege_statt_duplikate(session_factory)
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]}, suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()}, details_pro_id={13: _spiel()},
) )
service = SyncService(session_factory, client) service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
service.synchronisiere(["catan"]) service.synchronisiere(["catan"])
@@ -142,7 +146,9 @@ def test_sync_veraenderter_titel_gleiche_bgg_id_kein_duplikat(session_factory):
suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]}, suchergebnisse={"catan": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()}, details_pro_id={13: _spiel()},
) )
service = SyncService(session_factory, client) service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
service.synchronisiere(["catan"]) service.synchronisiere(["catan"])
client.details_pro_id[13] = _spiel(titel="Catan (Neuausgabe)") client.details_pro_id[13] = _spiel(titel="Catan (Neuausgabe)")
@@ -176,7 +182,9 @@ def test_sync_filtert_erweiterungen(session_factory):
55: _spiel(55, "Catan: Erweiterung", typ="boardgameexpansion"), 55: _spiel(55, "Catan: Erweiterung", typ="boardgameexpansion"),
}, },
) )
service = SyncService(session_factory, client) service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["catan"]) ergebnis = service.synchronisiere(["catan"])
@@ -200,7 +208,9 @@ def test_sync_filtert_prototypen(session_factory):
78: _spiel(78, "My Prototype Game"), 78: _spiel(78, "My Prototype Game"),
}, },
) )
service = SyncService(session_factory, client) service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["essen"]) ergebnis = service.synchronisiere(["essen"])
@@ -216,6 +226,61 @@ def test_prototyp_heuristik():
assert not ist_moeglicher_prototyp("Die Burgen von Burgund") assert not ist_moeglicher_prototyp("Die Burgen von Burgund")
# ---------- Filter: Jahr und deutscher Titel ----------
def test_sync_filtert_nach_jahr_und_deutschem_titel(session_factory):
aktuelles = 2026
client = FakeBggClient(
suchergebnisse={
"mix": [
SuchTreffer(13, "Catan", aktuelles - 1),
SuchTreffer(42, "Die neuen Siedler", aktuelles),
SuchTreffer(99, "Future Game", aktuelles + 1),
SuchTreffer(88, "The Old English Game", aktuelles),
]
},
details_pro_id={
13: _spiel(13, "Catan", erscheinungsjahr=aktuelles - 1),
42: _spiel(42, "Die neuen Siedler", erscheinungsjahr=aktuelles),
99: _spiel(99, "Future Game", erscheinungsjahr=aktuelles + 1),
88: _spiel(88, "The Old English Game", erscheinungsjahr=aktuelles),
},
)
service = SyncService(session_factory, client)
service._min_jahr = aktuelles
ergebnis = service.synchronisiere(["mix"])
assert ergebnis.neu == 1 # nur "Die neuen Siedler" überlebt beide Filter
assert ergebnis.gefiltert == 3 # 1 zu alt + 1 zukünftig aber englisch + 1 aktuell aber englisch
eintraege = _alle_eintraege(session_factory)
assert [e.bgg_id for e in eintraege] == [42]
def test_sync_ohne_filter_behält_alle_jahre_und_sprachen(session_factory):
client = FakeBggClient(
suchergebnisse={
"mix": [
SuchTreffer(13, "Catan", 1995),
SuchTreffer(88, "The English Game", 2026),
]
},
details_pro_id={
13: _spiel(13, "Catan", erscheinungsjahr=1995),
88: _spiel(88, "The English Game", erscheinungsjahr=2026),
},
)
service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["mix"])
assert ergebnis.neu == 2
assert ergebnis.gefiltert == 0
# ---------- Robustheit ---------- # ---------- Robustheit ----------
@@ -233,7 +298,9 @@ def test_401_bricht_sync_ab_mit_klarer_meldung(session_factory):
suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]}, suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()}, details_pro_id={13: _spiel()},
) )
service = SyncService(session_factory, client) service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["gut", "abgelehnt", "dritter"]) ergebnis = service.synchronisiere(["gut", "abgelehnt", "dritter"])
@@ -255,7 +322,9 @@ def test_fehler_bei_einem_suchbegriff_bricht_lauf_nicht_ab(session_factory):
suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]}, suchergebnisse={"gut": [SuchTreffer(13, "Catan", 1995)]},
details_pro_id={13: _spiel()}, details_pro_id={13: _spiel()},
) )
service = SyncService(session_factory, client) service = SyncService(
session_factory, client, jahr_filter=False, deutscher_titel_filter=False
)
ergebnis = service.synchronisiere(["kaputt", "gut"]) ergebnis = service.synchronisiere(["kaputt", "gut"])

View File

@@ -4,6 +4,8 @@ Alle BGG-Antworten sind gemockt (FakeBggClient), kein echter Netzwerkverkehr.
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from redaktionskern.app import create_app from redaktionskern.app import create_app
@@ -77,7 +79,7 @@ def test_liste_zeigt_eintraege_und_deutsche_beschriftung(app, client):
db.commit() db.commit()
melde_an(client) melde_an(client)
antwort = client.get("/neuheiten") antwort = client.get("/neuheiten?jahr=1990")
assert antwort.status_code == 200 assert antwort.status_code == 200
for text in ( for text in (
@@ -135,12 +137,31 @@ def test_sortierung_nach_jahr_abwaerts(app, client):
db.commit() db.commit()
melde_an(client) melde_an(client)
antwort = client.get("/neuheiten?sort=jahr&richtung=ab") antwort = client.get("/neuheiten?sort=jahr&richtung=ab&jahr=1990")
reihenfolge = [zeile for zeile in ("Neu", "Mittel", "Alt") if zeile] reihenfolge = [zeile for zeile in ("Neu", "Mittel", "Alt") if zeile]
position = [antwort.text.index(titel) for titel in reihenfolge] position = [antwort.text.index(titel) for titel in reihenfolge]
assert position == sorted(position) assert position == sorted(position)
def test_standard_jahr_filter_zeigt_nur_aktuelles_jahr_und_neuer(app, client):
"""Ohne jahr-Parameter: nur aktuelles Jahr und neuer (plus ohne Jahr)."""
with app.state.session_factory() as db:
db.add_all(
[
Neuheit(titel="Alt", bgg_id=1, erscheinungsjahr=1990),
Neuheit(titel="Aktuell", bgg_id=2, erscheinungsjahr=datetime.now(timezone.utc).year),
Neuheit(titel="Ohne Jahr", bgg_id=3),
]
)
db.commit()
melde_an(client)
antwort = client.get("/neuheiten")
assert "Aktuell" in antwort.text
assert "Ohne Jahr" in antwort.text
assert ">Alt<" not in antwort.text
def test_unbekannte_sortierung_faellt_auf_standard_zurueck(client): def test_unbekannte_sortierung_faellt_auf_standard_zurueck(client):
melde_an(client) melde_an(client)
antwort = client.get("/neuheiten?sort=hack&richtung=auf") antwort = client.get("/neuheiten?sort=hack&richtung=auf")
@@ -173,6 +194,8 @@ def test_sync_endpunkt_rollen_pruefung(app, settings):
def test_manueller_sync_legt_eintraege_an(monkeypatch, settings): def test_manueller_sync_legt_eintraege_an(monkeypatch, settings):
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
monkeypatch.setenv("SPIELE_BGG_JAHR_FILTER", "0")
monkeypatch.setenv("SPIELE_BGG_DEUTSCHE_TITEL_FILTER", "0")
fake = FakeBggClient( fake = FakeBggClient(
treffer=[SuchTreffer(13, "Catan", 1995)], treffer=[SuchTreffer(13, "Catan", 1995)],
spiel_details=[ spiel_details=[

View File

@@ -174,7 +174,10 @@ def test_migration_legt_planungsliste_an(app):
text("SELECT version FROM schema_migrations WHERE plugin='planung'") text("SELECT version FROM schema_migrations WHERE plugin='planung'")
).fetchall() ).fetchall()
assert "planungsliste" in tabellen assert "planungsliste" in tabellen
assert [z[0] for z in protokoll] == ["0001_planungsliste"] # 0002_bild_url + 0003_quellen_url: Coverbild- und Quellen-Link-Spalten.
assert [z[0] for z in protokoll] == [
"0001_planungsliste", "0002_bild_url", "0003_quellen_url",
]
def test_anonym_wird_zum_login_umgeleitet(client): def test_anonym_wird_zum_login_umgeleitet(client):