From b2b7bd423d505ae7e3bdc1c61754861e2fb1f26c Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 11 May 2026 10:26:50 +0200 Subject: [PATCH] feat(audit-log): implement full audit log for media assets, including visibility, copyright, metadata, and lifecycle changes; add correction declaration functionality --- backend/media_rights.py | 73 +++++ backend/migrations/050_media_audit_log.sql | 47 ++++ backend/routers/media_assets.py | 200 +++++++++++++- backend/version.py | 17 +- frontend/src/app.css | 55 ++++ frontend/src/pages/MediaLibraryPage.jsx | 298 ++++++++++++++++++++- frontend/src/utils/api.js | 8 + frontend/src/version.js | 4 +- 8 files changed, 677 insertions(+), 25 deletions(-) create mode 100644 backend/migrations/050_media_audit_log.sql diff --git a/backend/media_rights.py b/backend/media_rights.py index eab8aa6..426186d 100644 --- a/backend/media_rights.py +++ b/backend/media_rights.py @@ -10,6 +10,7 @@ VORLAEUTIG: Juristische Validierung der Felder und Texte steht aus. """ from __future__ import annotations +import json as _json from typing import Any, Optional from fastapi import HTTPException @@ -326,6 +327,78 @@ def write_rights_declaration( return int(row[0]) +def write_audit_log_entry( + cur: Any, + asset_id: int, + acting_profile_id: int, + event_type: str, + old_values: dict, + new_values: dict, +) -> None: + """Schreibt einen Eintrag in media_asset_audit_log (append-only).""" + cur.execute( + """INSERT INTO media_asset_audit_log + (media_asset_id, acting_profile_id, event_type, old_values, new_values) + VALUES (%s, %s, %s, %s, %s)""", + ( + asset_id, + acting_profile_id, + event_type, + _json.dumps(old_values, default=str), + _json.dumps(new_values, default=str), + ), + ) + + +def write_rights_correction_declaration( + cur: Any, + asset_id: int, + profile_id: int, + target_visibility: str, + decl: dict[str, Any], + correction_note: Optional[str], +) -> int: + """Schreibt eine Korrektur-Deklaration (action_type='correction', append-only).""" + cur.execute( + """INSERT INTO media_asset_rights_declarations ( + media_asset_id, declared_by_profile_id, action_type, target_visibility, + declaration_version, + rights_holder_confirmed, + contains_identifiable_persons, person_consent_confirmed, person_consent_context, + contains_minors, parental_consent_confirmed, parental_consent_context, + contains_music, music_rights_confirmed, music_rights_context, + contains_third_party_content, third_party_rights_confirmed, third_party_rights_context, + correction_note + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id""", + ( + asset_id, + profile_id, + "correction", + target_visibility, + DECLARATION_VERSION, + bool(decl.get("rights_holder_confirmed")), + decl.get("contains_identifiable_persons"), + decl.get("person_consent_confirmed"), + _clean_context(decl.get("person_consent_context")), + decl.get("contains_minors"), + decl.get("parental_consent_confirmed"), + _clean_context(decl.get("parental_consent_context")), + decl.get("contains_music"), + decl.get("music_rights_confirmed"), + _clean_context(decl.get("music_rights_context")), + decl.get("contains_third_party_content"), + decl.get("third_party_rights_confirmed"), + _clean_context(decl.get("third_party_rights_context")), + _clean_context(correction_note), + ), + ) + row = cur.fetchone() + if hasattr(row, "keys"): + return int(row["id"]) + return int(row[0]) + + def update_rights_quick_fields(cur: Any, asset_id: int, target_visibility: str) -> None: """Setzt die Schnellfelder in media_assets nach erfolgreicher Deklaration.""" cur.execute( diff --git a/backend/migrations/050_media_audit_log.sql b/backend/migrations/050_media_audit_log.sql new file mode 100644 index 0000000..c60d3a5 --- /dev/null +++ b/backend/migrations/050_media_audit_log.sql @@ -0,0 +1,47 @@ +-- Migration 050: Medien-Volljournal – Audit-Log für alle Änderungen + Korrektur-Deklarationen + +-- Vollständiger Audit-Log: Sichtbarkeitsänderungen, Copyright, Metadaten, Lifecycle +CREATE TABLE IF NOT EXISTS media_asset_audit_log ( + id SERIAL PRIMARY KEY, + media_asset_id INT NOT NULL REFERENCES media_assets(id) ON DELETE CASCADE, + acting_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + event_type VARCHAR(50) NOT NULL + CHECK (event_type IN ( + 'visibility_change', + 'copyright_change', + 'metadata_change', + 'lifecycle_change' + )), + old_values JSONB, + new_values JSONB +); + +CREATE INDEX IF NOT EXISTS idx_maal_asset ON media_asset_audit_log (media_asset_id); +CREATE INDEX IF NOT EXISTS idx_maal_asset_occurred ON media_asset_audit_log (media_asset_id, occurred_at); + +COMMENT ON TABLE media_asset_audit_log IS + 'Append-only Protokoll aller Aenderungen an Medien-Assets (Sichtbarkeit, Copyright, Metadaten, Lifecycle). ' + 'Wird nie aktualisiert oder geloescht (ausser ON DELETE CASCADE des Assets).'; + +-- Korrektur-Notiz für nachtraegliche Deklarations-Korrekturen +ALTER TABLE media_asset_rights_declarations + ADD COLUMN IF NOT EXISTS correction_note TEXT; + +COMMENT ON COLUMN media_asset_rights_declarations.correction_note IS + 'Optionale Begruendung fuer action_type=correction: Warum wurde die Erklaerung korrigiert?'; + +-- ''correction'' action_type hinzufuegen (bestehende CHECK-Constraint ersetzen) +ALTER TABLE media_asset_rights_declarations + DROP CONSTRAINT IF EXISTS media_asset_rights_declarations_action_type_check; + +ALTER TABLE media_asset_rights_declarations + ADD CONSTRAINT media_asset_rights_declarations_action_type_check + CHECK (action_type IN ( + 'upload', + 'promote_club', + 'promote_official', + 're_declaration', + 'legacy_re_declaration', + 'correction' + )); diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index e4a44a6..8a6bfa6 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -42,6 +42,8 @@ from media_rights import ( update_rights_quick_fields, validate_rights_declaration, write_rights_declaration, + write_audit_log_entry, + write_rights_correction_declaration, check_rights_coverage, VISIBILITY_LEVELS, ) @@ -112,6 +114,25 @@ class RightsDeclarationBody(BaseModel): third_party_rights_context: Optional[str] = Field(None, max_length=2000) +class RightsCorrectionBody(BaseModel): + """P-06: Nachtraegliche Korrektur einer Erklaerung (append-only, neueste gilt).""" + target_visibility: str = Field(..., pattern="^(private|club|official)$") + rights_holder_confirmed: bool + contains_identifiable_persons: bool + person_consent_confirmed: Optional[bool] = None + person_consent_context: Optional[str] = Field(None, max_length=2000) + contains_minors: bool + parental_consent_confirmed: Optional[bool] = None + parental_consent_context: Optional[str] = Field(None, max_length=2000) + contains_music: bool + music_rights_confirmed: Optional[bool] = None + music_rights_context: Optional[str] = Field(None, max_length=2000) + contains_third_party_content: bool + third_party_rights_confirmed: Optional[bool] = None + third_party_rights_context: Optional[str] = Field(None, max_length=2000) + correction_note: Optional[str] = Field(None, max_length=2000) + + class MediaBulkLifecycleBody(BaseModel): media_asset_ids: list[int] = Field(..., min_length=1, max_length=200) action: Literal[ @@ -627,6 +648,7 @@ def _apply_lifecycle_action( action = body.action role_raw = tenant.global_role + old_lc = (asset.get("lifecycle_state") or LC_ACTIVE).strip() if action == "superadmin_hard_delete": if not is_superadmin(role_raw): @@ -641,7 +663,13 @@ def _apply_lifecycle_action( raise HTTPException(status_code=403, detail="Nur Superadmin") tl = body.target_lifecycle or "active" mp = {"active": LC_ACTIVE, "trash_soft": LC_TRASH_SOFT, "trash_hidden": LC_TRASH_HIDDEN} - return superadmin_force_lifecycle_state(cur, conn, asset_id, mp[tl]) + new_lc = mp[tl] + result = superadmin_force_lifecycle_state(cur, conn, asset_id, new_lc) + if old_lc != new_lc: + write_audit_log_entry(cur, asset_id, tenant.profile_id, "lifecycle_change", + {"lifecycle_state": old_lc}, {"lifecycle_state": new_lc}) + conn.commit() + return result if action == "purge": if not is_superadmin(role_raw): @@ -658,16 +686,32 @@ def _apply_lifecycle_action( if action == "trash_soft": assert_can_trash_soft(cur, tenant, asset) - return transition_to_trash_soft(cur, conn, asset_id) + result = transition_to_trash_soft(cur, conn, asset_id) + write_audit_log_entry(cur, asset_id, tenant.profile_id, "lifecycle_change", + {"lifecycle_state": old_lc}, {"lifecycle_state": LC_TRASH_SOFT}) + conn.commit() + return result if action == "trash_hidden": assert_can_manage_media_asset_lifecycle(cur, tenant, asset) - return transition_to_trash_hidden(cur, conn, asset_id) + result = transition_to_trash_hidden(cur, conn, asset_id) + write_audit_log_entry(cur, asset_id, tenant.profile_id, "lifecycle_change", + {"lifecycle_state": old_lc}, {"lifecycle_state": LC_TRASH_HIDDEN}) + conn.commit() + return result if action == "recover": assert_can_manage_media_asset_lifecycle(cur, tenant, asset) - return transition_recover_from_hidden(cur, conn, asset_id) + result = transition_recover_from_hidden(cur, conn, asset_id) + write_audit_log_entry(cur, asset_id, tenant.profile_id, "lifecycle_change", + {"lifecycle_state": old_lc}, {"lifecycle_state": LC_TRASH_SOFT}) + conn.commit() + return result if action == "reactivate": assert_can_manage_media_asset_lifecycle(cur, tenant, asset) - return reactivate_media_asset_from_trash(cur, conn, asset_id) + result = reactivate_media_asset_from_trash(cur, conn, asset_id) + write_audit_log_entry(cur, asset_id, tenant.profile_id, "lifecycle_change", + {"lifecycle_state": old_lc}, {"lifecycle_state": LC_ACTIVE}) + conn.commit() + return result raise HTTPException(status_code=500, detail="Interner Fehler: lifecycle action") @@ -1532,6 +1576,32 @@ def patch_media_asset( cur, asset_id, profile_id, _p06_action, next_vis, _p06_pending_decl ) update_rights_quick_fields(cur, asset_id, next_vis) + # Audit-Log: Änderungen protokollieren (gleiche Transaktion) + if "visibility" in data or "club_id" in data: + _old_vis = (asset.get("visibility") or "").strip() + _old_cid = asset.get("club_id") + if next_vis != _old_vis or next_cid != _old_cid: + write_audit_log_entry( + cur, asset_id, profile_id, "visibility_change", + {"visibility": _old_vis, "club_id": _old_cid}, + {"visibility": next_vis, "club_id": next_cid}, + ) + if "copyright_notice" in data: + _old_cr = (asset.get("copyright_notice") or "").strip() + _new_cr = (data.get("copyright_notice") or "").strip() + if _new_cr != _old_cr: + write_audit_log_entry( + cur, asset_id, profile_id, "copyright_change", + {"copyright_notice": asset.get("copyright_notice")}, + {"copyright_notice": data["copyright_notice"]}, + ) + _meta_old: dict = {} + _meta_new: dict = {} + if "original_filename" in data: + _meta_old["original_filename"] = asset.get("original_filename") + _meta_new["original_filename"] = data["original_filename"] + if _meta_new: + write_audit_log_entry(cur, asset_id, profile_id, "metadata_change", _meta_old, _meta_new) conn.commit() has_tags = _media_assets_tags_column_present(cur) if has_tags: @@ -1699,14 +1769,17 @@ def get_media_asset_journal( asset_id: int, tenant: TenantContext = Depends(get_tenant_context), ): - """P-06 Superadmin: Vollständiges Deklarationsjournal für ein Medium.""" - if not is_superadmin(tenant.global_role): - raise HTTPException(status_code=403, detail="Keine Berechtigung (Superadmin erforderlich)") + """Vollständiges Medien-Journal: Einwilligungen + alle Änderungen chronologisch. + + Zugriff: Superadmin, ursprünglicher Uploader oder Vereins-Admin des betreffenden Mediums. + """ + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) cur.execute( """ - SELECT id, original_filename, visibility, rights_status, + SELECT id, original_filename, visibility, club_id, rights_status, rights_declared_for_visibility, rights_declared_at, copyright_notice, mime_type, lifecycle_state, uploaded_by_profile_id, created_at @@ -1719,6 +1792,23 @@ def get_media_asset_journal( if not asset_row: raise HTTPException(status_code=404, detail="Medium nicht gefunden") asset = r2d(asset_row) + + is_sup = is_superadmin(role) + is_uploader = asset.get("uploaded_by_profile_id") == profile_id + is_club_adm = False + if asset.get("club_id"): + cur.execute( + """SELECT 1 FROM club_members + WHERE profile_id = %s AND club_id = %s AND role = 'admin' AND status = 'active'""", + (profile_id, asset["club_id"]), + ) + is_club_adm = cur.fetchone() is not None + + if not (is_sup or is_uploader or is_club_adm): + raise HTTPException(status_code=403, detail="Keine Berechtigung (Superadmin, Uploader oder Vereinsadmin)") + + can_correct = is_sup or is_uploader or is_club_adm + cur.execute( """ SELECT d.id, d.declared_at, d.action_type, d.target_visibility, @@ -1732,15 +1822,99 @@ def get_media_asset_journal( d.music_rights_context, d.contains_third_party_content, d.third_party_rights_confirmed, d.third_party_rights_context, - d.declared_by_profile_id, + d.declared_by_profile_id, d.correction_note, p.name AS declared_by_name, p.email AS declared_by_email FROM media_asset_rights_declarations d LEFT JOIN profiles p ON p.id = d.declared_by_profile_id WHERE d.media_asset_id = %s - ORDER BY d.declared_at DESC + ORDER BY d.declared_at ASC """, (asset_id,), ) - declarations = [r2d(r) for r in cur.fetchall()] - return {"asset": asset, "declarations": declarations} + events: list[dict] = [] + for r in cur.fetchall(): + d = r2d(r) + d["kind"] = "declaration" + d["at"] = str(d.get("declared_at", "")) + events.append(d) + + # Audit-Log (Migration 050 – Tabelle darf noch nicht existieren) + try: + cur.execute( + """ + SELECT a.id, a.occurred_at, a.event_type, a.old_values, a.new_values, + a.acting_profile_id, + p.name AS acting_name, + p.email AS acting_email + FROM media_asset_audit_log a + LEFT JOIN profiles p ON p.id = a.acting_profile_id + WHERE a.media_asset_id = %s + ORDER BY a.occurred_at ASC + """, + (asset_id,), + ) + for r in cur.fetchall(): + a = r2d(r) + a["kind"] = "audit" + a["at"] = str(a.get("occurred_at", "")) + events.append(a) + except Exception: + pass # Tabelle noch nicht migriert + + events.sort(key=lambda e: e.get("at", "")) + + return {"asset": asset, "events": events, "can_correct": can_correct} + + +@admin_rights_router.post("/assets/{asset_id}/correction") +def add_rights_correction( + asset_id: int, + body: RightsCorrectionBody, + tenant: TenantContext = Depends(get_tenant_context), +): + """Nachträgliche Korrektur einer P-06-Einwilligungserklärung (append-only, neueste gilt). + + Zugriff: Superadmin, ursprünglicher Uploader oder Vereins-Admin. + """ + profile_id = tenant.profile_id + role = tenant.global_role + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT id, uploaded_by_profile_id, club_id, rights_status, lifecycle_state FROM media_assets WHERE id = %s", + (asset_id,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Medium nicht gefunden") + asset = r2d(row) + + is_sup = is_superadmin(role) + is_uploader = asset.get("uploaded_by_profile_id") == profile_id + is_club_adm = False + if asset.get("club_id"): + cur.execute( + """SELECT 1 FROM club_members + WHERE profile_id = %s AND club_id = %s AND role = 'admin' AND status = 'active'""", + (profile_id, asset["club_id"]), + ) + is_club_adm = cur.fetchone() is not None + + if not (is_sup or is_uploader or is_club_adm): + raise HTTPException( + status_code=403, + detail="Keine Berechtigung (Superadmin, ursprünglicher Uploader oder Vereinsadmin erforderlich)", + ) + + decl = body.model_dump(exclude_unset=False) if hasattr(body, "model_dump") else body.dict() + target_vis = decl.pop("target_visibility") + correction_note = decl.pop("correction_note", None) + + validate_rights_declaration(decl, target_vis) + write_rights_correction_declaration(cur, asset_id, profile_id, target_vis, decl, correction_note) + update_rights_quick_fields(cur, asset_id, target_vis) + conn.commit() + + return {"ok": True, "asset_id": asset_id} diff --git a/backend/version.py b/backend/version.py index eb8f5c8..ee55922 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.81" +APP_VERSION = "0.8.82" BUILD_DATE = "2026-05-11" -DB_SCHEMA_VERSION = "20260511048" +DB_SCHEMA_VERSION = "20260511050" MODULE_VERSIONS = { "legal_documents": "1.2.0", # jsPDF-Download auf LegalPage (oeffentlich) + Admin; Abschnitts-Sortierung/-Einfuegen @@ -14,8 +14,8 @@ 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.1.0", # P-06+: write_rights_declaration + 4 Kontext-Freitextfelder - "media_assets": "1.15.0", # P-06+: Superadmin-Journal GET /api/admin/media-rights/assets/{id}/journal + "media_rights": "1.2.0", # P-06+: write_audit_log_entry + write_rights_correction_declaration (Migration 050) + "media_assets": "1.16.0", # P-06+: Volljournal (audit_log + Korrektur); PATCH/lifecycle loggen Aenderungen "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", @@ -31,6 +31,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.82", + "date": "2026-05-11", + "changes": [ + "Feat P-06: Volljournal fuer Medien (Migration 050) — media_asset_audit_log protokolliert Sichtbarkeits-, Copyright-, Metadaten- und Lifecycle-Aenderungen automatisch.", + "Feat P-06: Korrektur-Deklaration (action_type='correction') via POST /api/admin/media-rights/assets/{id}/correction — zugaenglich fuer Superadmin, Uploader, Vereinsadmin.", + "Feat P-06: Journal-Endpoint gibt jetzt events[] (Deklarationen + Auditlog chronologisch gemischt) und can_correct zurueck; Zugriff fuer Superadmin + Uploader + Vereinsadmin.", + ], + }, { "version": "0.8.81", "date": "2026-05-11", diff --git a/frontend/src/app.css b/frontend/src/app.css index d77bd4c..8078a4a 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -6619,3 +6619,58 @@ a.analysis-split__nav-item { color: var(--text2); font-style: italic; } +/* Audit events (Sichtbarkeit, Copyright, Lifecycle) */ +.media-library__journal-entry--audit { + border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); + background: color-mix(in srgb, var(--accent) 4%, var(--surface2)); +} +.media-library__journal-action--audit { + background: var(--text3); + color: var(--surface); +} +.media-library__journal-audit-vals { + font-size: 0.82rem; + color: var(--text1); + margin-top: 4px; + display: flex; + flex-direction: column; + gap: 3px; +} +.media-library__journal-audit-row { + display: flex; + gap: 6px; +} +.media-library__journal-audit-label { + color: var(--text3); + min-width: 40px; + flex-shrink: 0; +} +/* Correction entries */ +.media-library__journal-entry--correction { + border-color: color-mix(in srgb, #e0a000 40%, var(--border)); + background: color-mix(in srgb, #e0a000 5%, var(--surface2)); +} +.media-library__journal-action--correction { + background: #b07800; + color: #fff; +} +.media-library__journal-correction-note { + font-size: 0.82rem; + color: var(--text2); + margin-bottom: 6px; + font-style: italic; +} +/* Correction form */ +.media-library__journal-correction-form { + margin-top: 20px; + padding: 16px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 8px; +} +.media-library__journal-correction-title { + font-size: 0.95rem; + font-weight: 600; + margin: 0 0 8px; + color: var(--text1); +} diff --git a/frontend/src/pages/MediaLibraryPage.jsx b/frontend/src/pages/MediaLibraryPage.jsx index 0415d44..ca8f8ce 100644 --- a/frontend/src/pages/MediaLibraryPage.jsx +++ b/frontend/src/pages/MediaLibraryPage.jsx @@ -232,6 +232,42 @@ function MediaTypeGlyph({ mimeType, compact }) { ) } +function actionTypeLabel(at) { + const MAP = { + upload: 'Erstupload', + promote_club: 'Promotion → Verein', + promote_official: 'Promotion → Offiziell', + re_declaration: 'Nachdeklaration', + legacy_re_declaration: 'Altbestand nachdeklariert', + correction: 'Korrektur', + } + return MAP[at] || at +} + +function eventTypeLabel(et) { + const MAP = { + visibility_change: 'Sichtbarkeit geändert', + copyright_change: 'Copyright geändert', + metadata_change: 'Metadaten geändert', + lifecycle_change: 'Lifecycle geändert', + } + return MAP[et] || et +} + +function visLabel(v) { + if (v === 'private') return 'Privat' + if (v === 'club') return 'Verein' + if (v === 'official') return 'Offiziell' + return v || '—' +} + +function lcLabel(lc) { + if (lc === 'active') return 'Aktiv' + if (lc === 'trash_soft') return 'Papierkorb' + if (lc === 'trash_hidden') return 'Ausgeblendet' + return lc || '—' +} + function JournalBoolField({ label, val, context }) { if (val === null || val === undefined) return null return ( @@ -292,6 +328,9 @@ export default function MediaLibraryPage() { const [pendingPatchBody, setPendingPatchBody] = useState(null) const [journalModal, setJournalModal] = useState(null) const [journalLoading, setJournalLoading] = useState(false) + const [journalCorrectionOpen, setJournalCorrectionOpen] = useState(false) + const [journalCorrectionDraft, setJournalCorrectionDraft] = useState(null) + const [journalCorrectionBusy, setJournalCorrectionBusy] = useState(false) const mediaListFetchSeqRef = useRef(0) const gridTopAnchorRef = useRef(null) @@ -1432,7 +1471,7 @@ export default function MediaLibraryPage() { + + ) : null} + + {journalCorrectionOpen && journalCorrectionDraft ? ( +
+

Korrektur der Einwilligungserklärung

+

+ Die Korrektur wird als neuer Eintrag im Journal gespeichert (append-only). Die ursprünglichen Einträge bleiben erhalten. +

+
+ + +
+
+ +
+
+ +
+ {journalCorrectionDraft.contains_identifiable_persons ? ( +
+ +
+ ) : null} +
+ +
+ {journalCorrectionDraft.contains_minors ? ( +
+ +
+ ) : null} +
+ +
+ {journalCorrectionDraft.contains_music ? ( +
+ +
+ ) : null} +
+ +
+ {journalCorrectionDraft.contains_third_party_content ? ( +
+ +
+ ) : null} +
+ +