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