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
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:
parent
56e952f084
commit
b2b7bd423d
|
|
@ -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(
|
||||
|
|
|
|||
47
backend/migrations/050_media_audit_log.sql
Normal file
47
backend/migrations/050_media_audit_log.sql
Normal 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'
|
||||
));
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user