"""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"" 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"