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 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."""
|
||||
|
||||
@@ -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_<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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -75,8 +75,24 @@
|
||||
<span class="ml-1 text-[10px] text-slate-300">{{ quelle.name }}</span>
|
||||
</td>
|
||||
<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>
|
||||
{% else %}<span class="inline-block px-2 py-0.5 rounded-full bg-slate-100 text-slate-400">aus</span>{% endif %}
|
||||
<form method="post" action="/neuheiten/quellen/{{ quelle.name }}/schalter" class="inline-flex items-center gap-2">
|
||||
{% 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 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 '—' }}
|
||||
|
||||
Reference in New Issue
Block a user