From 667196a4ba6e69b2f8cd022dbb611485a3248cd9 Mon Sep 17 00:00:00 2001 From: Flo Hartmann Date: Tue, 25 Aug 2026 01:10:36 +0000 Subject: [PATCH] Web-Quellen: An/Aus-Schalter pro Quelle im UI + Einzel-Quellen-Sync --- plugins/neuheiten/__init__.py | 78 +++++++++++++++++-- plugins/neuheiten/models.py | 3 + plugins/neuheiten/quellen_sync.py | 49 +++++++++++- .../neuheiten/templates/neuheiten/index.html | 20 ++++- tests/test_neuheiten_quellen.py | 40 ++++++++++ 5 files changed, 176 insertions(+), 14 deletions(-) diff --git a/plugins/neuheiten/__init__.py b/plugins/neuheiten/__init__.py index 2976e0c..6c7e8df 100644 --- a/plugins/neuheiten/__init__.py +++ b/plugins/neuheiten/__init__.py @@ -29,7 +29,7 @@ import threading from datetime import datetime, timedelta, timezone from urllib.parse import quote -from fastapi import Depends, Form, Request +from fastapi import Depends, Form, HTTPException, Request from fastapi.responses import RedirectResponse from sqlalchemy import func, or_, select @@ -192,12 +192,41 @@ class NeuheitenPlugin(BasePlugin): @self.router.post("/neuheiten/quellen-sync") def quellen_jetzt_synchronisieren( user: User = Depends(require_roles(Role.ADMIN.value, Role.REDAKTEUR.value)), + quelle: str = Form(""), ): - 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}')}" + """Gesamt-Sync oder Einzel-Quellen-Sync (`quelle` = Quellname).""" + if quelle: + 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) + @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]: """Zeilen der Sync-Übersicht: Adapter + letzter Lauf pro Quelle.""" status_nach_quelle = { @@ -207,12 +236,18 @@ class NeuheitenPlugin(BasePlugin): ansichten = [] for klasse in ADAPTER_KLASSEN: 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( { "name": klasse.name, "anzeigename": klasse.anzeigename, "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, "dauer_sekunden": status.dauer_sekunden if status else None, "neu": status.anzahl_neu if status else 0, @@ -301,12 +336,33 @@ class NeuheitenPlugin(BasePlugin): "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 [ Migration(version="0001_neuheiten_tabelle", up=neuheiten_tabelle), Migration(version="0002_bgg_id_nullable", up=bgg_id_nullable), 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]: @@ -386,8 +442,14 @@ class NeuheitenPlugin(BasePlugin): finally: self._sync_sperre.release() - def _quellen_sync_ausfuehren(self) -> list[QuellenLaufZeile]: - """Ein Lauf über alle aktiven Web-Quellen (Fehler pro Quelle isoliert).""" + def _quellen_sync_ausfuehren( + self, nur_quellen: set[str] | None = None + ) -> list[QuellenLaufZeile]: + """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 @@ -398,7 +460,7 @@ class NeuheitenPlugin(BasePlugin): service = WebQuellenSyncService( self.context.session_factory, dedup=dedup, audit=audit ) - return service.laufe() + return service.laufe(nur_quellen=nur_quellen) def _quellen_sync_job(self) -> None: """Hintergrund-Job: holt regelmäßig die Web-Quellen ab.""" diff --git a/plugins/neuheiten/models.py b/plugins/neuheiten/models.py index f32f25f..2e68817 100644 --- a/plugins/neuheiten/models.py +++ b/plugins/neuheiten/models.py @@ -67,6 +67,9 @@ class QuellenStatus(Base): 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__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) diff --git a/plugins/neuheiten/quellen_sync.py b/plugins/neuheiten/quellen_sync.py index 600aea5..7b3a0c9 100644 --- a/plugins/neuheiten/quellen_sync.py +++ b/plugins/neuheiten/quellen_sync.py @@ -124,12 +124,23 @@ class WebQuellenSyncService: # ---------- 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] = [] ki = self._ki_hilfe() for klasse in self.adapter_klassen: + if nur_quellen is not None and klasse.name not in nur_quellen: + continue try: - zeile = self._eine_quelle(klasse, ki) + zeile = self._eine_quelle( + klasse, ki, einzellauf=nur_quellen is not None + ) except Exception as exc: # letzte Verteidigungslinie pro Quelle _logger.exception("Quellen-Sync fehlgeschlagen für %s", klasse.name) zeile = QuellenLaufZeile( @@ -142,6 +153,32 @@ class WebQuellenSyncService: zeilen.append(zeile) 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: """Kompakte deutschsprachige Zusammenfassung für Banner/Protokoll.""" teile = [zeile.als_text() for zeile in zeilen] @@ -169,11 +206,15 @@ class WebQuellenSyncService: return None def _eine_quelle( - self, klasse: type[QuellenAdapter], ki: KiHilfe | None = None + self, + klasse: type[QuellenAdapter], + ki: KiHilfe | None = None, + einzellauf: bool = False, ) -> QuellenLaufZeile: zeile = QuellenLaufZeile(klasse.name, klasse.anzeigename) 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 return zeile diff --git a/plugins/neuheiten/templates/neuheiten/index.html b/plugins/neuheiten/templates/neuheiten/index.html index 3a0d4d2..749cd52 100644 --- a/plugins/neuheiten/templates/neuheiten/index.html +++ b/plugins/neuheiten/templates/neuheiten/index.html @@ -75,8 +75,24 @@ {{ quelle.name }} - {% if quelle.aktiv %}an - {% else %}aus{% endif %} +
+ {% if sync_aktiv %} + + + {% else %} + {{ 'an' if quelle.aktiv else 'aus' }} (Env) + {% endif %} +
+
+ + +
{{ quelle.letzte_laufzeit.strftime('%d.%m.%Y %H:%M') if quelle.letzte_laufzeit else '—' }} diff --git a/tests/test_neuheiten_quellen.py b/tests/test_neuheiten_quellen.py index 7e440b2..7ca7105 100644 --- a/tests/test_neuheiten_quellen.py +++ b/tests/test_neuheiten_quellen.py @@ -653,6 +653,46 @@ def test_env_schalter_deaktiviert_einzelne_quelle(session_factory, monkeypatch): 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): """304-Behandlung über Läufe hinweg: ETag wird gespeichert und gesendet.""" import tests._neuheiten as h