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() {| Typ | Titel | Beschreibung | @@ -140,6 +153,7 @@ function renderAufgabenSuggestions() { .map( (s) => `|
|---|---|---|---|
| ${SUGGESTION_TYPE_LABELS[s.suggestion_type] || s.suggestion_type} | ${esc(s.title)} | ${esc(s.description || "")} | @@ -157,6 +171,57 @@ function renderAufgabenSuggestions() {