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.
91 lines
3.8 KiB
Python
91 lines
3.8 KiB
Python
"""Datenmodell des Plugins „neuheiten“.
|
|
|
|
Ein Eintrag repräsentiert ein Spiel der Neuheitenliste. Die BGG-ID ist das
|
|
eindeutige Merge-Kriterium: Beim Sync werden bestehende Einträge aktualisiert,
|
|
statt Duplikate anzulegen.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text, func
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from redaktionskern.db import Base
|
|
|
|
STATUS_NEUHEIT = "neuheit"
|
|
|
|
|
|
class Neuheit(Base):
|
|
__tablename__ = "neuheiten"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
titel: Mapped[str] = mapped_column(String(300), index=True)
|
|
verlag: Mapped[str | None] = mapped_column(String(300))
|
|
autor: Mapped[str | None] = mapped_column(String(300))
|
|
# BoardGameGeek liefert nur das Erscheinungsjahr (kein genaues Datum).
|
|
erscheinungsjahr: Mapped[int | None] = mapped_column(Integer)
|
|
# Eindeutiges Merge-Kriterium der BGG-Syncs; Web-Quellen haben keine
|
|
# BGG-ID und speichern NULL (Migration 0002 macht die Spalte nullable,
|
|
# Duplikate werden dort über den Titel gematcht statt über die ID).
|
|
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))
|
|
status: Mapped[str] = mapped_column(String(50), default=STATUS_NEUHEIT, index=True)
|
|
quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek")
|
|
erstellt_am: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
|
aktualisiert_am: Mapped[datetime] = mapped_column(
|
|
DateTime, server_default=func.now(), onupdate=func.now()
|
|
)
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - Debug-Hilfe
|
|
return f"<Neuheit {self.bgg_id or '?'} {self.titel!r}>"
|
|
|
|
|
|
class QuellenStatus(Base):
|
|
"""Sync-Status einer Web-Quelle (Übersicht im UI + HTTP-Validatoren).
|
|
|
|
Pro Quelle eine Zeile: Ergebnis des letzten Laufes (Zeitpunkt, Dauer,
|
|
neu/aktualisiert/gefiltert/Fehler) plus die konditionalen
|
|
Request-Header (ETag/Last-Modified je abgerufener URL), damit 304er
|
|
auch über Neustarts hinweg respektiert werden.
|
|
"""
|
|
|
|
__tablename__ = "neuheiten_quellen_status"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
quelle: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
|
url: Mapped[str | None] = mapped_column(String(500))
|
|
letzte_laufzeit: Mapped[datetime | None] = mapped_column(DateTime)
|
|
dauer_sekunden: Mapped[float] = mapped_column(Float, default=0.0)
|
|
anzahl_neu: Mapped[int] = mapped_column(Integer, default=0)
|
|
anzahl_aktualisiert: Mapped[int] = mapped_column(Integer, default=0)
|
|
anzahl_gefiltert: Mapped[int] = mapped_column(Integer, default=0)
|
|
anzahl_fehler: Mapped[int] = mapped_column(Integer, default=0)
|
|
fehlermeldung: Mapped[str | None] = mapped_column(Text)
|
|
unveraendert: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
#: JSON-Objekt URL → [etag, last_modified]
|
|
validatoren_json: Mapped[str | None] = mapped_column(Text)
|
|
|
|
@property
|
|
def validatoren(self) -> dict[str, tuple[str | None, str | None]]:
|
|
if not self.validatoren_json:
|
|
return {}
|
|
try:
|
|
roh = json.loads(self.validatoren_json)
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
return {
|
|
str(url): (paar[0], paar[1]) if isinstance(paar, (list, tuple)) else (None, None)
|
|
for url, paar in roh.items()
|
|
}
|
|
|
|
@validatoren.setter
|
|
def validatoren(self, wert: dict[str, tuple[str | None, str | None]]) -> None:
|
|
self.validatoren_json = json.dumps(wert or {})
|
|
|
|
|
|
STATUS_UNVERAENDERT_HINWEIS = "unverändert (304) — übersprungen"
|