feat(sources): Sammel-Bearbeitung fuer Vorschlaege im Aufgaben-Reiter
Die Vorschlags-Tabelle hat jetzt eine Checkbox-Spalte mit Alle-auswaehlen (wirkt auf die gefilterten Zeilen) und zwei Knoepfe Ausgewaehlte annehmen / Ausgewaehlte ablehnen. Noetig geworden, weil ein Pruefaluf hunderte Vorschlaege erzeugen kann und Einzelklicks mit Dialog dann nicht praktikabel sind. Neuer Endpoint PUT /suggestions/bulk nutzt exakt dieselbe Entscheidungslogik wie der Einzel-Endpoint (gemeinsamer Helper _apply_suggestion_decision, inkl. Auto-Reject und Duplikat-Schutz), bereits erledigte IDs werden uebersprungen statt als Fehler gewertet. Ein Audit-Eintrag pro Sammelaktion mit Zaehlern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -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),
|
||||
|
||||
@@ -972,7 +972,7 @@
|
||||
<script src="/static/js/a11y.js?v=20260725a"></script>
|
||||
<script src="/static/js/app.js?v=20260725v"></script>
|
||||
<script src="/static/js/sources.js?v=20260725f"></script>
|
||||
<script src="/static/js/aufgaben.js?v=20260725c"></script>
|
||||
<script src="/static/js/aufgaben.js?v=20260725d"></script>
|
||||
<script src="/static/js/x-scraper.js?v=20260725a"></script>
|
||||
<script src="/static/js/audit.js?v=20260725a"></script>
|
||||
<div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
@@ -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() {
|
||||
</div>`;
|
||||
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() {
|
||||
<div class="card" style="margin-bottom:16px;">
|
||||
<div class="card-header">
|
||||
<h2>Vorschläge (${headCount})</h2>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="btn btn-primary btn-small" id="suggBulkAcceptBtn" onclick="bulkSuggestions(true)" disabled>Ausgewählte annehmen (0)</button>
|
||||
<button class="btn btn-secondary btn-small" id="suggBulkRejectBtn" onclick="bulkSuggestions(false)" disabled>Ausgewählte ablehnen (0)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:34px;"><input type="checkbox" id="aufgabenSelectAll" onchange="toggleSuggSelectAll(this.checked)" title="Alle angezeigten auswählen"></th>
|
||||
<th>Typ</th>
|
||||
<th>Titel</th>
|
||||
<th>Beschreibung</th>
|
||||
@@ -140,6 +153,7 @@ function renderAufgabenSuggestions() {
|
||||
.map(
|
||||
(s) => `
|
||||
<tr>
|
||||
<td><input type="checkbox" class="sugg-select" data-id="${s.id}" ${aufgabenSelected.has(s.id) ? "checked" : ""} onchange="toggleSuggSelect(${s.id}, this.checked)"></td>
|
||||
<td><span class="badge badge-suggestion-${s.suggestion_type}">${SUGGESTION_TYPE_LABELS[s.suggestion_type] || s.suggestion_type}</span></td>
|
||||
<td>${esc(s.title)}</td>
|
||||
<td class="text-secondary" style="max-width:420px; white-space:normal; overflow-wrap:anywhere;">${esc(s.description || "")}</td>
|
||||
@@ -157,6 +171,57 @@ function renderAufgabenSuggestions() {
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
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() {
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren