diff --git a/src/routers/sources.py b/src/routers/sources.py index 46da229..3f0e319 100644 --- a/src/routers/sources.py +++ b/src/routers/sources.py @@ -856,32 +856,19 @@ class SuggestionAction(BaseModel): accept: bool -@router.put("/suggestions/{suggestion_id}") -async def update_suggestion( - suggestion_id: int, - action: SuggestionAction, - request: Request, - admin: dict = Depends(get_current_admin), - db: aiosqlite.Connection = Depends(db_dependency), -): - """Vorschlag annehmen oder ablehnen.""" +async def _apply_suggestion_decision(db: aiosqlite.Connection, suggestion: dict, accept: bool): + """Führt die Annahme/Ablehnung EINES pending-Vorschlags aus (ohne commit/Audit). + + Liefert (new_status, result_action). Wird vom Einzel- und vom + Sammel-Endpoint identisch genutzt. + """ import json as _json - cursor = await db.execute( - "SELECT * FROM source_suggestions WHERE id = ?", (suggestion_id,) - ) - suggestion = await cursor.fetchone() - if not suggestion: - raise HTTPException(status_code=404, detail="Vorschlag nicht gefunden") - - suggestion = dict(suggestion) - if suggestion["status"] != "pending": - raise HTTPException(status_code=400, detail=f"Vorschlag bereits {suggestion['status']}") - - new_status = "accepted" if action.accept else "rejected" + suggestion_id = suggestion["id"] + new_status = "accepted" if accept else "rejected" result_action = None - if action.accept: + if accept: stype = suggestion["suggestion_type"] data = _json.loads(suggestion["suggested_data"]) if suggestion["suggested_data"] else {} @@ -944,6 +931,75 @@ async def update_suggestion( "UPDATE source_suggestions SET status = ?, reviewed_at = CURRENT_TIMESTAMP WHERE id = ?", (new_status, suggestion_id), ) + return new_status, result_action + + +class BulkSuggestionAction(BaseModel): + suggestion_ids: list[int] = Field(min_length=1, max_length=1000) + accept: bool + + +@router.put("/suggestions/bulk") +async def bulk_update_suggestions( + action: BulkSuggestionAction, + request: Request, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Mehrere Vorschläge auf einmal annehmen oder ablehnen. + + Nicht mehr pendende IDs (schon bearbeitet, per Auto-Reject erledigt + oder unbekannt) werden übersprungen und gezählt, nicht als Fehler gewertet. + """ + accepted = 0 + rejected = 0 + skipped = 0 + for sid in action.suggestion_ids: + cursor = await db.execute("SELECT * FROM source_suggestions WHERE id = ?", (sid,)) + row = await cursor.fetchone() + if not row: + skipped += 1 + continue + suggestion = dict(row) + if suggestion["status"] != "pending": + skipped += 1 + continue + new_status, _ = await _apply_suggestion_decision(db, suggestion, action.accept) + if new_status == "accepted": + accepted += 1 + else: + rejected += 1 + await db.commit() + await log_action( + db, admin, get_client_ip(request), + action="update", resource_type="source", + before={"suggestion_bulk": len(action.suggestion_ids), "accept": action.accept}, + after={"accepted": accepted, "rejected": rejected, "skipped": skipped}, + ) + return {"accepted": accepted, "rejected": rejected, "skipped": skipped} + + +@router.put("/suggestions/{suggestion_id}") +async def update_suggestion( + suggestion_id: int, + action: SuggestionAction, + request: Request, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Vorschlag annehmen oder ablehnen.""" + cursor = await db.execute( + "SELECT * FROM source_suggestions WHERE id = ?", (suggestion_id,) + ) + suggestion = await cursor.fetchone() + if not suggestion: + raise HTTPException(status_code=404, detail="Vorschlag nicht gefunden") + + suggestion = dict(suggestion) + if suggestion["status"] != "pending": + raise HTTPException(status_code=400, detail=f"Vorschlag bereits {suggestion['status']}") + + new_status, result_action = await _apply_suggestion_decision(db, suggestion, action.accept) await db.commit() await log_action( db, admin, get_client_ip(request), diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 6e5faf0..005293f 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -972,7 +972,7 @@ - +
diff --git a/src/static/js/aufgaben.js b/src/static/js/aufgaben.js index 6133f24..7ea0ee5 100644 --- a/src/static/js/aufgaben.js +++ b/src/static/js/aufgaben.js @@ -4,6 +4,8 @@ let suggestionsCache = []; let healthHistoryCache = []; let classificationQueueCache = []; +// Auswahl für die Sammel-Bearbeitung der Vorschläge +let aufgabenSelected = new Set(); // Checkbox-Filter des Aufgaben-Reiters. Leeres Set = keine Einschränkung. const aufgabenFilters = { types: new Set(), prio: new Set(), conf: new Set() }; @@ -61,6 +63,7 @@ async function loadAufgaben() { ]); suggestionsCache = suggestions || []; healthHistoryCache = history || []; + aufgabenSelected.clear(); renderAufgabenSuggestions(); renderAufgabenVerlauf(); } catch (err) { @@ -115,6 +118,11 @@ function renderAufgabenSuggestions() { `; return; } + // Auswahl auf die sichtbaren (gefilterten) Zeilen begrenzen, damit nie + // etwas Unsichtbares mitbearbeitet wird + const visibleIds = new Set(pending.map((s) => s.id)); + aufgabenSelected.forEach((id) => { if (!visibleIds.has(id)) aufgabenSelected.delete(id); }); + const headCount = pending.length === allPending.length ? `${allPending.length} offen` : `${pending.length} von ${allPending.length}`; @@ -122,11 +130,16 @@ function renderAufgabenSuggestions() {

Vorschläge (${headCount})

+
+ + +
+ @@ -140,6 +153,7 @@ function renderAufgabenSuggestions() { .map( (s) => ` + @@ -157,6 +171,57 @@ function renderAufgabenSuggestions() {
Typ Titel Beschreibung
${SUGGESTION_TYPE_LABELS[s.suggestion_type] || s.suggestion_type} ${esc(s.title)} ${esc(s.description || "")}
`; + updateSuggBulkButtons(); +} + +// --- Sammel-Bearbeitung der Vorschläge --- +function toggleSuggSelectAll(checked) { + document.querySelectorAll("#aufgabenSuggestions input.sugg-select").forEach((cb) => { + cb.checked = checked; + const id = parseInt(cb.dataset.id); + if (checked) aufgabenSelected.add(id); else aufgabenSelected.delete(id); + }); + updateSuggBulkButtons(); +} + +function toggleSuggSelect(id, checked) { + id = parseInt(id); + if (checked) aufgabenSelected.add(id); else aufgabenSelected.delete(id); + const visible = document.querySelectorAll("#aufgabenSuggestions input.sugg-select").length; + const checkedVisible = document.querySelectorAll("#aufgabenSuggestions input.sugg-select:checked").length; + const all = document.getElementById("aufgabenSelectAll"); + if (all) all.checked = visible > 0 && visible === checkedVisible; + updateSuggBulkButtons(); +} + +function updateSuggBulkButtons() { + const n = aufgabenSelected.size; + const a = document.getElementById("suggBulkAcceptBtn"); + const r = document.getElementById("suggBulkRejectBtn"); + if (a) { a.disabled = n === 0; a.textContent = `Ausgewählte annehmen (${n})`; } + if (r) { r.disabled = n === 0; r.textContent = `Ausgewählte ablehnen (${n})`; } +} + +async function bulkSuggestions(accept) { + if (aufgabenSelected.size === 0) return; + const ids = Array.from(aufgabenSelected); + const text = accept + ? `Sollen ${ids.length} Vorschläge angenommen werden? Die Aktionen werden sofort ausgeführt, z.B. Quellen deaktiviert, angelegt oder URLs korrigiert.` + : `Sollen ${ids.length} Vorschläge abgelehnt werden? Sie wandern in den Verlauf. Beim nächsten Prüflauf können sie erneut vorgeschlagen werden.`; + const ok = await showConfirm(accept ? "Ausgewählte annehmen" : "Ausgewählte ablehnen", text); + if (!ok) return; + try { + const r = await API.put("/api/sources/suggestions/bulk", { suggestion_ids: ids, accept: accept }); + let msg = accept ? `${r.accepted} angenommen` : `${r.rejected} abgelehnt`; + if (accept && r.rejected) msg += `, ${r.rejected} nicht ausführbar (abgelehnt)`; + if (r.skipped) msg += `, ${r.skipped} bereits erledigt`; + showToast(msg + ".", "success"); + aufgabenSelected.clear(); + loadAufgaben(); + if (typeof loadUnifiedSources === "function") loadUnifiedSources(); + } catch (err) { + showToast("Sammel-Aktion fehlgeschlagen. " + err.message, "error"); + } } function renderAufgabenVerlauf() { diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py index 1937d30..c7ee8d3 100644 --- a/tests/test_api_smoke.py +++ b/tests/test_api_smoke.py @@ -55,6 +55,7 @@ AUTH_PROTECTED = [ ("GET", "/api/sources/health/history"), ("GET", "/api/sources/suggestions"), ("PUT", "/api/sources/suggestions/1"), + ("PUT", "/api/sources/suggestions/bulk"), ("POST", "/api/sources/health/run"), ("GET", "/api/sources/health/run-status"), ("POST", "/api/sources/health/search-fix/1"),