Web-Quellen: An/Aus-Schalter pro Quelle im UI + Einzel-Quellen-Sync

This commit is contained in:
Flo Hartmann
2026-08-25 01:10:36 +00:00
parent 8b38bc6c89
commit 667196a4ba
5 changed files with 176 additions and 14 deletions

View File

@@ -29,7 +29,7 @@ import threading
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from urllib.parse import quote from urllib.parse import quote
from fastapi import Depends, Form, Request from fastapi import Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
@@ -192,12 +192,41 @@ class NeuheitenPlugin(BasePlugin):
@self.router.post("/neuheiten/quellen-sync") @self.router.post("/neuheiten/quellen-sync")
def quellen_jetzt_synchronisieren( def quellen_jetzt_synchronisieren(
user: User = Depends(require_roles(Role.ADMIN.value, Role.REDAKTEUR.value)), user: User = Depends(require_roles(Role.ADMIN.value, Role.REDAKTEUR.value)),
quelle: str = Form(""),
): ):
zeilen = self._quellen_sync_ausfuehren() """Gesamt-Sync oder Einzel-Quellen-Sync (`quelle` = Quellname)."""
text = " · ".join(z.als_text() for z in zeilen) if quelle:
ziel = f"/neuheiten?meldung={quote(f'Web-Quellen-Sync abgeschlossen: {text}')}" 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) 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]: def _quellen_uebersicht(self, db) -> list[dict]:
"""Zeilen der Sync-Übersicht: Adapter + letzter Lauf pro Quelle.""" """Zeilen der Sync-Übersicht: Adapter + letzter Lauf pro Quelle."""
status_nach_quelle = { status_nach_quelle = {
@@ -207,12 +236,18 @@ class NeuheitenPlugin(BasePlugin):
ansichten = [] ansichten = []
for klasse in ADAPTER_KLASSEN: for klasse in ADAPTER_KLASSEN:
status = status_nach_quelle.get(klasse.name) 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( ansichten.append(
{ {
"name": klasse.name, "name": klasse.name,
"anzeigename": klasse.anzeigename, "anzeigename": klasse.anzeigename,
"url": klasse.start_urls[0] if klasse.start_urls else "", "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, "letzte_laufzeit": status.letzte_laufzeit if status else None,
"dauer_sekunden": status.dauer_sekunden if status else None, "dauer_sekunden": status.dauer_sekunden if status else None,
"neu": status.anzahl_neu if status else 0, "neu": status.anzahl_neu if status else 0,
@@ -301,12 +336,33 @@ class NeuheitenPlugin(BasePlugin):
"ALTER TABLE neuheiten ADD COLUMN quellen_url VARCHAR(500)" "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 [ return [
Migration(version="0001_neuheiten_tabelle", up=neuheiten_tabelle), Migration(version="0001_neuheiten_tabelle", up=neuheiten_tabelle),
Migration(version="0002_bgg_id_nullable", up=bgg_id_nullable), Migration(version="0002_bgg_id_nullable", up=bgg_id_nullable),
Migration(version="0003_quellen_status", up=quellen_status_tabelle), Migration(version="0003_quellen_status", up=quellen_status_tabelle),
Migration(version="0004_bild_url", up=bild_url_spalte), Migration(version="0004_bild_url", up=bild_url_spalte),
Migration(version="0005_quellen_url", up=quellen_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]: def navigation(self) -> list[NavEntry]:
@@ -386,8 +442,14 @@ class NeuheitenPlugin(BasePlugin):
finally: finally:
self._sync_sperre.release() self._sync_sperre.release()
def _quellen_sync_ausfuehren(self) -> list[QuellenLaufZeile]: def _quellen_sync_ausfuehren(
"""Ein Lauf über alle aktiven Web-Quellen (Fehler pro Quelle isoliert).""" 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 = ( registry = (
self.context.registry self.context.registry
if self.context is not None and self.context.registry is not None if self.context is not None and self.context.registry is not None
@@ -398,7 +460,7 @@ class NeuheitenPlugin(BasePlugin):
service = WebQuellenSyncService( service = WebQuellenSyncService(
self.context.session_factory, dedup=dedup, audit=audit self.context.session_factory, dedup=dedup, audit=audit
) )
return service.laufe() return service.laufe(nur_quellen=nur_quellen)
def _quellen_sync_job(self) -> None: def _quellen_sync_job(self) -> None:
"""Hintergrund-Job: holt regelmäßig die Web-Quellen ab.""" """Hintergrund-Job: holt regelmäßig die Web-Quellen ab."""

View File

@@ -67,6 +67,9 @@ class QuellenStatus(Base):
anzahl_fehler: Mapped[int] = mapped_column(Integer, default=0) anzahl_fehler: Mapped[int] = mapped_column(Integer, default=0)
fehlermeldung: Mapped[str | None] = mapped_column(Text) fehlermeldung: Mapped[str | None] = mapped_column(Text)
unveraendert: Mapped[bool] = mapped_column(Boolean, default=False) 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] #: JSON-Objekt URL → [etag, last_modified]
validatoren_json: Mapped[str | None] = mapped_column(Text) validatoren_json: Mapped[str | None] = mapped_column(Text)

View File

@@ -124,12 +124,23 @@ class WebQuellenSyncService:
# ---------- Gesamtdurchlauf ---------- # ---------- 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] = [] zeilen: list[QuellenLaufZeile] = []
ki = self._ki_hilfe() ki = self._ki_hilfe()
for klasse in self.adapter_klassen: for klasse in self.adapter_klassen:
if nur_quellen is not None and klasse.name not in nur_quellen:
continue
try: 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 except Exception as exc: # letzte Verteidigungslinie pro Quelle
_logger.exception("Quellen-Sync fehlgeschlagen für %s", klasse.name) _logger.exception("Quellen-Sync fehlgeschlagen für %s", klasse.name)
zeile = QuellenLaufZeile( zeile = QuellenLaufZeile(
@@ -142,6 +153,32 @@ class WebQuellenSyncService:
zeilen.append(zeile) zeilen.append(zeile)
return zeilen 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: def gesamt_text(self, zeilen: list[QuellenLaufZeile]) -> str:
"""Kompakte deutschsprachige Zusammenfassung für Banner/Protokoll.""" """Kompakte deutschsprachige Zusammenfassung für Banner/Protokoll."""
teile = [zeile.als_text() for zeile in zeilen] teile = [zeile.als_text() for zeile in zeilen]
@@ -169,11 +206,15 @@ class WebQuellenSyncService:
return None return None
def _eine_quelle( def _eine_quelle(
self, klasse: type[QuellenAdapter], ki: KiHilfe | None = None self,
klasse: type[QuellenAdapter],
ki: KiHilfe | None = None,
einzellauf: bool = False,
) -> QuellenLaufZeile: ) -> QuellenLaufZeile:
zeile = QuellenLaufZeile(klasse.name, klasse.anzeigename) zeile = QuellenLaufZeile(klasse.name, klasse.anzeigename)
zeile.url = klasse.start_urls[0] if klasse.start_urls else None 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 zeile.aktiv = False
return zeile return zeile

View File

@@ -75,8 +75,24 @@
<span class="ml-1 text-[10px] text-slate-300">{{ quelle.name }}</span> <span class="ml-1 text-[10px] text-slate-300">{{ quelle.name }}</span>
</td> </td>
<td class="px-2 py-1.5"> <td class="px-2 py-1.5">
{% if quelle.aktiv %}<span class="inline-block px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700">an</span> <form method="post" action="/neuheiten/quellen/{{ quelle.name }}/schalter" class="inline-flex items-center gap-2">
{% else %}<span class="inline-block px-2 py-0.5 rounded-full bg-slate-100 text-slate-400">aus</span>{% endif %} {% if sync_aktiv %}
<input type="hidden" name="aktiv" value="{{ '0' if quelle.aktiv else '1' }}">
<button type="submit"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors {{ 'bg-emerald-500' if quelle.aktiv else 'bg-slate-300' }}"
title="{{ 'Quelle ausschalten' if quelle.aktiv else 'Quelle einschalten' }}">
<span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform {{ 'translate-x-4' if quelle.aktiv else 'translate-x-0.5' }}"></span>
</button>
{% else %}
<span class="text-xs text-slate-400">{{ 'an' if quelle.aktiv else 'aus' }} (Env)</span>
{% endif %}
</form>
<form method="post" action="/neuheiten/quellen-sync" class="inline">
<input type="hidden" name="quelle" value="{{ quelle.name }}">
<button type="submit"
class="ml-2 text-xs text-emerald-700 hover:text-emerald-900 underline decoration-dotted"
title="Nur diese Quelle jetzt synchronisieren">jetzt sync</button>
</form>
</td> </td>
<td class="px-2 py-1.5 whitespace-nowrap text-slate-500"> <td class="px-2 py-1.5 whitespace-nowrap text-slate-500">
{{ quelle.letzte_laufzeit.strftime('%d.%m.%Y %H:%M') if quelle.letzte_laufzeit else '—' }} {{ quelle.letzte_laufzeit.strftime('%d.%m.%Y %H:%M') if quelle.letzte_laufzeit else '—' }}

View File

@@ -653,6 +653,46 @@ def test_env_schalter_deaktiviert_einzelne_quelle(session_factory, monkeypatch):
assert quelle_aktiv("brettspielbox") is True # Standard „1“ 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): def test_validatoren_werden_persistiert_und_wieder_verwendet(session_factory):
"""304-Behandlung über Läufe hinweg: ETag wird gespeichert und gesendet.""" """304-Behandlung über Läufe hinweg: ETag wird gespeichert und gesendet."""
import tests._neuheiten as h import tests._neuheiten as h