From 87039994ce179c97d7c41e8d3dd5b8486a3826f7 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 1 May 2026 19:44:59 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Moderation=20History=20=E2=80=94=20L?= =?UTF-8?q?og=20f=C3=BCr=20alle=204=20Bereiche,=20resolved=5Fby/at=20Migra?= =?UTF-8?q?tion,=20SW=20by-v590?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/database.py | 13 +++++ backend/routes/moderation.py | 41 ++++++++----- backend/routes/wiki.py | 11 ++-- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 99 +++++++++++++++++++++++++------- backend/static/sw.js | 2 +- 6 files changed, 125 insertions(+), 43 deletions(-) diff --git a/backend/database.py b/backend/database.py index 8ef5362..b7b5114 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1072,6 +1072,19 @@ def _migrate(conn_factory): pass logger.info("Migration: wiki_rassen Anreicherungs-Felder bereit.") + # Moderation-Logging: resolved_by/at für forum_reports, verified_by/at/reject für wiki_zuchter + for table, col, typedef in [ + ("forum_reports", "resolved_by", "INTEGER"), + ("forum_reports", "resolved_at", "TEXT"), + ("wiki_zuchter", "verified_by", "INTEGER"), + ("wiki_zuchter", "verified_at", "TEXT"), + ("wiki_zuchter", "reject_reason", "TEXT"), + ]: + try: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}") + except Exception: + pass + # Wiki: Züchter-Verzeichnis conn.executescript(""" CREATE TABLE IF NOT EXISTS wiki_zuchter ( diff --git a/backend/routes/moderation.py b/backend/routes/moderation.py index 95b33a9..1357a85 100644 --- a/backend/routes/moderation.py +++ b/backend/routes/moderation.py @@ -1,4 +1,5 @@ """BAN YARO — Moderations-Panel Backend""" +from datetime import datetime from fastapi import APIRouter, Depends, HTTPException from database import db from auth import get_current_user @@ -69,17 +70,19 @@ async def mod_stats(user=Depends(require_moderator)): async def mod_reports(user=Depends(require_moderator)): with db() as conn: rows = conn.execute(""" - SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved, r.created_at, - u.name AS melder_name, + SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved, + r.created_at, r.resolved_at, + u.name AS melder_name, + m.name AS resolved_by_name, CASE r.target_type WHEN 'thread' THEN (SELECT t.titel FROM forum_threads t WHERE t.id=r.target_id) WHEN 'post' THEN (SELECT SUBSTR(p.text,1,80) FROM forum_posts p WHERE p.id=r.target_id) END AS content_preview FROM forum_reports r LEFT JOIN users u ON u.id=r.user_id - WHERE r.resolved=0 - ORDER BY r.created_at DESC - LIMIT 100 + LEFT JOIN users m ON m.id=r.resolved_by + ORDER BY r.resolved ASC, r.created_at DESC + LIMIT 200 """).fetchall() return [dict(r) for r in rows] @@ -97,8 +100,12 @@ async def mod_resolve_report(rid: int, user=Depends(require_moderator)): raise HTTPException(404, "Meldung nicht gefunden.") new_state = 0 if r["resolved"] else 1 conn.execute( - "UPDATE forum_reports SET resolved=? WHERE id=?", - (new_state, rid) + """UPDATE forum_reports SET resolved=?, resolved_by=?, resolved_at=? + WHERE id=?""", + (new_state, + user["id"] if new_state else None, + datetime.utcnow().isoformat() if new_state else None, + rid) ) return {"ok": True} @@ -189,17 +196,19 @@ async def mod_patch_user(uid: int, data: dict, user=Depends(require_moderator)): async def mod_fotos(user=Depends(require_moderator)): with db() as conn: rows = conn.execute(""" - SELECT s.id, s.foto_url, s.created_at, + SELECT s.id, s.foto_url, s.status, s.created_at, + s.reviewed_at, s.reject_reason, COALESCE(s.rights_confirmed, 0) AS rights_confirmed, - u.name AS user_name, - r.name AS rasse_name, r.slug AS rasse_slug, + u.name AS user_name, + m.name AS reviewed_by_name, + r.name AS rasse_name, r.slug AS rasse_slug, r.foto_url AS aktuell_foto FROM wiki_foto_submissions s LEFT JOIN users u ON u.id = s.user_id + LEFT JOIN users m ON m.id = s.reviewed_by LEFT JOIN wiki_rassen r ON r.id = s.rasse_id - WHERE s.status = 'pending' - ORDER BY s.created_at ASC - LIMIT 50 + ORDER BY s.status ASC, s.created_at ASC + LIMIT 200 """).fetchall() return [dict(r) for r in rows] @@ -228,11 +237,13 @@ async def mod_poi_edits(user=Depends(require_moderator)): SELECT e.id, e.osm_id, e.poi_name, e.field, e.old_value, e.new_value, e.status, e.created_at, e.resolved_at, - u.name AS einreicher_name + u.name AS einreicher_name, + m.name AS mod_name FROM osm_poi_edits e JOIN users u ON u.id = e.user_id + LEFT JOIN users m ON m.id = e.mod_id ORDER BY e.status ASC, e.created_at DESC - LIMIT 100 + LIMIT 200 """).fetchall() return [dict(r) for r in rows] diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py index 83093d7..bf3c19c 100644 --- a/backend/routes/wiki.py +++ b/backend/routes/wiki.py @@ -694,11 +694,12 @@ async def list_zuchter_pending(user=Depends(get_current_user)): raise HTTPException(403, "Nur Moderatoren.") with db() as conn: rows = conn.execute( - """SELECT z.*, u.name AS user_name + """SELECT z.*, u.name AS user_name, m.name AS verified_by_name FROM wiki_zuchter z LEFT JOIN users u ON u.id = z.user_id - WHERE z.verified=0 - ORDER BY z.created_at ASC""", + LEFT JOIN users m ON m.id = z.verified_by + ORDER BY z.verified ASC, z.created_at ASC + LIMIT 200""", ).fetchall() return [dict(r) for r in rows] @@ -716,8 +717,10 @@ async def verify_zuchter(zuchter_id: int, user=Depends(get_current_user)): ).fetchone() if not row: raise HTTPException(404, "Züchter nicht gefunden.") + from datetime import datetime conn.execute( - "UPDATE wiki_zuchter SET verified=1 WHERE id=?", (zuchter_id,) + "UPDATE wiki_zuchter SET verified=1, verified_by=?, verified_at=? WHERE id=?", + (user["id"], datetime.utcnow().isoformat(), zuchter_id) ) result = conn.execute( "SELECT * FROM wiki_zuchter WHERE id=?", (zuchter_id,) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index bdb4e29..b145576 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '589'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '590'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 6809ee0..65a9b76 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -1460,6 +1460,30 @@ window.Page_admin = (() => { // ------------------------------------------------------------------ // TAB: MODERATION // ------------------------------------------------------------------ + function _historySection(label, items, renderItem) { + const id = `hist-${label.replace(/\W/g,'').toLowerCase()}`; + return ` +
+ + ${UI.icon('clock-countdown')} ${items.length} erledigte ${label} + + +
+ ${items.map(item => ` +
+ ${renderItem(item)} +
`).join('')} +
+
`; + } + async function _renderModeration(el) { el.innerHTML = `
@@ -1480,13 +1504,20 @@ window.Page_admin = (() => { API.get('/moderation/reports').catch(() => []), API.get('/moderation/poi-edits').catch(() => []), ]); - const poiPending = poiEdits.filter(e => e.status === 'pending'); + const zuchterPending = zuchter.filter(z => !z.verified); + const zuchterDone = zuchter.filter(z => z.verified); + const fotosPending = fotos.filter(f => f.status === 'pending'); + const fotosDone = fotos.filter(f => f.status !== 'pending'); + const reportsPending = reports.filter(r => !r.resolved); + const reportsDone = reports.filter(r => r.resolved); + const poiPending = poiEdits.filter(e => e.status === 'pending'); + const poiDone = poiEdits.filter(e => e.status !== 'pending'); const modItems = [ - { label: 'Züchter-Einreichungen', count: zuchter.length, icon: 'certificate' }, - { label: 'Foto-Einreichungen', count: fotos.length, icon: 'image' }, - { label: 'Forum-Meldungen', count: reports.length, icon: 'warning' }, - { label: 'POI-Korrekturen', count: poiPending.length, icon: 'map-pin' }, + { label: 'Züchter-Einreichungen', count: zuchterPending.length, icon: 'certificate' }, + { label: 'Foto-Einreichungen', count: fotosPending.length, icon: 'image' }, + { label: 'Forum-Meldungen', count: reportsPending.length, icon: 'warning' }, + { label: 'POI-Korrekturen', count: poiPending.length, icon: 'map-pin' }, ].filter(i => i.count > 0); let html = ` @@ -1520,18 +1551,18 @@ window.Page_admin = (() => { margin-bottom:var(--space-3)"> Züchter-Einreichungen ${zuchter.length} + padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${zuchterPending.length} `; - if (!zuchter.length) { - html += `

Keine ausstehenden Einreichungen.

`; + if (!zuchterPending.length) { + html += `

Keine ausstehenden Einreichungen.

`; } else { - html += `
+ html += `
- ${zuchter.map((z, i) => ` + ${zuchterPending.map((z, i) => ` @@ -1545,6 +1576,10 @@ window.Page_admin = (() => { `).join('')}
RasseName / Zwingername OrtVDHWebsite
${_esc(z.rasse_slug)} ${_esc(z.name)}${z.zwingername ? `
${_esc(z.zwingername)}` : ''}
`; } + // Züchter-History + if (zuchterDone.length) html += _historySection('Züchter-Einreichungen', zuchterDone, + z => `${_esc(z.name)} · ${_esc(z.rasse_slug)} · + ${UI.icon('check-circle')} ${_esc(z.verified_by_name||'?')} · ${(z.verified_at||'').slice(0,10)}`); // --- Wiki-Foto-Einreichungen --- html += `

Wiki-Foto-Einreichungen ${fotos.length} + padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotosPending.length}

`; - if (!fotos.length) { - html += `

Keine ausstehenden Foto-Einreichungen.

`; + if (!fotosPending.length) { + html += `

Keine ausstehenden Foto-Einreichungen.

`; } else { - html += `
- ${fotos.map(f => ` + html += `
+ ${fotosPending.map(f => `
@@ -1577,21 +1612,28 @@ window.Page_admin = (() => {
`; } + // Fotos-History + if (fotosDone.length) html += _historySection('Foto-Einreichungen', fotosDone, + f => ` + ${_esc(f.rasse_name||'?')} · von ${_esc(f.user_name||'?')} · + ${f.status==='approved' ? `${UI.icon('check-circle')} genehmigt` : `${UI.icon('x-circle')} abgelehnt`} + ${f.reviewed_by_name ? ` von ${_esc(f.reviewed_by_name)}` : ''} · ${(f.reviewed_at||'').slice(0,10)}`); + // --- Forum-Meldungen --- html += `

+ margin:var(--space-4) 0 var(--space-3)"> Forum-Meldungen - - ${reports.length} + ${reportsPending.length}

`; - if (!reports.length) { - html += `

Keine offenen Meldungen.

`; + if (!reportsPending.length) { + html += `

Keine offenen Meldungen.

`; } else { - html += `
- ${reports.map(r => ` + html += `
+ ${reportsPending.map(r => `
@@ -1614,6 +1656,11 @@ window.Page_admin = (() => {
`; } + // Meldungen-History + if (reportsDone.length) html += _historySection('Forum-Meldungen', reportsDone, + r => `${_esc(r.target_type)} #${r.target_id} · ${_esc(r.grund)} · Gemeldet von ${_esc(r.melder_name||'?')} · + ${UI.icon('check-circle')} ${_esc(r.resolved_by_name||'?')} · ${(r.resolved_at||'').slice(0,10)}`); + // --- POI-Korrekturen --- html += `

${_esc(e.poi_name||`OSM #${e.osm_id}`)} · + ${_esc(e.field)}: + ${_esc(e.old_value||'—')} → + ${_esc(e.new_value||'—')} · + ${e.status==='approved' ? `${UI.icon('check-circle')} freigegeben` : `${UI.icon('x-circle')} abgelehnt`} + ${e.mod_name ? ` von ${_esc(e.mod_name)}` : ''} · ${(e.resolved_at||'').slice(0,10)}`); el.innerHTML = html; diff --git a/backend/static/sw.js b/backend/static/sw.js index 48d8c56..2f97913 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v589'; +const CACHE_VERSION = 'by-v590'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache