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:
@@ -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),
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren