feat(sources): vereinte Quellenliste (Grund- + Kundenquellen in einer Tabelle)

- GET /api/sources mit scope=all|global|tenant ersetzt GET /global und
  GET /tenant. Liefert org_name (LEFT JOIN organizations) und wie bisher
  health_status + Artikel-Aktivitaet fuer ALLE Quellen.
- GET /global/stats wird GET /stats (zaehlt jetzt beide Herkuenfte,
  neu by_origin und last_check), GET /global/languages wird GET /languages.
- PUT /global/{id} akzeptiert jetzt auch Kundenquellen (Guard ohne
  tenant_id-Filter), damit der Deaktivieren-Knopf fuer beide funktioniert.
  DELETE bleibt bewusst global-only.
- Neu GET /{source_id}/health fuer die Ausklapp-Details je Quelle.
- Frontend: EIN Render-/Filter-/Sortier-Pfad statt zwei (Tenant-Kette
  komplett entfernt, ~200 Zeilen). Herkunfts-Filter, Organisations-Spalte,
  Checkbox-Spalte + Bulk-Promote nur bei Herkunft Kundenquellen.
- Health-Badge ist klickbar und klappt eine gemeinsame Detailzeile aus
  (Notizen + Checks mit Meldung und Zeitpunkt) mit Deaktivieren-Knopf.
  Klickweg kaputte Quelle finden und deaktivieren: 3 statt 8 bis 10 Klicks.
- Unterreiter Kundenquellen entfaellt, erster Reiter heisst Quellenliste.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-07-25 18:06:35 +00:00
Ursprung 1a08facb64
Commit bbbcc51fc2
6 geänderte Dateien mit 314 neuen und 288 gelöschten Zeilen

Datei anzeigen

@@ -144,18 +144,27 @@ class GlobalSourceUpdate(BaseModel):
alignments: Optional[list[str]] = None
@router.get("/global")
async def list_global_sources(
@router.get("")
async def list_sources(
scope: str = "all",
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Grundquellen auflisten (tenant_id IS NULL).
"""Vereinte Quellenliste (Grund- und Kundenquellen).
Liefert pro Quelle den worst-case Health-Status aus source_health_checks
(error > warning > ok > unknown). Damit kann das Frontend ein Inline-Badge
pro Zeile zeigen, ohne separate Health-Tab-Abfrage.
scope = all | global | tenant. Liefert pro Quelle den worst-case
Health-Status aus source_health_checks (error > warning > ok > unknown)
und den Organisationsnamen bei Kundenquellen (org_name, NULL bei global).
"""
cursor = await db.execute("""
scope_where = {
"all": "1=1",
"global": "s.tenant_id IS NULL",
"tenant": "s.tenant_id IS NOT NULL",
}.get(scope)
if scope_where is None:
raise HTTPException(status_code=422, detail="scope muss all, global oder tenant sein")
cursor = await db.execute(f"""
WITH article_stats AS (
-- Match per source-Name (case-insensitive). source_url im articles ist die
-- Artikel-URL, nicht die Feed-URL - daher matcht das nicht mit sources.url.
@@ -177,6 +186,7 @@ async def list_global_sources(
GROUP BY source_id
)
SELECT s.*,
o.name AS org_name,
CASE ha.rank
WHEN 3 THEN 'error'
WHEN 2 THEN 'warning'
@@ -186,16 +196,13 @@ async def list_global_sources(
COALESCE(ast.a7d, 0) AS articles_7d,
COALESCE(ast.a30d, 0) AS articles_30d
FROM sources s
LEFT JOIN organizations o ON o.id = s.tenant_id
LEFT JOIN article_stats ast ON ast.s_lower = LOWER(s.name)
LEFT JOIN health_agg ha ON ha.source_id = s.id
WHERE s.tenant_id IS NULL
WHERE {scope_where}
ORDER BY s.category, s.source_type, s.name
""")
rows = [dict(row) for row in await cursor.fetchall()]
alignments_map = await _load_alignments_for(db, [r["id"] for r in rows])
for r in rows:
r["alignments"] = alignments_map.get(r["id"], [])
return rows
return [dict(row) for row in await cursor.fetchall()]
@router.post("/global", status_code=201)
@@ -245,13 +252,17 @@ async def update_global_source(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Grundquelle bearbeiten — inkl. Klassifikation + alignments."""
"""Quelle bearbeiten (Grund- und Kundenquellen) — inkl. Klassifikation + alignments.
Kundenquellen sind absichtlich mit erlaubt, damit der Deaktivieren-Knopf
der vereinten Liste (status=inactive) fuer beide Herkuenfte funktioniert.
"""
cursor = await db.execute(
"SELECT * FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,)
"SELECT * FROM sources WHERE id = ?", (source_id,)
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
before = dict(row)
before_alignments = sorted((await _load_alignments_for(db, [source_id])).get(source_id, []))
@@ -336,31 +347,35 @@ async def delete_global_source(
@router.get("/global/languages")
async def get_global_languages(
@router.get("/languages")
async def get_languages(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Distinct language-Werte aus Grundquellen - für Frontend-Filter-Dropdown."""
"""Distinct language-Werte aller Quellen - für Frontend-Filter-Dropdown."""
cur = await db.execute("""
SELECT DISTINCT language
FROM sources
WHERE tenant_id IS NULL AND language IS NOT NULL AND language != ''
WHERE language IS NOT NULL AND language != ''
ORDER BY language
""")
return [r["language"] for r in await cur.fetchall()]
@router.get("/global/stats")
async def get_global_stats(
@router.get("/stats")
async def get_sources_stats(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Aggregierte Stats für die Grundquellen-Stats-Bar oben im Tab."""
"""Aggregierte Stats für die Stats-Bar der vereinten Quellenliste.
Zählt Grund- UND Kundenquellen. by_origin liefert die Aufteilung,
last_check den Zeitpunkt des jüngsten Health-Checks.
"""
cur = await db.execute("""
SELECT source_type, COUNT(*) AS count, COALESCE(SUM(article_count), 0) AS articles
FROM sources
WHERE tenant_id IS NULL AND status = 'active'
WHERE status = 'active'
GROUP BY source_type
""")
by_type = {}
@@ -372,8 +387,18 @@ async def get_global_stats(
total += d["count"]
total_articles += d["articles"]
# Health-Counter
cur = await db.execute("""
SELECT CASE WHEN tenant_id IS NULL THEN 'global' ELSE 'tenant' END AS origin,
COUNT(*) AS cnt
FROM sources
WHERE status = 'active'
GROUP BY origin
""")
by_origin = {dict(r)["origin"]: dict(r)["cnt"] for r in await cur.fetchall()}
# Health-Counter + Zeitpunkt des letzten Checks
health = {"errors": 0, "warnings": 0, "ok": 0}
last_check = None
cur = await db.execute("""
SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks'
""")
@@ -382,7 +407,7 @@ async def get_global_stats(
SELECT h.status AS hs, COUNT(DISTINCT h.source_id) AS cnt
FROM source_health_checks h
JOIN sources s ON s.id = h.source_id
WHERE s.tenant_id IS NULL AND s.status = 'active'
WHERE s.status = 'active'
GROUP BY h.status
""")
for r in await cur.fetchall():
@@ -393,31 +418,21 @@ async def get_global_stats(
health["warnings"] = d["cnt"]
elif d["hs"] == "ok":
health["ok"] = d["cnt"]
cur = await db.execute("SELECT MAX(checked_at) AS lc FROM source_health_checks")
row = await cur.fetchone()
if row:
last_check = dict(row)["lc"]
return {
"by_type": by_type,
"by_origin": by_origin,
"total": total,
"total_articles": total_articles,
"health": health,
"last_check": last_check,
}
@router.get("/tenant")
async def list_tenant_sources(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle tenant-spezifischen Quellen mit Org-Name auflisten."""
cursor = await db.execute("""
SELECT s.*, o.name as org_name
FROM sources s
LEFT JOIN organizations o ON o.id = s.tenant_id
WHERE s.tenant_id IS NOT NULL
ORDER BY o.name, s.category, s.name
""")
return [dict(row) for row in await cursor.fetchall()]
@router.post("/tenant/{source_id}/promote")
async def promote_to_global(
source_id: int,
@@ -799,6 +814,36 @@ async def get_health_history(
return [dict(row) for row in await cursor.fetchall()]
@router.get("/{source_id}/health")
async def get_source_health(
source_id: int,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Health-Checks einer einzelnen Quelle (für die Ausklapp-Zeile der Liste).
Sortiert error > warning > ok, damit das Wichtigste oben steht.
"""
cursor = await db.execute("SELECT id FROM sources WHERE id = ?", (source_id,))
if not await cursor.fetchone():
raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
cursor = await db.execute("""
SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks'
""")
if not await cursor.fetchone():
return {"source_id": source_id, "checks": []}
cursor = await db.execute("""
SELECT check_type, status, message, checked_at
FROM source_health_checks
WHERE source_id = ?
ORDER BY CASE status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
check_type
""", (source_id,))
return {"source_id": source_id, "checks": [dict(row) for row in await cursor.fetchall()]}
@router.get("/suggestions")
async def get_suggestions(
admin: dict = Depends(get_current_admin),