fix(P-13): leserliche Journals, readOnly-Felder, Meldungs-Badge auf Medienkacheln
Some checks failed
Deploy Development / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Failing after 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 10s
Test Suite / playwright-tests (push) Successful in 56s

- ReportContentModal: Name/E-Mail readOnly fuer eingeloggte Nutzer
- MediaLibraryPage: content_report_filed im Journal leserlich (Meldegrund, Prioritaet, Status DE)
- MediaLibraryPage: Badge auf Medienkacheln zeigt offene Meldungsanzahl (nur Admins)
- media_assets.py: open_report_count Subquery fuer Admin-Sicht in Listendaten
- Inbox-500-Fix: ma.media_kind -> ma.mime_type (alle 3 Stellen)
- PATCH content_reports: Statuswechsel wird in Audit-Log protokolliert
- E-Mails: Dateiname statt Medium-ID, lesbarer Inhalt

version: 0.8.91

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-11 21:16:21 +02:00
parent 8dd748e7d9
commit bb4d927090
6 changed files with 151 additions and 23 deletions

View File

@ -341,32 +341,33 @@ def _notify_report_submitted(
priority: str,
target_type: str,
target_id: int,
target_label: str,
admin_emails: list[str],
) -> None:
"""Sendet Bestaetigungs-Mail an Melder + Benachrichtigung an alle Plattform-Admins (best-effort)."""
app_url = (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/")
reason_label = REASON_LABELS_DE.get(reason, reason)
target_label = ("Medium" if target_type == "media_asset" else "Übung") + f" #{target_id}"
prio_label = "DRINGEND" if priority == "high" else "normal"
# Bestaetigungs-Mail an Melder
confirmation_body = (
f"Hallo {reporter_name},\n\n"
f"Ihre Meldung (#{report_id}) wurde entgegengenommen.\n\n"
f"Meldegrund: {reason_label}\n"
f"Meldegrund: {reason_label}\n"
f"Gemeldeter Inhalt: {target_label}\n\n"
f"Ein Administrator wird Ihre Meldung zeitnah prüfen.\n\n"
f"Shinkan Jinkendo"
f"Ein Administrator wird Ihre Meldung zeitnah prüfen. Sie müssen nichts weiter tun.\n\n"
f"Shinkan Jinkendo\n"
f"{app_url}"
)
_send_email(reporter_email, f"Meldung #{report_id} eingegangen Shinkan Jinkendo", confirmation_body)
# Admin-Benachrichtigung
prio_label = "DRINGEND" if priority == "high" else "normal"
admin_body = (
f"Neue Inhaltsmeldung #{report_id} [{prio_label}]\n\n"
f"Meldegrund: {reason_label}\n"
f"Ziel: {target_label}\n"
f"Gemeldet von: {reporter_name} <{reporter_email}>\n\n"
f"Zur Inbox: {app_url}/inbox\n"
f"Meldegrund: {reason_label}\n"
f"Gemeldeter Inhalt: {target_label}\n"
f"Gemeldet von: {reporter_name} <{reporter_email}>\n\n"
f"Posteingang öffnen: {app_url}/inbox\n"
)
subject = f"Inhaltsmeldung #{report_id} [{prio_label}] Shinkan Jinkendo"
for email in admin_emails:
@ -456,6 +457,24 @@ def submit_content_report(
result = r2d(row)
report_id = int(result["id"])
# Dateinamen / Zielbezeichnung fuer E-Mail abfragen
target_label_for_email = f"{'Medium' if body.target_type == 'media_asset' else 'Übung'} #{body.target_id}"
if body.target_type == "media_asset":
cur.execute("SELECT original_filename FROM media_assets WHERE id = %s", (body.target_id,))
fn_row = cur.fetchone()
if fn_row:
fn = r2d(fn_row).get("original_filename") or ""
if fn:
target_label_for_email = f"{fn} (Medium #{body.target_id})"
elif body.target_type == "exercise":
cur.execute("SELECT name FROM exercises WHERE id = %s", (body.target_id,))
ex_row = cur.fetchone()
if ex_row:
ex_name = r2d(ex_row).get("name") or ""
if ex_name:
target_label_for_email = f"{ex_name} (Übung #{body.target_id})"
# Audit-Log-Eintrag (nur fuer media_asset-Meldungen)
if body.target_type == "media_asset":
write_audit_log_entry(
@ -466,9 +485,8 @@ def submit_content_report(
{},
{
"content_report_id": report_id,
"report_reason": body.report_reason,
"priority": priority,
"reporter_email": body.reporter_email,
"report_reason": REASON_LABELS_DE.get(body.report_reason, body.report_reason),
"priority": "hoch" if priority == "high" else "normal",
},
)
@ -486,6 +504,7 @@ def submit_content_report(
priority=priority,
target_type=body.target_type,
target_id=body.target_id,
target_label=target_label_for_email,
admin_emails=admin_emails,
)
@ -539,7 +558,7 @@ def list_inbox_content_reports(
cr.updated_at,
ma.original_filename AS target_filename,
ma.visibility AS target_visibility,
ma.media_kind AS target_media_kind,
ma.mime_type AS target_mime_type,
ma.legal_hold_active AS target_legal_hold_active,
ex.name AS target_exercise_name,
rev.name AS reviewed_by_name,
@ -596,7 +615,7 @@ def list_inbox_content_reports(
cr.updated_at,
ma.original_filename AS target_filename,
ma.visibility AS target_visibility,
ma.media_kind AS target_media_kind,
ma.mime_type AS target_mime_type,
ma.legal_hold_active AS target_legal_hold_active,
ex.name AS target_exercise_name,
rev.name AS reviewed_by_name,
@ -643,7 +662,7 @@ def get_content_report(
cr.*,
ma.original_filename AS target_filename,
ma.visibility AS target_visibility,
ma.media_kind AS target_media_kind,
ma.mime_type AS target_mime_type,
ma.legal_hold_active AS target_legal_hold_active,
ma.legal_hold_reason_code AS target_legal_hold_reason_code,
ex.name AS target_exercise_name,
@ -686,12 +705,25 @@ def patch_content_report(
detail="Bei Abschluss ohne Massnahme ist eine Begründung (resolution_note) Pflicht.",
)
STATUS_LABELS_DE = {
"submitted": "Eingegangen",
"under_review": "In Bearbeitung",
"resolved_no_action": "Abgeschlossen (kein Handlungsbedarf)",
"resolved_legal_hold": "Abgeschlossen (Legal Hold)",
"rejected_invalid": "Abgewiesen (ungültig)",
}
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id, status FROM content_reports WHERE id = %s", (report_id,))
cur.execute(
"SELECT id, status, target_type, target_id FROM content_reports WHERE id = %s",
(report_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
old_report = r2d(row)
old_status = old_report["status"]
updates = ["updated_at = NOW()"]
params = []
@ -721,6 +753,24 @@ def patch_content_report(
params,
)
updated = r2d(cur.fetchone())
new_status = updated["status"]
# Audit-Log-Eintrag bei Status-Aenderung auf media_asset-Meldungen
if body.status is not None and old_status != new_status and old_report["target_type"] == "media_asset":
resolution = (body.resolution_note or "").strip() or None
write_audit_log_entry(
cur,
int(old_report["target_id"]),
pid,
"content_report_filed",
{"status": STATUS_LABELS_DE.get(old_status, old_status)},
{
"content_report_id": report_id,
"status": STATUS_LABELS_DE.get(new_status, new_status),
**({"begründung": resolution} if resolution else {}),
},
)
conn.commit()
return {"ok": True, "id": updated["id"], "status": updated["status"]}

View File

@ -1168,10 +1168,26 @@ def list_media_assets(
show_club = sup or is_adm or bool(admin_club_ids)
asset_ids = [int(r["id"]) for r in rows]
usage_map = _usage_for_media_assets(cur, asset_ids)
# open_report_count: nur für Admins relevant; immer befüllen (0 für normale Nutzer ok)
report_count_map: dict[int, int] = {}
if asset_ids and (is_adm or bool(admin_club_ids)):
cur.execute(
"""SELECT target_id, COUNT(*) AS cnt
FROM content_reports
WHERE target_type = 'media_asset'
AND target_id = ANY(%s)
AND status IN ('submitted', 'under_review')
GROUP BY target_id""",
(asset_ids,),
)
for row in cur.fetchall():
rd = r2d(row)
report_count_map[int(rd["target_id"])] = int(rd["cnt"])
for r in rows:
r["permissions"] = _item_permissions(r, tenant, admin_club_ids)
tid = int(r["id"])
r["usage"] = usage_map.get(tid, {"exercises": [], "training_units": []})
r["open_report_count"] = report_count_map.get(tid, 0)
tags_val = r.get("tags")
if tags_val is None:
r["tags"] = []

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.90"
APP_VERSION = "0.8.91"
BUILD_DATE = "2026-05-11"
DB_SCHEMA_VERSION = "20260511053"
@ -15,7 +15,7 @@ MODULE_VERSIONS = {
"admin_users": "1.0.0", # GET /api/admin/users
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
"media_rights": "1.3.1", # acting_profile_id in write_audit_log_entry auf Optional[int] (P-13 anonyme Meldungen)
"media_assets": "1.18.0", # P-11: Legal-Hold nur fuer Superadmin sichtbar (nicht fuer alle Plattform-Admins)
"media_assets": "1.18.1", # P-13: open_report_count in Listendaten (fuer Admins)
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
"groups": "0.1.0",
@ -34,6 +34,16 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.91",
"date": "2026-05-11",
"changes": [
"Fix P-13: Name- und E-Mail-Felder im Melde-Dialog fuer eingeloggte Nutzer nicht mehr bearbeitbar (readOnly).",
"Fix P-13: Journaleintraege fuer content_report_filed leserlich (Meldegrund, Prioritaet, Status auf Deutsch).",
"Fix P-13: Badge auf Medienkacheln zeigt Anzahl offener Meldungen (nur fuer Admins).",
"Fix P-13: Statuswechsel an Meldungen werden im Journal des Mediums protokolliert.",
],
},
{
"version": "0.8.90",
"date": "2026-05-11",

View File

@ -143,7 +143,9 @@ export default function ReportContentModal({ targetType, targetId, targetLabel,
type="text"
className="form-input"
value={name}
onChange={(e) => setName(e.target.value)}
onChange={user?.name ? undefined : (e) => setName(e.target.value)}
readOnly={!!user?.name}
style={user?.name ? { background: 'var(--surface2)', color: 'var(--text2)' } : undefined}
required
/>
</div>
@ -154,7 +156,9 @@ export default function ReportContentModal({ targetType, targetId, targetLabel,
type="email"
className="form-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
onChange={user?.email ? undefined : (e) => setEmail(e.target.value)}
readOnly={!!user?.email}
style={user?.email ? { background: 'var(--surface2)', color: 'var(--text2)' } : undefined}
required
/>
</div>

View File

@ -254,6 +254,7 @@ function eventTypeLabel(et) {
lifecycle_change: 'Lifecycle geändert',
legal_hold_set: 'Sofortsperre gesetzt',
legal_hold_released: 'Sofortsperre aufgehoben',
content_report_filed: 'Inhaltsmeldung',
}
return MAP[et] || et
}
@ -978,6 +979,20 @@ export default function MediaLibraryPage() {
title="Altbestand Rechtserklärung nach neuem Standard (P-06) noch nicht erfasst"
>Altbestand </span>
)}
{it.open_report_count > 0 && (
<span
style={{
fontSize: '0.7rem',
background: 'var(--danger)',
color: '#fff',
borderRadius: '10px',
padding: '1px 6px',
marginLeft: 4,
fontWeight: 600,
}}
title={`${it.open_report_count} offene Inhaltsmeldung${it.open_report_count > 1 ? 'en' : ''}`}
>{it.open_report_count} Meldung{it.open_report_count > 1 ? 'en' : ''}</span>
)}
</div>
{(it.tags || []).length ? (
<div className="media-library__tag-chips">
@ -1598,6 +1613,39 @@ export default function MediaLibraryPage() {
<span>{nw.rights_status || '—'}</span>
</div>
</>
) : evt.event_type === 'content_report_filed' ? (
<>
{nw.content_report_id ? (
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Meldungs-ID:</span>
<span>#{nw.content_report_id}</span>
</div>
) : null}
{nw.report_reason ? (
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Meldegrund:</span>
<span>{nw.report_reason}</span>
</div>
) : null}
{nw.priority ? (
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Priorität:</span>
<span>{nw.priority}</span>
</div>
) : null}
{nw.status ? (
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Status:</span>
<span>{old.status ? `${old.status}` : ''}{nw.status}</span>
</div>
) : null}
{nw.begründung ? (
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Begründung:</span>
<span>{nw.begründung}</span>
</div>
) : null}
</>
) : (
Object.keys(nw).map((k) => (
<div key={k} className="media-library__journal-audit-row">

View File

@ -1,6 +1,6 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.90"
export const APP_VERSION = "0.8.91"
export const BUILD_DATE = "2026-05-11"
export const PAGE_VERSIONS = {
@ -20,7 +20,7 @@ export const PAGE_VERSIONS = {
TrainingCoachPage: "1.0.0",
AdminCatalogsPage: "2.2.0",
TrainerContextsPage: "1.0.0",
MediaLibraryPage: "1.8.0", // P-13: MediaPreviewModal (geteilt) + Melde-Button in Viewer
MediaLibraryPage: "1.9.0", // P-13: open_report_count Badge + Journal content_report_filed
ExerciseFormPage: "1.1.0", // P-13: MediaPreviewModal (geteilt) + Melden im Viewer
ExerciseInlineFileMediaModal: "1.1.0", // P-06: RightsDeclarationDialog vor Upload
ExerciseInlineEmbedModal: "1.1.0", // P-06: RightsDeclarationDialog vor Embed-Upload
@ -30,5 +30,5 @@ export const PAGE_VERSIONS = {
InboxPage: "2.1.0", // P-13: target_filename/target_exercise_name fix; Club-Admin-Sicht
OrgInboxContext: "1.1.0", // P-13: canSeeContentReports schliesst Club-Admins ein
MediaPreviewModal: "1.0.0", // P-13: geteilter Medienvorschau-Dialog (Melden + Bearbeiten optional)
ReportContentModal: "1.0.0", // P-13: Melde-Formular (Grund, Beschreibung, Name, E-Mail)
ReportContentModal: "1.1.0", // P-13: Name/E-Mail readOnly fuer eingeloggte Nutzer
}