Teil 1 — Coverbilder: - Migration 0004_bild_url (neuheiten), 0002_bild_url (planung, archiv mit beiden Spiegel-Tabellen); nullable VARCHAR(500), SQLite↔Postgres portabel, bestehende Zeilen bleiben erhalten - BGG-Client extrahiert Cover aus der Thing-Antwort (bevorzugt <image>, sonst <thumbnail>; protokoll-relative URLs → https) - Sync-Upsert pflegt bild_url beim Update; fehlendes Bild löscht keines - Web-Quellen übernehmen img-URLs aus den Listenelementen (src/data-src, relative Pfade gegen die Seiten-URL aufgelöst); SPIEL-Essen nutzt das BILDER-Feld des JSON-Endpunkts - Verschiebung Neuheiten→Planung und Archivierung/Wiederherstellen schreiben bild_url mit - Anzeige: kleine Vorschaubilder (40x56) in den Listen, groß (max 200px) in Planung-Bearbeiten/Prüf-Dialog; Platzhalter-Div ohne Bild; Hotlinking mit loading=lazy + decoding=async Teil 2 — Responsives Layout: - Basislayout: Burger-Menü (Alpine.js) auf Mobil, Desktop-Leiste ab md: - Listen kollabieren <768px zu Karten-Ansichten (Cover links, Titel + Metadaten rechts, Status-Badge oben); Desktop behält Tabellenform - Touch-Ziele >= 44px auf Mobil, Formulare vollbreit mit Labels oben - Sync-Übersicht/Audit-Log bleiben horizontal scrollbare Tabellen Tests: 15 neue Fälle (tests/test_covers.py) — Migration inkl. Daten-Erhalt, BGG-Parsing gemockt, Quellen-Übernahme, Template-Render; 316 Tests grün. README aktualisiert.
151 lines
5.4 KiB
Python
151 lines
5.4 KiB
Python
"""Sync-Logik des Plugins „neuheiten“: BGG-Suche → Filter → Upsert.
|
|
|
|
- Update statt Duplikat: bestehende Einträge werden über die eindeutige
|
|
BGG-ID gefunden und aktualisiert (Status bleibt erhalten).
|
|
- Erweiterungen werden im Client und hier auf Elementebene gefiltert.
|
|
- Prototypen: Die BGG-XML-API2 liefert keinen verlässlichen Prototyp-Marker;
|
|
als Heuristik werden Titel mit Prototyp-Schlüsselwörtern ausgeschlossen
|
|
(dokumentierte Einschränkung, siehe README).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from collections.abc import Iterable, Sequence
|
|
from dataclasses import dataclass, field
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from .bgg import BggAuthFehler, BggSpiel, BggClient
|
|
from .models import STATUS_NEUHEIT, Neuheit
|
|
|
|
_logger = logging.getLogger("plugins.neuheiten")
|
|
|
|
PROTOTYP_MUSTER = re.compile(
|
|
r"(?i)\b(prototyp\w*|prototype|proto)\b"
|
|
)
|
|
|
|
THING_BATCH_GROESSE = 20
|
|
|
|
|
|
def ist_moeglicher_prototyp(titel: str) -> bool:
|
|
"""Heuristik: Titel klingt nach Prototyp/Protoversion."""
|
|
return bool(PROTOTYP_MUSTER.search(titel))
|
|
|
|
|
|
@dataclass
|
|
class SyncErgebnis:
|
|
"""Zusammenfassung eines Sync-Laufs."""
|
|
|
|
neu: int = 0
|
|
aktualisiert: int = 0
|
|
gefiltert: int = 0
|
|
fehler: list[str] = field(default_factory=list)
|
|
# Grund, warum der Lauf vorzeitig endete oder ganz übersprungen wurde
|
|
# (z. B. fehlender BGG-Token oder HTTP 401); None = normal gelaufen.
|
|
abbruch: str | None = None
|
|
|
|
def als_text(self) -> str:
|
|
if self.abbruch:
|
|
return self.abbruch
|
|
text = (
|
|
f"{self.neu} neu, {self.aktualisiert} aktualisiert, "
|
|
f"{self.gefiltert} gefiltert (Erweiterungen/Prototypen)"
|
|
)
|
|
if self.fehler:
|
|
text += f"; {len(self.fehler)} Fehler"
|
|
return text
|
|
|
|
|
|
def speichere_oder_aktualisiere(db: Session, spiel: BggSpiel) -> bool:
|
|
"""Upsert nach BGG-ID. Rückgabe: True = neu angelegt, False = aktualisiert."""
|
|
vorhanden = db.scalar(select(Neuheit).where(Neuheit.bgg_id == spiel.bgg_id))
|
|
if vorhanden is None:
|
|
db.add(
|
|
Neuheit(
|
|
titel=spiel.titel,
|
|
verlag=spiel.verlag,
|
|
autor=spiel.autor,
|
|
erscheinungsjahr=spiel.erscheinungsjahr,
|
|
bgg_id=spiel.bgg_id,
|
|
bild_url=spiel.bild_url,
|
|
status=STATUS_NEUHEIT,
|
|
quelle="boardgamegeek",
|
|
)
|
|
)
|
|
return True
|
|
vorhanden.titel = spiel.titel
|
|
vorhanden.verlag = spiel.verlag
|
|
vorhanden.autor = spiel.autor
|
|
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
|
|
|
|
|
|
class SyncService:
|
|
"""Führt einen Sync-Lauf aus: Suchen → Details → Filter → Upsert."""
|
|
|
|
def __init__(
|
|
self,
|
|
session_factory: sessionmaker,
|
|
client: BggClient,
|
|
*,
|
|
thing_batch_groesse: int = THING_BATCH_GROESSE,
|
|
max_treffer_pro_suchbegriff: int | None = None,
|
|
) -> None:
|
|
self.session_factory = session_factory
|
|
self.client = client
|
|
self.thing_batch_groesse = thing_batch_groesse
|
|
self.max_treffer_pro_suchbegriff = max_treffer_pro_suchbegriff
|
|
|
|
def synchronisiere(self, suchbegriffe: Iterable[str]) -> SyncErgebnis:
|
|
ergebnis = SyncErgebnis()
|
|
with self.session_factory() as db:
|
|
for suchbegriff in suchbegriffe:
|
|
begriff = suchbegriff.strip()
|
|
if not begriff:
|
|
continue
|
|
try:
|
|
self._ein_suchbegriff(db, begriff, ergebnis)
|
|
except BggAuthFehler as exc:
|
|
# HTTP 401 trifft jede weitere Anfrage genauso — der Lauf
|
|
# wird abgebrochen, statt alle Begriffe einzeln scheitern
|
|
# zu lassen; bereits gespeicherte Treffer bleiben erhalten.
|
|
_logger.warning("BGG-Sync abgebrochen für %r: %s", begriff, exc)
|
|
ergebnis.abbruch = str(exc)
|
|
break
|
|
except Exception as exc: # ein Begriff darf den Lauf nicht abbrechen
|
|
_logger.warning("BGG-Sync fehlgeschlagen für %r: %s", begriff, exc)
|
|
ergebnis.fehler.append(f"{begriff}: {exc}")
|
|
db.commit()
|
|
return ergebnis
|
|
|
|
def _ein_suchbegriff(
|
|
self, db: Session, suchbegriff: str, ergebnis: SyncErgebnis
|
|
) -> None:
|
|
treffer = self.client.suche(
|
|
suchbegriff, max_treffer=self.max_treffer_pro_suchbegriff
|
|
)
|
|
ids = [t.bgg_id for t in treffer]
|
|
for batch in _batches(ids, self.thing_batch_groesse):
|
|
for spiel in self.client.details(list(batch)):
|
|
if spiel.typ != "boardgame":
|
|
ergebnis.gefiltert += 1
|
|
continue
|
|
if ist_moeglicher_prototyp(spiel.titel):
|
|
ergebnis.gefiltert += 1
|
|
continue
|
|
if speichere_oder_aktualisiere(db, spiel):
|
|
ergebnis.neu += 1
|
|
else:
|
|
ergebnis.aktualisiert += 1
|
|
|
|
|
|
def _batches(werte: Sequence[int], groesse: int) -> Iterable[Sequence[int]]:
|
|
for start in range(0, len(werte), groesse):
|
|
yield werte[start : start + groesse]
|