Files
spiele-redaktion/plugins/neuheiten/models.py

95 lines
4.1 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))
quellen_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)
#: 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]
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"