Promote develop → main (2026-07-25 22:22 UTC) #16

Zusammengeführt
IntelSight_Admin hat 41 Commits von develop nach main 2026-07-26 00:22:10 +02:00 zusammengeführt
4 geänderte Dateien mit 145 neuen und 23 gelöschten Zeilen
Nur Änderungen aus Commit ae4fb106ad werden angezeigt - Alle Commits anzeigen

Datei anzeigen

@@ -856,32 +856,19 @@ class SuggestionAction(BaseModel):
accept: bool accept: bool
@router.put("/suggestions/{suggestion_id}") async def _apply_suggestion_decision(db: aiosqlite.Connection, suggestion: dict, accept: bool):
async def update_suggestion( """Führt die Annahme/Ablehnung EINES pending-Vorschlags aus (ohne commit/Audit).
suggestion_id: int,
action: SuggestionAction, Liefert (new_status, result_action). Wird vom Einzel- und vom
request: Request, Sammel-Endpoint identisch genutzt.
admin: dict = Depends(get_current_admin), """
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Vorschlag annehmen oder ablehnen."""
import json as _json import json as _json
cursor = await db.execute( suggestion_id = suggestion["id"]
"SELECT * FROM source_suggestions WHERE id = ?", (suggestion_id,) new_status = "accepted" if accept else "rejected"
)
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"
result_action = None result_action = None
if action.accept: if accept:
stype = suggestion["suggestion_type"] stype = suggestion["suggestion_type"]
data = _json.loads(suggestion["suggested_data"]) if suggestion["suggested_data"] else {} 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 = ?", "UPDATE source_suggestions SET status = ?, reviewed_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_status, suggestion_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 db.commit()
await log_action( await log_action(
db, admin, get_client_ip(request), db, admin, get_client_ip(request),

Datei anzeigen

@@ -972,7 +972,7 @@
<script src="/static/js/a11y.js?v=20260725a"></script> <script src="/static/js/a11y.js?v=20260725a"></script>
<script src="/static/js/app.js?v=20260725v"></script> <script src="/static/js/app.js?v=20260725v"></script>
<script src="/static/js/sources.js?v=20260725f"></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/x-scraper.js?v=20260725a"></script>
<script src="/static/js/audit.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> <div id="toastContainer" class="toast-container" aria-live="polite" aria-atomic="true"></div>

Datei anzeigen

@@ -4,6 +4,8 @@
let suggestionsCache = []; let suggestionsCache = [];
let healthHistoryCache = []; let healthHistoryCache = [];
let classificationQueueCache = []; 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. // Checkbox-Filter des Aufgaben-Reiters. Leeres Set = keine Einschränkung.
const aufgabenFilters = { types: new Set(), prio: new Set(), conf: new Set() }; const aufgabenFilters = { types: new Set(), prio: new Set(), conf: new Set() };
@@ -61,6 +63,7 @@ async function loadAufgaben() {
]); ]);
suggestionsCache = suggestions || []; suggestionsCache = suggestions || [];
healthHistoryCache = history || []; healthHistoryCache = history || [];
aufgabenSelected.clear();
renderAufgabenSuggestions(); renderAufgabenSuggestions();
renderAufgabenVerlauf(); renderAufgabenVerlauf();
} catch (err) { } catch (err) {
@@ -115,6 +118,11 @@ function renderAufgabenSuggestions() {
</div>`; </div>`;
return; 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 const headCount = pending.length === allPending.length
? `${allPending.length} offen` ? `${allPending.length} offen`
: `${pending.length} von ${allPending.length}`; : `${pending.length} von ${allPending.length}`;
@@ -122,11 +130,16 @@ function renderAufgabenSuggestions() {
<div class="card" style="margin-bottom:16px;"> <div class="card" style="margin-bottom:16px;">
<div class="card-header"> <div class="card-header">
<h2>Vorschläge (${headCount})</h2> <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>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
<th style="width:34px;"><input type="checkbox" id="aufgabenSelectAll" onchange="toggleSuggSelectAll(this.checked)" title="Alle angezeigten auswählen"></th>
<th>Typ</th> <th>Typ</th>
<th>Titel</th> <th>Titel</th>
<th>Beschreibung</th> <th>Beschreibung</th>
@@ -140,6 +153,7 @@ function renderAufgabenSuggestions() {
.map( .map(
(s) => ` (s) => `
<tr> <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><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>${esc(s.title)}</td>
<td class="text-secondary" style="max-width:420px; white-space:normal; overflow-wrap:anywhere;">${esc(s.description || "")}</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> </table>
</div> </div>
</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() { function renderAufgabenVerlauf() {

Datei anzeigen

@@ -55,6 +55,7 @@ AUTH_PROTECTED = [
("GET", "/api/sources/health/history"), ("GET", "/api/sources/health/history"),
("GET", "/api/sources/suggestions"), ("GET", "/api/sources/suggestions"),
("PUT", "/api/sources/suggestions/1"), ("PUT", "/api/sources/suggestions/1"),
("PUT", "/api/sources/suggestions/bulk"),
("POST", "/api/sources/health/run"), ("POST", "/api/sources/health/run"),
("GET", "/api/sources/health/run-status"), ("GET", "/api/sources/health/run-status"),
("POST", "/api/sources/health/search-fix/1"), ("POST", "/api/sources/health/search-fix/1"),