feat(audit-log): implement full audit log for media assets, including visibility, copyright, metadata, and lifecycle changes; add correction declaration functionality
Some checks failed
Deploy Development / deploy (push) Failing after 18s
Test Suite / pytest-backend (push) Successful in 32s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Failing after 3s
Test Suite / playwright-tests (push) Successful in 55s

This commit is contained in:
Lars 2026-05-11 10:26:50 +02:00
parent 56e952f084
commit b2b7bd423d
8 changed files with 677 additions and 25 deletions

View File

@ -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(

View File

@ -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'
));

View File

@ -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}

View File

@ -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",

View File

@ -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);
}

View File

@ -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() {
<button
type="button"
className="media-library__icon-btn"
onClick={() => setJournalModal(null)}
onClick={() => { setJournalModal(null); setJournalCorrectionOpen(false) }}
aria-label="Schließen"
>
<X size={22} />
@ -1456,17 +1495,66 @@ export default function MediaLibraryPage() {
) : null}
</div>
{journalModal.declarations.length === 0 ? (
<p className="media-library__hint">Noch keine Einwilligungserklärungen für dieses Medium erfasst.</p>
{/* Chronologische Event-Timeline */}
{(journalModal.events || journalModal.declarations || []).length === 0 ? (
<p className="media-library__hint">Noch keine Einträge für dieses Medium.</p>
) : (
<div className="media-library__journal-list">
{journalModal.declarations.map((d) => {
{(journalModal.events || journalModal.declarations || []).map((evt, idx) => {
if (evt.kind === 'audit') {
const byLabel = evt.acting_name || evt.acting_email
|| (evt.acting_profile_id ? `Profil #${evt.acting_profile_id}` : '—')
const old = evt.old_values || {}
const nw = evt.new_values || {}
return (
<div key={`a-${idx}`} className="media-library__journal-entry media-library__journal-entry--audit">
<div className="media-library__journal-entry-head">
<span className="media-library__journal-action media-library__journal-action--audit">
{eventTypeLabel(evt.event_type)}
</span>
<span className="media-library__journal-date">
{new Date(evt.occurred_at).toLocaleString('de-DE')}
</span>
</div>
<div className="media-library__journal-entry-by">{byLabel}</div>
<div className="media-library__journal-audit-vals">
{evt.event_type === 'visibility_change' ? (
<span>{visLabel(old.visibility)} {visLabel(nw.visibility)}</span>
) : evt.event_type === 'lifecycle_change' ? (
<span>{lcLabel(old.lifecycle_state)} {lcLabel(nw.lifecycle_state)}</span>
) : evt.event_type === 'copyright_change' ? (
<>
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Alt:</span>
<span>{old.copyright_notice || '—'}</span>
</div>
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Neu:</span>
<span>{nw.copyright_notice || '—'}</span>
</div>
</>
) : (
Object.keys(nw).map((k) => (
<div key={k} className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">{k}:</span>
<span>{String(old[k] ?? '—')} {String(nw[k] ?? '—')}</span>
</div>
))
)}
</div>
</div>
)
}
// kind === 'declaration' (or legacy array format)
const d = evt
const byLabel = d.declared_by_name || d.declared_by_email
|| (d.declared_by_profile_id ? `Profil #${d.declared_by_profile_id}` : '—')
return (
<div key={d.id} className="media-library__journal-entry">
<div key={`d-${idx}`} className={`media-library__journal-entry${d.action_type === 'correction' ? ' media-library__journal-entry--correction' : ''}`}>
<div className="media-library__journal-entry-head">
<span className="media-library__journal-action">{d.action_type}</span>
<span className={`media-library__journal-action${d.action_type === 'correction' ? ' media-library__journal-action--correction' : ''}`}>
{actionTypeLabel(d.action_type)}
</span>
<span className="media-library__journal-arrow"></span>
<span className="media-library__journal-vis">{visibilityUiLabel(d.target_visibility)}</span>
<span className="media-library__journal-date">
@ -1477,6 +1565,11 @@ export default function MediaLibraryPage() {
{byLabel}
<span className="media-library__journal-version"> · {d.declaration_version}</span>
</div>
{d.correction_note ? (
<div className="media-library__journal-correction-note">
<strong>Korrekturgrund:</strong> {d.correction_note}
</div>
) : null}
<div className="media-library__journal-fields">
<JournalBoolField label="Rechteinhaber bestätigt" val={d.rights_holder_confirmed} />
<JournalBoolField label="Erkennbare Personen" val={d.contains_identifiable_persons} />
@ -1517,6 +1610,199 @@ export default function MediaLibraryPage() {
})}
</div>
)}
{/* Korrektur-Formular */}
{journalModal.can_correct && !journalCorrectionOpen ? (
<div style={{ marginTop: 16 }}>
<button
className="btn btn-secondary"
onClick={() => {
setJournalCorrectionDraft({
target_visibility: journalModal.asset.visibility || 'private',
rights_holder_confirmed: false,
contains_identifiable_persons: false,
person_consent_confirmed: null,
contains_minors: false,
parental_consent_confirmed: null,
contains_music: false,
music_rights_confirmed: null,
contains_third_party_content: false,
third_party_rights_confirmed: null,
correction_note: '',
})
setJournalCorrectionOpen(true)
}}
>
Korrektur erfassen
</button>
</div>
) : null}
{journalCorrectionOpen && journalCorrectionDraft ? (
<div className="media-library__journal-correction-form">
<h3 className="media-library__journal-correction-title">Korrektur der Einwilligungserklärung</h3>
<p className="media-library__hint" style={{ marginBottom: 12 }}>
Die Korrektur wird als neuer Eintrag im Journal gespeichert (append-only). Die ursprünglichen Einträge bleiben erhalten.
</p>
<div className="form-row">
<label className="form-label">Ziel-Sichtbarkeit</label>
<select
className="form-input"
value={journalCorrectionDraft.target_visibility}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, target_visibility: e.target.value }))}
>
<option value="private">Privat</option>
<option value="club">Verein</option>
<option value="official">Offiziell</option>
</select>
</div>
<div className="form-row">
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.rights_holder_confirmed}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, rights_holder_confirmed: e.target.checked }))}
style={{ marginRight: 8 }}
/>
Ich bestätige, dass ich die erforderlichen Rechte an diesem Medium besitze *
</label>
</div>
<div className="form-row">
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.contains_identifiable_persons}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, contains_identifiable_persons: e.target.checked, person_consent_confirmed: e.target.checked ? d.person_consent_confirmed : null }))}
style={{ marginRight: 8 }}
/>
Erkennbare Personen abgebildet *
</label>
</div>
{journalCorrectionDraft.contains_identifiable_persons ? (
<div className="form-row" style={{ paddingLeft: 24 }}>
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.person_consent_confirmed}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, person_consent_confirmed: e.target.checked }))}
style={{ marginRight: 8 }}
/>
Einwilligung aller erkennbaren Personen liegt vor *
</label>
</div>
) : null}
<div className="form-row">
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.contains_minors}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, contains_minors: e.target.checked, parental_consent_confirmed: e.target.checked ? d.parental_consent_confirmed : null }))}
style={{ marginRight: 8 }}
/>
Minderjährige abgebildet *
</label>
</div>
{journalCorrectionDraft.contains_minors ? (
<div className="form-row" style={{ paddingLeft: 24 }}>
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.parental_consent_confirmed}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, parental_consent_confirmed: e.target.checked }))}
style={{ marginRight: 8 }}
/>
Einwilligung der Erziehungsberechtigten liegt vor *
</label>
</div>
) : null}
<div className="form-row">
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.contains_music}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, contains_music: e.target.checked, music_rights_confirmed: e.target.checked ? d.music_rights_confirmed : null }))}
style={{ marginRight: 8 }}
/>
Musik enthalten *
</label>
</div>
{journalCorrectionDraft.contains_music ? (
<div className="form-row" style={{ paddingLeft: 24 }}>
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.music_rights_confirmed}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, music_rights_confirmed: e.target.checked }))}
style={{ marginRight: 8 }}
/>
Musikrechte liegen vor *
</label>
</div>
) : null}
<div className="form-row">
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.contains_third_party_content}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, contains_third_party_content: e.target.checked, third_party_rights_confirmed: e.target.checked ? d.third_party_rights_confirmed : null }))}
style={{ marginRight: 8 }}
/>
Fremdinhalte (Logos, Grafiken etc.) enthalten *
</label>
</div>
{journalCorrectionDraft.contains_third_party_content ? (
<div className="form-row" style={{ paddingLeft: 24 }}>
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.third_party_rights_confirmed}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, third_party_rights_confirmed: e.target.checked }))}
style={{ marginRight: 8 }}
/>
Rechte an Fremdinhalten liegen vor *
</label>
</div>
) : null}
<div className="form-row">
<label className="form-label">Korrekturgrund (empfohlen)</label>
<textarea
className="form-input"
rows={2}
value={journalCorrectionDraft.correction_note || ''}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, correction_note: e.target.value }))}
placeholder="Warum wird die Erklärung korrigiert?"
/>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button
className="btn btn-primary"
disabled={journalCorrectionBusy}
onClick={async () => {
setJournalCorrectionBusy(true)
try {
await api.addMediaAssetDeclarationCorrection(journalModal.asset.id, journalCorrectionDraft)
setJournalCorrectionOpen(false)
const fresh = await api.getMediaAssetJournal(journalModal.asset.id)
setJournalModal(fresh)
} catch (e) {
alert(e.message || String(e))
} finally {
setJournalCorrectionBusy(false)
}
}}
>
{journalCorrectionBusy ? 'Speichern…' : 'Korrektur speichern'}
</button>
<button
className="btn btn-secondary"
disabled={journalCorrectionBusy}
onClick={() => setJournalCorrectionOpen(false)}
>
Abbrechen
</button>
</div>
</div>
) : null}
</div>
</div>
</div>

View File

@ -669,6 +669,13 @@ export async function getMediaAssetJournal(assetId) {
return request(`/api/admin/media-rights/assets/${assetId}/journal`)
}
export async function addMediaAssetDeclarationCorrection(assetId, body) {
return request(`/api/admin/media-rights/assets/${assetId}/correction`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export async function attachExerciseMediaFromAsset(exerciseId, body) {
return request(`/api/exercises/${exerciseId}/media/from-asset`, {
method: 'POST',
@ -1421,6 +1428,7 @@ export const api = {
bulkPatchMediaAssets,
bulkUploadMediaAssets,
getMediaAssetJournal,
addMediaAssetDeclarationCorrection,
attachExerciseMediaFromAsset,
listExerciseProgressionGraphs,
getExerciseProgressionGraph,

View File

@ -1,6 +1,6 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.81"
export const APP_VERSION = "0.8.82"
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.4.0", // P-06: Rechte-Dialog bei Sichtbarkeits-Promotion
MediaLibraryPage: "1.5.0", // P-06: Volljournal mit Audit-Log und Korrektur-Formular
ExerciseInlineFileMediaModal: "1.1.0", // P-06: RightsDeclarationDialog vor Upload
ExerciseInlineEmbedModal: "1.1.0", // P-06: RightsDeclarationDialog vor Embed-Upload
}