Web-Quellen: An/Aus-Schalter pro Quelle im UI + Einzel-Quellen-Sync
This commit is contained in:
@@ -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(""),
|
||||||
):
|
):
|
||||||
|
"""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()
|
zeilen = self._quellen_sync_ausfuehren()
|
||||||
text = " · ".join(z.als_text() for z in zeilen)
|
text = " · ".join(z.als_text() for z in zeilen)
|
||||||
ziel = f"/neuheiten?meldung={quote(f'Web-Quellen-Sync abgeschlossen: {text}')}"
|
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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 '—' }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user