diff --git a/backend/media_rights.py b/backend/media_rights.py index 426186d..b9230c3 100644 --- a/backend/media_rights.py +++ b/backend/media_rights.py @@ -330,7 +330,7 @@ def write_rights_declaration( def write_audit_log_entry( cur: Any, asset_id: int, - acting_profile_id: int, + acting_profile_id: Optional[int], event_type: str, old_values: dict, new_values: dict, diff --git a/backend/migrations/053_content_report_audit_event.sql b/backend/migrations/053_content_report_audit_event.sql new file mode 100644 index 0000000..82b4ed4 --- /dev/null +++ b/backend/migrations/053_content_report_audit_event.sql @@ -0,0 +1,15 @@ +-- Migration 053: 'content_report_filed' als neuer event_type im Medien-Audit-Log +-- Notwendig fuer P-13: Journaleintrag beim Einreichen einer Inhaltsmeldung. + +ALTER TABLE media_asset_audit_log + DROP CONSTRAINT IF EXISTS media_asset_audit_log_event_type_check; + +ALTER TABLE media_asset_audit_log + ADD CONSTRAINT media_asset_audit_log_event_type_check + CHECK (event_type IN ( + 'visibility_change', + 'copyright_change', + 'metadata_change', + 'lifecycle_change', + 'content_report_filed' + )); diff --git a/backend/routers/content_reports.py b/backend/routers/content_reports.py index c4c2c4f..d00b3d3 100644 --- a/backend/routers/content_reports.py +++ b/backend/routers/content_reports.py @@ -11,12 +11,20 @@ Architektur: Berechtigungen: - Meldung einreichen: optional Auth (ohne Auth nur fuer official-Medien erlaubt) -- Liste/Detail: Plattform-Admin (admin/superadmin) +- Liste/Detail: Plattform-Admin (alle Meldungen) oder Club-Admin (nur eigene Vereinsmedien) - Status-Bearbeitung: Plattform-Admin - Legal Hold aus Meldung: ausschliesslich Superadmin + +E-Mail-Benachrichtigungen: +- Nach Eingang: Bestaetigung an Melder +- Nach Eingang: Benachrichtigung aller Plattform-Admins (best-effort, kein Fehler wenn SMTP fehlt) + +Audit-Log (Migration 053): +- Bei media_asset-Meldungen: Eintrag in media_asset_audit_log (event_type='content_report_filed') """ from __future__ import annotations +import os import re from typing import Optional @@ -30,6 +38,7 @@ from media_legal_hold import ( assert_superadmin_for_legal_hold, set_legal_hold, ) +from media_rights import write_audit_log_entry from tenant_context import TenantContext, get_tenant_context router = APIRouter(prefix="/api", tags=["content_reports"]) @@ -46,6 +55,17 @@ REPORT_REASONS = frozenset({ "other", }) +REASON_LABELS_DE = { + "copyright": "Urheberrecht", + "image_rights": "Bildrechte", + "privacy": "Datenschutz / Persönlichkeitsrecht", + "minors": "Minderjährige", + "illegal_content": "Rechtswidriger Inhalt", + "youth_protection": "Jugendschutz", + "offensive_content": "Beleidigender Inhalt", + "other": "Sonstiges", +} + # Gruende mit hoher Prioritaet (illegal, Minderjaehrigenschutz) HIGH_PRIORITY_REASONS = frozenset({ "minors", @@ -157,7 +177,6 @@ def _is_media_asset_visible_to_profile(cur, asset_id: int, profile_id: int, glob if is_platform_admin(global_role): cur.execute("SELECT 1 FROM media_assets WHERE id = %s", (asset_id,)) return cur.fetchone() is not None - # Sichtbarkeit pruefen: official (immer), club (Mitglied), private (Eigentuemerrechte sind breiter) cur.execute( """ SELECT ma.id, ma.visibility, ma.club_id, ma.uploaded_by_profile_id @@ -188,7 +207,6 @@ def _is_media_asset_visible_to_profile(cur, asset_id: int, profile_id: int, glob ) return cur.fetchone() is not None if visibility == "private": - # Private Medien: Eigentuemerrechte oder gleicher Verein mit club_admin if asset.get("uploaded_by_profile_id") == profile_id: return True club_id = asset.get("club_id") @@ -265,6 +283,96 @@ def _resolve_optional_auth(cur, x_auth_token: Optional[str]) -> tuple[Optional[i return d.get("profile_id"), d.get("role") +def _get_platform_admin_emails(cur) -> list[str]: + """Gibt E-Mail-Adressen aller aktiven Plattform-Admins zurueck.""" + cur.execute( + """ + SELECT email FROM profiles + WHERE role IN ('admin', 'superadmin') + AND email IS NOT NULL AND email <> '' + """ + ) + return [r2d(row)["email"] for row in cur.fetchall()] + + +def _send_email(to: str, subject: str, body: str) -> None: + """SMTP-Versand (best-effort, ignoriert Fehler und nicht konfiguriertes SMTP).""" + try: + import smtplib, ssl + from email.mime.text import MIMEText + + smtp_host = (os.getenv("SMTP_HOST") or "").strip() + smtp_user = (os.getenv("SMTP_USER") or "").strip() + smtp_pass = os.getenv("SMTP_PASS") or "" + if not smtp_host or not smtp_user or not smtp_pass: + return + smtp_port = int(os.getenv("SMTP_PORT") or "587") + smtp_from = os.getenv("SMTP_FROM", "noreply@jinkendo.de") + force_ssl = (os.getenv("SMTP_SSL", "").strip().lower() in ("1", "true", "yes")) or smtp_port == 465 + use_tls = os.getenv("SMTP_STARTTLS", "true").strip().lower() not in ("0", "false", "no") + + msg = MIMEText(body, "plain", "utf-8") + msg["Subject"] = subject + msg["From"] = smtp_from + msg["To"] = to + ctx = ssl.create_default_context() + if force_ssl: + with smtplib.SMTP_SSL(smtp_host, smtp_port, context=ctx, timeout=30) as srv: + srv.login(smtp_user, smtp_pass) + srv.send_message(msg) + else: + with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as srv: + srv.ehlo() + if use_tls: + srv.starttls(context=ctx) + srv.ehlo() + srv.login(smtp_user, smtp_pass) + srv.send_message(msg) + except Exception as exc: + print(f"[SMTP content_reports] Fehler: {exc}") + + +def _notify_report_submitted( + *, + report_id: int, + reporter_name: str, + reporter_email: str, + reason: str, + priority: str, + target_type: str, + target_id: int, + 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}" + + # 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"Gemeldeter Inhalt: {target_label}\n\n" + f"Ein Administrator wird Ihre Meldung zeitnah prüfen.\n\n" + f"Shinkan Jinkendo" + ) + _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" + ) + subject = f"Inhaltsmeldung #{report_id} [{prio_label}] – Shinkan Jinkendo" + for email in admin_emails: + _send_email(email, subject, admin_body) + + def _report_row_to_dict(row) -> dict: return r2d(row) @@ -282,6 +390,8 @@ def submit_content_report( - Ohne Login: nur fuer 'official'-Medien erlaubt (öffentlich erreichbare Inhalte) - Mit Login: fuer alle Inhalte, die der Nutzer laut Sichtbarkeitslogik sehen darf - Gutglaubenserklärung (good_faith_confirmed=true) ist Pflicht + - Journaleintrag im Medien-Audit-Log (wenn target_type=media_asset) + - E-Mail-Bestaetigung an Melder + Benachrichtigung an Plattform-Admins (best-effort) """ if not body.good_faith_confirmed: raise HTTPException( @@ -291,6 +401,9 @@ def submit_content_report( priority = "high" if body.report_reason in HIGH_PRIORITY_REASONS else "normal" + report_id: int + admin_emails: list[str] = [] + with get_db() as conn: cur = get_cursor(conn) @@ -341,10 +454,43 @@ def submit_content_report( ) row = cur.fetchone() result = r2d(row) + report_id = int(result["id"]) + + # Audit-Log-Eintrag (nur fuer media_asset-Meldungen) + if body.target_type == "media_asset": + write_audit_log_entry( + cur, + body.target_id, + profile_id, + "content_report_filed", + {}, + { + "content_report_id": report_id, + "report_reason": body.report_reason, + "priority": priority, + "reporter_email": body.reporter_email, + }, + ) + + # Admin-E-Mails vor Commit abfragen (Verbindung noch offen) + admin_emails = _get_platform_admin_emails(cur) + conn.commit() + # E-Mails nach Commit senden (best-effort, kein Rollback-Risiko) + _notify_report_submitted( + report_id=report_id, + reporter_name=body.reporter_name, + reporter_email=body.reporter_email, + reason=body.report_reason, + priority=priority, + target_type=body.target_type, + target_id=body.target_id, + admin_emails=admin_emails, + ) + return { - "id": result["id"], + "id": report_id, "status": result["status"], "priority": result["priority"], "submitted_at": str(result["submitted_at"]), @@ -357,59 +503,127 @@ def list_inbox_content_reports( tenant: TenantContext = Depends(get_tenant_context), ): """ - Alle Meldungen fuer die Admin-Inbox. - Sichtbar fuer Plattform-Admins (admin + superadmin). - In der InboxPage neben den Join-Requests angezeigt. + Meldungen fuer die Inbox. + - Plattform-Admins (admin/superadmin): alle Meldungen + - Club-Admins: nur Meldungen ueber Medien ihres Vereins """ - _assert_platform_admin(tenant.global_role) + pid = tenant.profile_id + role = tenant.global_role + + is_padmin = is_platform_admin(role) with get_db() as conn: cur = get_cursor(conn) - cur.execute( - """ - SELECT - cr.id, - cr.target_type, - cr.target_id, - cr.report_reason, - cr.report_description, - cr.reporter_name, - cr.reporter_email, - cr.reporter_profile_id, - cr.priority, - cr.status, - cr.assigned_to_profile_id, - cr.reviewed_by_profile_id, - cr.reviewed_at, - cr.resolution_note, - cr.submitted_at, - cr.created_at, - cr.updated_at, - -- Ziel-Medium-Infos (wenn target_type = media_asset) - ma.original_filename AS target_filename, - ma.visibility AS target_visibility, - ma.media_kind AS target_media_kind, - ma.legal_hold_active AS target_legal_hold_active, - -- Ziel-Uebungs-Infos (wenn target_type = exercise) - ex.name AS target_exercise_name, - -- Reviewer-Name - rev.name AS reviewed_by_name, - -- Zugewiesen-Name - asgn.name AS assigned_to_name - FROM content_reports cr - LEFT JOIN media_assets ma ON ma.id = cr.target_id AND cr.target_type = 'media_asset' - LEFT JOIN exercises ex ON ex.id = cr.target_id AND cr.target_type = 'exercise' - LEFT JOIN profiles rev ON rev.id = cr.reviewed_by_profile_id - LEFT JOIN profiles asgn ON asgn.id = cr.assigned_to_profile_id - ORDER BY - CASE WHEN cr.status = 'submitted' THEN 0 - WHEN cr.status = 'under_review' THEN 1 - ELSE 2 END ASC, - cr.priority DESC, - cr.created_at ASC - LIMIT 500 - """, - ) + + if is_padmin: + # Plattform-Admin: alle Meldungen + cur.execute( + """ + SELECT + cr.id, + cr.target_type, + cr.target_id, + cr.report_reason, + cr.report_description, + cr.reporter_name, + cr.reporter_email, + cr.reporter_profile_id, + cr.priority, + cr.status, + cr.assigned_to_profile_id, + cr.reviewed_by_profile_id, + cr.reviewed_at, + cr.resolution_note, + cr.submitted_at, + cr.created_at, + cr.updated_at, + ma.original_filename AS target_filename, + ma.visibility AS target_visibility, + ma.media_kind AS target_media_kind, + ma.legal_hold_active AS target_legal_hold_active, + ex.name AS target_exercise_name, + rev.name AS reviewed_by_name, + asgn.name AS assigned_to_name + FROM content_reports cr + LEFT JOIN media_assets ma ON ma.id = cr.target_id AND cr.target_type = 'media_asset' + LEFT JOIN exercises ex ON ex.id = cr.target_id AND cr.target_type = 'exercise' + LEFT JOIN profiles rev ON rev.id = cr.reviewed_by_profile_id + LEFT JOIN profiles asgn ON asgn.id = cr.assigned_to_profile_id + ORDER BY + CASE WHEN cr.status = 'submitted' THEN 0 + WHEN cr.status = 'under_review' THEN 1 + ELSE 2 END ASC, + cr.priority DESC, + cr.created_at ASC + LIMIT 500 + """, + ) + else: + # Club-Admin: Meldungen ueber Medien der eigenen Vereine + # Zuerst pruefen ob ueberhaupt Club-Admin-Rolle vorhanden + cur.execute( + """ + SELECT COUNT(*) AS cnt + FROM club_members cm + INNER JOIN club_member_roles r ON r.club_member_id = cm.id + WHERE cm.profile_id = %s AND cm.status = 'active' AND r.role_code = 'club_admin' + """, + (pid,), + ) + cnt_row = r2d(cur.fetchone()) + if int(cnt_row.get("cnt", 0)) == 0: + raise HTTPException(status_code=403, detail="Kein Zugriff auf Inhaltsmeldungen") + + cur.execute( + """ + SELECT + cr.id, + cr.target_type, + cr.target_id, + cr.report_reason, + cr.report_description, + cr.reporter_name, + cr.reporter_email, + cr.reporter_profile_id, + cr.priority, + cr.status, + cr.assigned_to_profile_id, + cr.reviewed_by_profile_id, + cr.reviewed_at, + cr.resolution_note, + cr.submitted_at, + cr.created_at, + cr.updated_at, + ma.original_filename AS target_filename, + ma.visibility AS target_visibility, + ma.media_kind AS target_media_kind, + ma.legal_hold_active AS target_legal_hold_active, + ex.name AS target_exercise_name, + rev.name AS reviewed_by_name, + asgn.name AS assigned_to_name + FROM content_reports cr + LEFT JOIN media_assets ma ON ma.id = cr.target_id AND cr.target_type = 'media_asset' + LEFT JOIN exercises ex ON ex.id = cr.target_id AND cr.target_type = 'exercise' + LEFT JOIN profiles rev ON rev.id = cr.reviewed_by_profile_id + LEFT JOIN profiles asgn ON asgn.id = cr.assigned_to_profile_id + WHERE cr.target_type = 'media_asset' + AND ma.club_id IN ( + SELECT cm.club_id + FROM club_members cm + INNER JOIN club_member_roles r ON r.club_member_id = cm.id + WHERE cm.profile_id = %s AND cm.status = 'active' AND r.role_code = 'club_admin' + ) + ORDER BY + CASE WHEN cr.status = 'submitted' THEN 0 + WHEN cr.status = 'under_review' THEN 1 + ELSE 2 END ASC, + cr.priority DESC, + cr.created_at ASC + LIMIT 200 + """, + (pid,), + ) + return [_report_row_to_dict(row) for row in cur.fetchall()] @@ -427,22 +641,22 @@ def get_content_report( """ SELECT cr.*, - ma.original_filename AS target_filename, - ma.visibility AS target_visibility, - ma.media_kind AS target_media_kind, - ma.legal_hold_active AS target_legal_hold_active, + ma.original_filename AS target_filename, + ma.visibility AS target_visibility, + ma.media_kind AS target_media_kind, + 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, - ex.visibility AS target_exercise_visibility, - rev.name AS reviewed_by_name, - asgn.name AS assigned_to_name, - rep.name AS reporter_profile_name + ex.name AS target_exercise_name, + ex.visibility AS target_exercise_visibility, + rev.name AS reviewed_by_name, + asgn.name AS assigned_to_name, + rep.name AS reporter_profile_name FROM content_reports cr - LEFT JOIN media_assets ma ON ma.id = cr.target_id AND cr.target_type = 'media_asset' - LEFT JOIN exercises ex ON ex.id = cr.target_id AND cr.target_type = 'exercise' - LEFT JOIN profiles rev ON rev.id = cr.reviewed_by_profile_id - LEFT JOIN profiles asgn ON asgn.id = cr.assigned_to_profile_id - LEFT JOIN profiles rep ON rep.id = cr.reporter_profile_id + LEFT JOIN media_assets ma ON ma.id = cr.target_id AND cr.target_type = 'media_asset' + LEFT JOIN exercises ex ON ex.id = cr.target_id AND cr.target_type = 'exercise' + LEFT JOIN profiles rev ON rev.id = cr.reviewed_by_profile_id + LEFT JOIN profiles asgn ON asgn.id = cr.assigned_to_profile_id + LEFT JOIN profiles rep ON rep.id = cr.reporter_profile_id WHERE cr.id = %s """, (report_id,), diff --git a/backend/version.py b/backend/version.py index c9d0572..0340bfc 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.89" +APP_VERSION = "0.8.90" BUILD_DATE = "2026-05-11" -DB_SCHEMA_VERSION = "20260511052" +DB_SCHEMA_VERSION = "20260511053" MODULE_VERSIONS = { "legal_documents": "1.2.0", # jsPDF-Download auf LegalPage (oeffentlich) + Admin; Abschnitts-Sortierung/-Einfuegen @@ -14,7 +14,7 @@ MODULE_VERSIONS = { "club_join_requests": "1.0.1", # Depends(get_tenant_context) "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.0", # P-11: write_audit_log_entry + legal_hold_set/released events + "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_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 @@ -30,10 +30,21 @@ MODULE_VERSIONS = { "membership": "1.0.0", "catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012) "maturity_models": "1.4.0", # matrix_stack_bundle: vollständiger Katalog+Modelle+Bindings Export/Import - "content_reports": "1.2.0", # P-13: Viewer konsolidiert — MediaPreviewModal (geteilt); Melden in ExerciseFormPage-Viewer + "content_reports": "1.3.0", # P-13: Club-Admin-Zugriff; Audit-Log; E-Mail (Admin + Melder); target_name-Fix } CHANGELOG = [ + { + "version": "0.8.90", + "date": "2026-05-11", + "changes": [ + "Fix P-13: Club-Admins sehen jetzt Inhaltsmeldungen ihrer Vereinsmedien in der Inbox (Backend-Filter + Frontend canSeeContentReports).", + "Fix P-13: Journaleintrag (content_report_filed) in media_asset_audit_log beim Einreichen einer Meldung (Migration 053).", + "Fix P-13: E-Mail-Benachrichtigung an alle Plattform-Admins bei neuer Meldung (best-effort).", + "Fix P-13: Bestätigungs-E-Mail an Melder beim Einreichen (best-effort).", + "Fix P-13: Medienname (target_filename/target_exercise_name) korrekt in Inbox-Liste angezeigt.", + ], + }, { "version": "0.8.89", "date": "2026-05-11", diff --git a/frontend/src/context/OrgInboxContext.jsx b/frontend/src/context/OrgInboxContext.jsx index 68ab9f8..59e82de 100644 --- a/frontend/src/context/OrgInboxContext.jsx +++ b/frontend/src/context/OrgInboxContext.jsx @@ -17,8 +17,9 @@ export function canAccessOrgInbox(user) { return activeClubMemberships(user.clubs).some((c) => (c.roles || []).includes('club_admin')) } -function isPlatformAdmin(user) { - return user?.role === 'admin' || user?.role === 'superadmin' +function canSeeContentReports(user) { + if (user?.role === 'admin' || user?.role === 'superadmin') return true + return activeClubMemberships(user?.clubs || []).some((c) => (c.roles || []).includes('club_admin')) } /** Nach Annahme/Ablehnung: Posteingang-Badges & Widget aktualisieren */ @@ -30,7 +31,7 @@ export function OrgInboxProvider({ user, children }) { const [items, setItems] = useState([]) const [contentReports, setContentReports] = useState([]) const canAccess = useMemo(() => canAccessOrgInbox(user), [user]) - const canAccessReports = useMemo(() => isPlatformAdmin(user), [user]) + const canAccessReports = useMemo(() => canSeeContentReports(user), [user]) const refresh = useCallback(async () => { if (!canAccess) { diff --git a/frontend/src/pages/InboxPage.jsx b/frontend/src/pages/InboxPage.jsx index 0c95095..4537d8e 100644 --- a/frontend/src/pages/InboxPage.jsx +++ b/frontend/src/pages/InboxPage.jsx @@ -512,7 +512,7 @@ export default function InboxPage() { {rep.target_type === 'media_asset' ? 'Medium' : 'Übung'} #{rep.target_id} - {rep.target_name ? ` – ${rep.target_name}` : ''} + {rep.target_filename || rep.target_exercise_name ? ` – ${rep.target_filename || rep.target_exercise_name}` : ''}