fix(sources): Feinschliff nach Konsistenz-Pruefung + UI-Rueckmeldungen

Echte Fehler:
- esc() maskiert jetzt auch Anfuehrungszeichen. Quellennamen mit
  Apostroph (z.B. LExpress) brachen sonst die Zeilen-Knoepfe.
- formatDateTime parst SQLite-UTC-Zeitstempel jetzt als UTC statt
  Lokalzeit (Zeiten waren um den Zeitzonen-Offset verschoben).
- Audit-Filter Bis-Datum schliesst den gewaehlten Tag jetzt komplett ein.

UI-Rueckmeldungen:
- Beschreibung in der Vorschlags-Tabelle bricht mehrzeilig um statt
  abgeschnitten zu werden.
- Bestaetigen-Knopf im Dialog ist standardmaessig Gold, Rot nur noch bei
  destruktiven Aktionen (Org/Nutzer/Quelle loeschen, X-Konto entfernen).
- Alle >= 0.85 genehmigen ist ausgegraut, solange keine Klassifikationen
  ausstehen.

Konsistenz:
- GET /suggestions deckelt pending nicht mehr auf 50 (Badge und Tabelle
  liefen auseinander), bearbeitete weiterhin letzte 50.
- /stats zaehlt jetzt alle Quellen wie die Liste (nicht nur aktive),
  Grundquellen/Kundenquellen-Aufteilung wird immer angezeigt.
- Health-Spalte sortiert nach Schweregrad statt alphabetisch.
- Aufgaben-Badge laedt schon beim Seitenstart, nicht erst beim Reiterklick.
- Loesung-suchen-Knopf stellt nach Fehler das Icon wieder her, native
  confirm()-Dialoge durch showConfirm ersetzt.
- Tote Endpoints GET /health (Alt-Liste) und GET /classification/stats
  entfernt, tote CSS-Bloecke (activity-*, badge-health-*,
  review-conf-filter) geloescht.
- Smoke-Test deckt jetzt alle Klassifikations-/Upload-/Sync-Endpoints ab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-07-25 18:48:16 +00:00
Ursprung bc3b0bb9ee
Commit c5f63b1d14
9 geänderte Dateien mit 100 neuen und 219 gelöschten Zeilen

Datei anzeigen

@@ -120,7 +120,7 @@ async def tasks_summary(
):
"""Billiger Zähler für den Aufgaben-Badge im Quellen-Reiter.
classification_pending nutzt DIESELBE Bedingung wie /classification/stats,
classification_pending nutzt DIESELBE Bedingung wie /classification/queue,
damit Badge und Review-Karten nie auseinanderlaufen.
"""
suggestions_pending = 0
@@ -403,13 +403,13 @@ async def get_sources_stats(
):
"""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.
Zählt Grund- UND Kundenquellen, aktive wie inaktive (die Liste zeigt
ebenfalls alle). 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 status = 'active'
GROUP BY source_type
""")
by_type = {}
@@ -425,7 +425,6 @@ async def get_sources_stats(
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()}
@@ -441,7 +440,6 @@ async def get_sources_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.status = 'active'
GROUP BY h.status
""")
for r in await cur.fetchall():
@@ -716,107 +714,6 @@ async def add_discovered_sources(
# --- Health-Check & Vorschläge ---
@router.get("/health")
async def get_health(
limit: int = 100,
offset: int = 0,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Health-Check-Ergebnisse abrufen.
Default-Limit 100, sortiert nach Status (errors first, dann warnings, dann ok).
Counters (errors/warnings/ok/total_checks) beziehen sich auf den GESAMTEN
Datenbestand, nicht nur auf die zurückgegebene Page. Damit kann das Frontend
den vollen Status anzeigen, ohne alle Zeilen rendern zu müssen.
has_more zeigt an, ob es weitere Items zum Nachladen gibt.
all_orgs liefert die Liste aller Tenants mit Health-Checks (für Filter-Dropdown).
"""
limit = max(1, min(int(limit or 100), 5000))
offset = max(0, int(offset or 0))
# Prüfen ob Tabelle existiert
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_health_checks'"
)
if not await cursor.fetchone():
return {
"last_check": None, "total_checks": 0,
"errors": 0, "warnings": 0, "ok": 0,
"checks": [], "all_orgs": [],
"limit": limit, "offset": offset, "has_more": False,
}
# Aggregate über GESAMTEN Bestand. Eine GROUP-BY-Query nach (check_type, status)
# liefert sowohl die Top-Counters als auch das feine Breakdown für die UI.
cursor = await db.execute(
"SELECT check_type, status, COUNT(*) AS n FROM source_health_checks GROUP BY check_type, status"
)
breakdown = {} # {check_type: {status: count}}
error_count = 0
warning_count = 0
ok_count = 0
for row in await cursor.fetchall():
ct = row["check_type"]
st = row["status"]
breakdown.setdefault(ct, {})[st] = row["n"]
if st == "error":
error_count += row["n"]
elif st == "warning":
warning_count += row["n"]
elif st == "ok":
ok_count += row["n"]
total_checks = error_count + warning_count + ok_count
# Paginierte Daten
cursor = await db.execute("""
SELECT
h.source_id, s.name, s.domain, s.tenant_id, s.language,
o.name AS org_name,
h.check_type, h.status, h.message
FROM source_health_checks h
JOIN sources s ON s.id = h.source_id
LEFT JOIN organizations o ON o.id = s.tenant_id
ORDER BY
CASE h.status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
s.name
LIMIT ? OFFSET ?
""", (limit, offset))
checks = [dict(row) for row in await cursor.fetchall()]
# Org-Liste (alle Tenants mit Health-Checks, für Frontend-Filter-Dropdown)
cursor = await db.execute("""
SELECT DISTINCT s.tenant_id AS id, o.name AS name
FROM source_health_checks h
JOIN sources s ON s.id = h.source_id
LEFT JOIN organizations o ON o.id = s.tenant_id
WHERE s.tenant_id IS NOT NULL
ORDER BY o.name
""")
all_orgs = [dict(row) for row in await cursor.fetchall()]
cursor = await db.execute("SELECT MAX(checked_at) as last_check FROM source_health_checks")
row = await cursor.fetchone()
last_check = row["last_check"] if row else None
return {
"last_check": last_check,
"total_checks": total_checks,
"errors": error_count,
"warnings": warning_count,
"ok": ok_count,
"breakdown": breakdown,
"checks": checks,
"all_orgs": all_orgs,
"limit": limit,
"offset": offset,
"has_more": (offset + len(checks)) < total_checks,
}
@router.get("/health/history")
async def get_health_history(
limit: int = 20,
@@ -929,7 +826,11 @@ async def get_suggestions(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Vorschläge abrufen (pending zuerst, dann letzte 20 bearbeitete)."""
"""Alle OFFENEN Vorschläge plus die letzten 50 bearbeiteten.
Kein Deckel auf pending, sonst liefe der Aufgaben-Badge
(tasks/summary zählt ungedeckelt) der Tabelle davon.
"""
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='source_suggestions'"
)
@@ -938,12 +839,17 @@ async def get_suggestions(
cursor = await db.execute("""
SELECT * FROM source_suggestions
ORDER BY
CASE status WHEN 'pending' THEN 0 ELSE 1 END,
created_at DESC
WHERE status = 'pending'
ORDER BY created_at DESC
""")
pending = [dict(row) for row in await cursor.fetchall()]
cursor = await db.execute("""
SELECT * FROM source_suggestions
WHERE status != 'pending'
ORDER BY created_at DESC
LIMIT 50
""")
return [dict(row) for row in await cursor.fetchall()]
return pending + [dict(row) for row in await cursor.fetchall()]
class SuggestionAction(BaseModel):
@@ -1172,31 +1078,6 @@ Nur das JSON, kein anderer Text."""
# === Klassifikations-Review (LLM-Vorschlaege approve/reject/reclassify) ===
@router.get("/classification/stats")
async def classification_stats(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Counts pro classification_source-Wert + Anzahl Pending-Reviews (alle Quellen)."""
cursor = await db.execute(
"""SELECT classification_source, COUNT(*) as cnt
FROM sources
WHERE status = 'active'
GROUP BY classification_source"""
)
by_source = {row["classification_source"] or "legacy": row["cnt"] for row in await cursor.fetchall()}
cursor = await db.execute(
"""SELECT COUNT(*) as cnt FROM sources
WHERE status = 'active' AND proposed_political_orientation IS NOT NULL"""
)
pending = (await cursor.fetchone())["cnt"]
return {
"by_classification_source": by_source,
"pending_review": pending,
"total": sum(by_source.values()),
}
@router.get("/classification/queue")
async def classification_queue(
limit: int = 50,