Feature: Moderation SLA — Altersanzeige + Overdue-Alarm täglich 12:00, SW by-v591

This commit is contained in:
rene 2026-05-01 19:49:02 +02:00
parent 87039994ce
commit d00284184b
4 changed files with 113 additions and 6 deletions

View file

@ -100,6 +100,14 @@ def start():
replace_existing=True, replace_existing=True,
misfire_grace_time=1800, misfire_grace_time=1800,
) )
# Täglich 12:00 — Moderation-Overdue-Check
_scheduler.add_job(
_job_moderation_overdue,
CronTrigger(hour=12, minute=0),
id="moderation_overdue",
replace_existing=True,
misfire_grace_time=1800,
)
# 1. Feb / Mai / Aug / Nov 07:00 — Quartalsbericht # 1. Feb / Mai / Aug / Nov 07:00 — Quartalsbericht
_scheduler.add_job( _scheduler.add_job(
_job_quarterly_report, _job_quarterly_report,
@ -117,7 +125,7 @@ def start():
misfire_grace_time=3600, misfire_grace_time=3600,
) )
_scheduler.start() _scheduler.start()
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00. OSM-Cache: on-demand (kein Prewarm).") logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00. OSM-Cache: on-demand (kein Prewarm).")
def stop(): def stop():
@ -650,6 +658,87 @@ async def _job_ki_health_report():
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def _job_moderation_overdue():
"""Sendet Alarm-Mail wenn Moderations-Einträge seit >24h offen sind."""
import os
from mailer import send_email
admin = os.getenv("ADMIN_EMAIL", "")
if not admin:
return
SLA_H = 24
threshold = f"datetime('now', '-{SLA_H} hours')"
overdue = {}
try:
with db() as conn:
n = conn.execute(f"SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing') AND created_at < {threshold}").fetchone()[0]
if n: overdue["Bewerbungen"] = n
n = conn.execute(f"SELECT COUNT(*) FROM users WHERE breeder_status='pending' AND created_at < {threshold}").fetchone()[0]
if n: overdue["Züchter-Anträge"] = n
n = conn.execute(f"SELECT COUNT(*) FROM forum_reports WHERE resolved=0 AND created_at < {threshold}").fetchone()[0]
if n: overdue["Forum-Meldungen"] = n
n = conn.execute(f"SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending' AND created_at < {threshold}").fetchone()[0]
if n: overdue["Foto-Einreichungen"] = n
n = conn.execute(f"SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending' AND created_at < {threshold}").fetchone()[0]
if n: overdue["POI-Korrekturen"] = n
n = conn.execute(f"SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0 AND created_at < {threshold}").fetchone()[0]
if n: overdue["Züchter-Einreichungen (Wiki)"] = n
except Exception as e:
logger.error(f"Moderation-Overdue-Check: DB-Fehler: {e}")
return
if not overdue:
logger.info("Moderation-Overdue-Check: Alles im SLA.")
return
now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y %H:%M")
rows_html = "".join(
f'<tr><td style="padding:6px 12px;font-weight:600;color:#c45000">{label}</td>'
f'<td style="padding:6px 12px;font-size:18px;font-weight:800;color:#c45000">{count}</td></tr>'
for label, count in overdue.items()
)
html = f"""\
<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f0ea;margin:0;padding:0">
<div style="max-width:560px;margin:24px auto;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.08)">
<div style="background:linear-gradient(135deg,#c45000,#e8733a);padding:22px 28px;color:#fff">
<div style="font-size:20px;font-weight:800;margin-bottom:2px"> Moderation überfällig</div>
<div style="opacity:.88;font-size:13px">{now_str} · SLA: {SLA_H}h</div>
</div>
<div style="padding:22px 28px">
<p style="color:#444;margin:0 0 16px">Folgende Einträge warten seit mehr als {SLA_H} Stunden auf Bearbeitung:</p>
<table style="width:100%;border-collapse:collapse;font-size:14px">
<thead><tr style="border-bottom:2px solid #f0e8dc">
<th style="text-align:left;padding:6px 12px;color:#888;font-size:11px;text-transform:uppercase;letter-spacing:.06em">Bereich</th>
<th style="text-align:left;padding:6px 12px;color:#888;font-size:11px;text-transform:uppercase;letter-spacing:.06em">Anzahl</th>
</tr></thead>
<tbody>{rows_html}</tbody>
</table>
<div style="margin-top:20px">
<a href="https://banyaro.app/app/admin" style="display:inline-block;background:#c45000;color:#fff;
text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:700;font-size:14px">
Admin-Panel öffnen
</a>
</div>
</div>
<div style="padding:12px 28px;background:#fdf6ef;font-size:11px;color:#aaa;text-align:center">
Ban Yaro · banyaro.app
</div>
</div></body></html>"""
plain = f"Ban Yaro — Moderation überfällig ({now_str})\n\nSeit >{SLA_H}h offen:\n" + \
"\n".join(f"{l}: {c}" for l, c in overdue.items()) + \
"\n\nhttps://banyaro.app/app/admin"
try:
await send_email(admin, f"⚠️ Ban Yaro — Moderation überfällig ({', '.join(overdue)})", html, plain)
logger.info(f"Moderation-Overdue-Mail gesendet: {overdue}")
except Exception as e:
logger.error(f"Moderation-Overdue-Mail fehlgeschlagen: {e}")
def _action_items_html(metrics: dict) -> str: def _action_items_html(metrics: dict) -> str:
items = [ items = [
("jobs_pending", "Bewerbungen offen"), ("jobs_pending", "Bewerbungen offen"),

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '590'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '591'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app'; const IS_STAGING = location.hostname === 'staging.banyaro.app';

View file

@ -1460,6 +1460,19 @@ window.Page_admin = (() => {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// TAB: MODERATION // TAB: MODERATION
// ------------------------------------------------------------------ // ------------------------------------------------------------------
function _ageLabel(createdAt) {
if (!createdAt) return '';
const h = (Date.now() - new Date(createdAt + 'Z').getTime()) / 3600000;
const overdue = h >= 24;
const label = h < 1 ? '<1h' : h < 24 ? `${Math.floor(h)}h` : `${Math.floor(h/24)}d ${Math.floor(h%24)}h`;
return `<span style="font-size:var(--text-xs);font-weight:700;padding:1px 7px;border-radius:999px;
margin-left:6px;${overdue
? 'background:#fef2f2;color:#dc2626;border:1px solid #fca5a5'
: 'background:var(--c-surface-2);color:var(--c-text-muted);border:1px solid var(--c-border)'}">
${overdue ? '⚠️ ' : ''}${label}
</span>`;
}
function _historySection(label, items, renderItem) { function _historySection(label, items, renderItem) {
const id = `hist-${label.replace(/\W/g,'').toLowerCase()}`; const id = `hist-${label.replace(/\W/g,'').toLowerCase()}`;
return ` return `
@ -1560,7 +1573,7 @@ window.Page_admin = (() => {
html += `<div class="card adm-table-card" style="margin-bottom:var(--space-3)"><div class="adm-table-scroll"><table class="adm-table"> html += `<div class="card adm-table-card" style="margin-bottom:var(--space-3)"><div class="adm-table-scroll"><table class="adm-table">
<thead><tr style="background:var(--c-surface-2);text-align:left"> <thead><tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Rasse</th><th class="adm-th">Name / Zwingername</th> <th class="adm-th">Rasse</th><th class="adm-th">Name / Zwingername</th>
<th class="adm-th">Ort</th><th class="adm-th">VDH</th><th class="adm-th">Website</th><th class="adm-th"></th> <th class="adm-th">Ort</th><th class="adm-th">VDH</th><th class="adm-th">Alter</th><th class="adm-th">Website</th><th class="adm-th"></th>
</tr></thead><tbody> </tr></thead><tbody>
${zuchterPending.map((z, i) => ` ${zuchterPending.map((z, i) => `
<tr style="${i%2===1?'background:var(--c-surface-2)':''}"> <tr style="${i%2===1?'background:var(--c-surface-2)':''}">
@ -1568,6 +1581,7 @@ window.Page_admin = (() => {
<td class="adm-td">${_esc(z.name)}${z.zwingername ? `<br><span style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(z.zwingername)}</span>` : ''}</td> <td class="adm-td">${_esc(z.name)}${z.zwingername ? `<br><span style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(z.zwingername)}</span>` : ''}</td>
<td class="adm-td">${_esc([z.plz, z.ort, z.bundesland].filter(Boolean).join(' '))}</td> <td class="adm-td">${_esc([z.plz, z.ort, z.bundesland].filter(Boolean).join(' '))}</td>
<td class="adm-td">${z.vdh_mitglied ? `<span style="color:var(--c-success);display:flex;align-items:center;gap:2px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> VDH</span>` : '—'}</td> <td class="adm-td">${z.vdh_mitglied ? `<span style="color:var(--c-success);display:flex;align-items:center;gap:2px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> VDH</span>` : '—'}</td>
<td class="adm-td">${_ageLabel(z.created_at)}</td>
<td class="adm-td">${z.website ? `<a href="${_esc(z.website)}" target="_blank" style="color:var(--c-primary);font-size:var(--text-xs)">Link</a>` : '—'}</td> <td class="adm-td">${z.website ? `<a href="${_esc(z.website)}" target="_blank" style="color:var(--c-primary);font-size:var(--text-xs)">Link</a>` : '—'}</td>
<td class="adm-td" style="text-align:right;white-space:nowrap"> <td class="adm-td" style="text-align:right;white-space:nowrap">
<button class="btn btn-sm btn-primary adm-zuchter-approve" data-id="${z.id}" style="margin-right:4px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Freigeben</button> <button class="btn btn-sm btn-primary adm-zuchter-approve" data-id="${z.id}" style="margin-right:4px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Freigeben</button>
@ -1599,7 +1613,8 @@ window.Page_admin = (() => {
<img src="${_esc(f.foto_url)}" alt="" <img src="${_esc(f.foto_url)}" alt=""
style="width:100%;height:140px;object-fit:cover;border-radius:var(--radius-md);margin-bottom:var(--space-3)"> style="width:100%;height:140px;object-fit:cover;border-radius:var(--radius-md);margin-bottom:var(--space-3)">
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm)">${_esc(f.rasse_name)}</div> <div style="font-weight:var(--weight-semibold);font-size:var(--text-sm)">${_esc(f.rasse_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-3)">von ${_esc(f.user_name)}</div> <div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">von ${_esc(f.user_name)}</div>
<div style="margin-bottom:var(--space-3)">${_ageLabel(f.created_at)}</div>
${f.aktuell_foto ? `<img src="${_esc(f.aktuell_foto)}" alt="Aktuell" ${f.aktuell_foto ? `<img src="${_esc(f.aktuell_foto)}" alt="Aktuell"
style="width:100%;height:80px;object-fit:cover;border-radius:var(--radius-sm); style="width:100%;height:80px;object-fit:cover;border-radius:var(--radius-sm);
opacity:.5;margin-bottom:var(--space-2)"> opacity:.5;margin-bottom:var(--space-2)">
@ -1637,8 +1652,9 @@ window.Page_admin = (() => {
<div class="card" style="padding:var(--space-4);border-left:3px solid var(--c-danger)"> <div class="card" style="padding:var(--space-4);border-left:3px solid var(--c-danger)">
<div style="display:flex;align-items:flex-start;gap:var(--space-3)"> <div style="display:flex;align-items:flex-start;gap:var(--space-3)">
<div style="flex:1;min-width:0"> <div style="flex:1;min-width:0">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-1)"> <div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-1);display:flex;align-items:center;flex-wrap:wrap;gap:4px">
${_esc(r.target_type)} #${r.target_id} · Gemeldet von <strong>${_esc(r.melder_name || '?')}</strong> ${_esc(r.target_type)} #${r.target_id} · Gemeldet von <strong>${_esc(r.melder_name || '?')}</strong>
${_ageLabel(r.created_at)}
</div> </div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);margin-bottom:var(--space-1)"> <div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);margin-bottom:var(--space-1)">
Grund: ${_esc(r.grund)} Grund: ${_esc(r.grund)}
@ -1682,6 +1698,7 @@ window.Page_admin = (() => {
<th class="adm-th">Alt</th> <th class="adm-th">Alt</th>
<th class="adm-th">Neu</th> <th class="adm-th">Neu</th>
<th class="adm-th">Von</th> <th class="adm-th">Von</th>
<th class="adm-th">Alter</th>
<th class="adm-th"></th> <th class="adm-th"></th>
</tr></thead> </tr></thead>
<tbody> <tbody>
@ -1692,6 +1709,7 @@ window.Page_admin = (() => {
<td class="adm-td" style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(e.old_value || '—')}</td> <td class="adm-td" style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(e.old_value || '—')}</td>
<td class="adm-td" style="font-size:var(--text-xs)">${_esc(e.new_value || '—')}</td> <td class="adm-td" style="font-size:var(--text-xs)">${_esc(e.new_value || '—')}</td>
<td class="adm-td" style="color:var(--c-text-muted)">${_esc(e.einreicher_name || '?')}</td> <td class="adm-td" style="color:var(--c-text-muted)">${_esc(e.einreicher_name || '?')}</td>
<td class="adm-td">${_ageLabel(e.created_at)}</td>
<td class="adm-td" style="text-align:right;white-space:nowrap"> <td class="adm-td" style="text-align:right;white-space:nowrap">
<button class="btn btn-sm btn-primary adm-poi-approve" data-id="${e.id}" style="margin-right:4px"> <button class="btn btn-sm btn-primary adm-poi-approve" data-id="${e.id}" style="margin-right:4px">
${UI.icon('check')} ${UI.icon('check')}

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v590'; const CACHE_VERSION = 'by-v591';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache