Compare commits
24 Commits
c6f9b19f6b
...
ui-ux-verb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49f3b2d2fb | ||
|
|
968333bf45 | ||
|
|
f90c0de46d | ||
|
|
d6ed8fa56e | ||
|
|
fee5f8b9a3 | ||
|
|
7645f6a791 | ||
|
|
117b34653c | ||
|
|
bba39525a0 | ||
|
|
9bd3b700c4 | ||
|
|
742c265df8 | ||
|
|
b9444289b5 | ||
|
|
14a4846948 | ||
|
|
e1ef7ba926 | ||
|
|
929cea1887 | ||
|
|
d91402881a | ||
|
|
ca6d94938c | ||
|
|
667196a4ba | ||
|
|
8b38bc6c89 | ||
|
|
199f8b080d | ||
|
|
aea52d1563 | ||
|
|
fa453518d4 | ||
|
|
96d6af8163 | ||
|
|
20b84a8e1a | ||
|
|
f27940db8e |
9
.env
Normal file
9
.env
Normal 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
152
README.md
@@ -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; 1–2 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
|
||||||
|
|
||||||
|
|||||||
4
TODO.md
4
TODO.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
31
plugins/archiv/templates/archiv/_cover.html
Normal file
31
plugins/archiv/templates/archiv/_cover.html
Normal 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 %}
|
||||||
@@ -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ückliegt, werden täglich automatisch aus den
|
mehr als 12 Monate zurückliegt, werden tä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äge im Archiv</p>
|
<p class="text-xs text-slate-400 mb-3">{{ anzahl }} Einträ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 {{ 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ü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">
|
||||||
↻ 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ür diese Suche.
|
Keine Treffer für diese Suche.
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">–</span>
|
<span class="text-slate-400">–</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">← 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 →</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% from "seitennavigation.html" import seitennavigation %}
|
||||||
|
{{ seitennavigation(seiten_url, seite, gesamt_seiten) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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">Benachrichtigungen — Einstellungen</h1>
|
<h1 class="text-2xl font-bold text-slate-900 mb-1">Benachrichtigungen — Einstellungen</h1>
|
||||||
<p class="text-slate-600 mb-4">
|
<p class="text-slate-600 mb-4">
|
||||||
Wählen Sie, über welche Kanäle Sie Benachrichtigungen erhalten.
|
Wählen Sie, über welche Kanäle Sie Benachrichtigungen erhalten.
|
||||||
Mehrere Kanäle sind gleichzeitig möglich.
|
Mehrere Kanäle sind gleichzeitig mö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ählten Kanal erhalten Sie keine Benachrichtigungen.</span>
|
<span class="text-xs text-slate-400">Ohne ausgewählten Kanal erhalten Sie keine Benachrichtigungen.</span>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block titel %}{{ titel }} — {{ app_name }}{% endblock %}
|
{% block titel %}{{ titel }} — {{ 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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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">🏚 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 → Verlagsauswahl.</p>
|
<p class="text-slate-500">Gleiches Spiel bei verschiedenen Verlagen/Vertrieb → 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">🇧 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">📖 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änger (BGG-Erweiterungs-Relation + Fuzzy-Match) schon in der Redaktion.</p>
|
<p class="text-slate-500">Spiel oder Vorgä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">📋 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üfen</h2>
|
<h2 class="text-base font-semibold text-slate-800 mb-3">Titel prü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 %}⚠ {{ ergebnis.konflikte|length }} Treffer für „{{ ergebnis.titel }}“{% else %}✓ Keine Treffer fü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ür „{{ ergebnis.titel }}“{% else %}{% from "icons.html" import icon_check %}{{ icon_check("h-4 w-4") }} Keine Treffer fü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üfungen</h2>
|
<h2 class="text-base font-semibold text-slate-800 px-4 pt-4 pb-2">Letzte Prü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üft von</th>
|
<th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Geprü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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">„{{ ausgabe.name }}“ · Redaktionsschluss:
|
<p class="text-slate-600 mb-4">„{{ ausgabe.name }}“ · 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">
|
||||||
|
|||||||
@@ -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üsse der kommenden Ausgaben. Vier Wochen vor jedem
|
Redaktionsschlü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ö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ö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üfen</h2>
|
<h2 class="text-base font-semibold text-slate-800">Sofort prüfen</h2>
|
||||||
<p class="text-xs text-slate-500">Führt denselben Check aus wie der tägliche Hintergrund-Job ({{ fenster_tage }} Tage vor dem Redaktionsschluss). Doppelerinnerungen sind ausgeschlossen.</p>
|
<p class="text-xs text-slate-500">Führt denselben Check aus wie der tägliche Hintergrund-Job ({{ fenster_tage }} Tage vor dem Redaktionsschluss). Doppelerinnerungen sind ausgeschlossen.</p>
|
||||||
</div>
|
</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ür welche Ausgabe wann erinnert? Pro Ausgabe und Benutzer wird höchstens einmal erinnert.</p>
|
<p class="text-xs text-slate-500 mb-3">Wer wurde für welche Ausgabe wann erinnert? Pro Ausgabe und Benutzer wird höchstens einmal erinnert.</p>
|
||||||
<div class="overflow-x-auto">
|
<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äle</th>
|
<th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Kanä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">
|
||||||
|
„<span x-text="loeschTitel"></span>“ 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 %}
|
||||||
|
|||||||
@@ -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 — direkt in Excel
|
<strong>CSV</strong> (Semikolon-getrennt, UTF-8 mit BOM — 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">
|
||||||
|
|||||||
@@ -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(""),
|
||||||
):
|
):
|
||||||
zeilen = self._quellen_sync_ausfuehren()
|
"""Gesamt-Sync oder Einzel-Quellen-Sync (`quelle` = Quellname)."""
|
||||||
text = " · ".join(z.als_text() for z in zeilen)
|
if quelle:
|
||||||
ziel = f"/neuheiten?meldung={quote(f'Web-Quellen-Sync abgeschlossen: {text}')}"
|
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()
|
||||||
|
text = " · ".join(z.als_text() for z in zeilen)
|
||||||
|
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."""
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
antwort = self.client.hole(url)
|
try:
|
||||||
|
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]]:
|
||||||
|
|||||||
@@ -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, []
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
575
plugins/neuheiten/quellen/ki_hilfe.py
Normal file
575
plugins/neuheiten/quellen/ki_hilfe.py
Normal 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. 1–2 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
|
||||||
@@ -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),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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] = {}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
31
plugins/neuheiten/templates/neuheiten/_cover.html
Normal file
31
plugins/neuheiten/templates/neuheiten/_cover.html
Normal 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 %}
|
||||||
@@ -1,40 +1,103 @@
|
|||||||
<div id="neuheiten-liste">
|
<div id="neuheiten-liste">
|
||||||
<p class="text-xs text-slate-500 mb-2">{{ anzahl }} Einträge</p>
|
<p class="text-xs text-slate-500 mb-2">{{ anzahl }} Einträ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 {{ 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 }} ↗</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äge gefunden.
|
||||||
|
{% if ist_redaktion %}Nutze „Jetzt synchronisieren“, um die Liste aus BoardGameGeek zu befüllen.{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Desktop/Tablet (≥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 }} {{ '▲' if richtung == 'auf' else '▼' }}</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 {{ eintrag.bgg_id }}</a>
|
class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG {{ 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 }} ↗</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">→ 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äge gefunden.
|
Keine Einträge gefunden.
|
||||||
{% if ist_redaktion %}Nutze „Jetzt synchronisieren“, um die Liste aus BoardGameGeek zu befüllen.{% endif %}
|
{% if ist_redaktion %}Nutze „Jetzt synchronisieren“, 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>
|
||||||
|
|||||||
@@ -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 & importieren (optional)
|
Sofort auf BoardGameGeek suchen & 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">
|
||||||
↻ 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">
|
||||||
↻ 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
31
plugins/planung/templates/planung/_cover.html
Normal file
31
plugins/planung/templates/planung/_cover.html
Normal 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 %}
|
||||||
@@ -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 }}“ · Rezensent:
|
<p class="text-slate-600 mb-4">„{{ eintrag.titel }}“ · 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 %}
|
||||||
|
|||||||
@@ -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ür welche Ausgabe.
|
Planungsliste der Redaktion: wer reviewt welches Spiel für welche Ausgabe.
|
||||||
Jede Eintragung — aus den Neuheiten verschoben oder händisch nachgetragen —
|
Jede Eintragung — aus den Neuheiten verschoben oder händisch nachgetragen —
|
||||||
@@ -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ändisch nachtragen</h2>
|
<h2 class="text-base font-semibold text-slate-800 mb-3">Titel hä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üfen & eintragen
|
Prüfen & 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äge</p>
|
<p class="text-xs text-slate-400 ml-auto">{{ anzahl }} Einträ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 {{ 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 }} ↗</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ü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ö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äge — schiebe einen Titel aus den Neuheiten hierher oder trage ihn oben händisch nach.
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Desktop/Tablet (≥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 {{ eintrag.bgg_id }}</a>
|
class="ml-1 text-[10px] text-slate-400 hover:text-emerald-700 align-super">BGG {{ 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 }} ↗</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.">⚠ Prü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ü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ö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ö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äge — schiebe einen Titel aus den Neuheiten hierher oder trage ihn oben händisch nach.
|
Noch keine Einträge — schiebe einen Titel aus den Neuheiten hierher oder trage ihn oben hä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">
|
||||||
|
„<span x-text="loeschTitel"></span>“ 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 %}
|
||||||
|
|||||||
@@ -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üfung hat Treffer ergeben. Bitte prüfen, bevor
|
Die automatische Dedup-Prüfung hat Treffer ergeben. Bitte prü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">⚠ {{ konflikte|length }} Treffer fü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ü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ührt werden?</h2>
|
<h2 class="text-base font-semibold text-slate-800 mb-3">Welcher Verlag soll gefü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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ----------------
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|||||||
BIN
src/redaktionskern/static/powered-by-bgg.png
Normal file
BIN
src/redaktionskern/static/powered-by-bgg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -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">🎳 {{ app_name }}</a>
|
<a href="/" class="font-bold text-emerald-700 whitespace-nowrap inline-flex items-center gap-1.5">
|
||||||
{% for eintrag in plugin_nav %}
|
{% from "icons.html" import icon_wuerfelbecher %}{{ icon_wuerfelbecher("h-5 w-5") }} {{ app_name }}
|
||||||
<a href="{{ eintrag.url }}" class="text-sm text-slate-600 hover:text-emerald-700">{{ eintrag.label }}</a>
|
</a>
|
||||||
{% endfor %}
|
<!-- Desktop: horizontale Navigationsleiste -->
|
||||||
<div class="ml-auto relative" x-data="{ offen: false }">
|
<span class="hidden md:flex items-center gap-5 flex-1">
|
||||||
|
{% 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 }}" {% 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 %}
|
||||||
|
</span>
|
||||||
|
<!-- Mobil: Burger-Button (Alpine.js), Touch-Ziel ≥≥ 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 }} ▾
|
{{ 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 }} · Plugin-Anzahl: {{ geladene_plugins|length }}
|
{{ app_name }} · 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>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block titel %}{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer bearbeiten{% endif %} — {{ app_name }}{% endblock %}
|
{% block titel %}{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer bearbeiten{% endif %} — {{ 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 „{{ ziel.username }}“ bearbeiten{% endif %}
|
{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer „{{ ziel.username }}“ 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ändert werden.</p>
|
<p class="text-xs text-slate-400 mt-1">Der Benutzername kann nicht geä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ändert){% endif %}
|
{% if aktion == 'neu' %}Passwort{% else %}Neues Passwort (leer = unverä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 %}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
{% block titel %}Benutzerverwaltung — {{ app_name }}{% endblock %}
|
{% block titel %}Benutzerverwaltung — {{ 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 '–' }}</td>
|
<td class="px-4 py-2 text-slate-500">{{ b.created_at.strftime('%d.%m.%Y') if b.created_at else '–' }}</td>
|
||||||
|
|||||||
@@ -1,17 +1,56 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block titel %}Übersicht — {{ app_name }}{% endblock %}
|
{% block titel %}Übersicht — {{ 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 — 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 }} →</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 %}
|
||||||
|
|||||||
101
src/redaktionskern/templates/icons.html
Normal file
101
src/redaktionskern/templates/icons.html
Normal 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 %}
|
||||||
@@ -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 — {{ app_name }}</title>
|
<title>Anmelden — {{ 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">🎳 {{ app_name }}</h1>
|
<div class="bg-white shadow-md rounded-xl p-8 w-full">
|
||||||
<p class="text-sm text-slate-500 mb-6">Bitte anmelden.</p>
|
<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>
|
||||||
{% if fehler %}
|
<p class="text-sm text-slate-500 mb-6">Bitte anmelden.</p>
|
||||||
<div class="mb-4 rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2" role="alert">
|
{% if fehler %}
|
||||||
{{ fehler }}
|
<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 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/login" class="space-y-4" x-data="{ passwortSichtbar: false }">
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium mb-1">Benutzername</label>
|
||||||
|
<input id="username" name="username" value="{{ username }}" required autocomplete="username"
|
||||||
|
{% 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>
|
||||||
|
<label for="password" class="block text-sm font-medium mb-1">Passwort</label>
|
||||||
|
<div class="relative">
|
||||||
|
<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>
|
||||||
|
<button type="submit"
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</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>
|
||||||
{% endif %}
|
|
||||||
<form method="post" action="/login" class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label for="username" class="block text-sm font-medium mb-1">Benutzername</label>
|
|
||||||
<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">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="password" class="block text-sm font-medium mb-1">Passwort</label>
|
|
||||||
<input id="password" name="password" type="password" required autocomplete="current-password"
|
|
||||||
class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
|
||||||
</div>
|
|
||||||
<button type="submit"
|
|
||||||
class="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-lg px-3 py-2">
|
|
||||||
Anmelden
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
61
src/redaktionskern/templates/seitennavigation.html
Normal file
61
src/redaktionskern/templates/seitennavigation.html
Normal 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">…</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">…</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 %}
|
||||||
26
src/redaktionskern/templates/status.html
Normal file
26
src/redaktionskern/templates/status.html
Normal 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 %}
|
||||||
@@ -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
484
tests/test_covers.py
Normal 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
|
||||||
@@ -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"
|
||||||
|
|||||||
544
tests/test_neuheiten_ki_crawler.py
Normal file
544
tests/test_neuheiten_ki_crawler.py
Normal 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]
|
||||||
@@ -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>—</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
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|||||||
@@ -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=[
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user