DGSVO Compliance update 1 #30
|
|
@ -10,6 +10,7 @@ VORLAEUTIG: Juristische Validierung der Felder und Texte steht aus.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json as _json
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
@ -326,6 +327,78 @@ def write_rights_declaration(
|
||||||
return int(row[0])
|
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:
|
def update_rights_quick_fields(cur: Any, asset_id: int, target_visibility: str) -> None:
|
||||||
"""Setzt die Schnellfelder in media_assets nach erfolgreicher Deklaration."""
|
"""Setzt die Schnellfelder in media_assets nach erfolgreicher Deklaration."""
|
||||||
cur.execute(
|
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,
|
update_rights_quick_fields,
|
||||||
validate_rights_declaration,
|
validate_rights_declaration,
|
||||||
write_rights_declaration,
|
write_rights_declaration,
|
||||||
|
write_audit_log_entry,
|
||||||
|
write_rights_correction_declaration,
|
||||||
check_rights_coverage,
|
check_rights_coverage,
|
||||||
VISIBILITY_LEVELS,
|
VISIBILITY_LEVELS,
|
||||||
)
|
)
|
||||||
|
|
@ -112,6 +114,25 @@ class RightsDeclarationBody(BaseModel):
|
||||||
third_party_rights_context: Optional[str] = Field(None, max_length=2000)
|
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):
|
class MediaBulkLifecycleBody(BaseModel):
|
||||||
media_asset_ids: list[int] = Field(..., min_length=1, max_length=200)
|
media_asset_ids: list[int] = Field(..., min_length=1, max_length=200)
|
||||||
action: Literal[
|
action: Literal[
|
||||||
|
|
@ -627,6 +648,7 @@ def _apply_lifecycle_action(
|
||||||
|
|
||||||
action = body.action
|
action = body.action
|
||||||
role_raw = tenant.global_role
|
role_raw = tenant.global_role
|
||||||
|
old_lc = (asset.get("lifecycle_state") or LC_ACTIVE).strip()
|
||||||
|
|
||||||
if action == "superadmin_hard_delete":
|
if action == "superadmin_hard_delete":
|
||||||
if not is_superadmin(role_raw):
|
if not is_superadmin(role_raw):
|
||||||
|
|
@ -641,7 +663,13 @@ def _apply_lifecycle_action(
|
||||||
raise HTTPException(status_code=403, detail="Nur Superadmin")
|
raise HTTPException(status_code=403, detail="Nur Superadmin")
|
||||||
tl = body.target_lifecycle or "active"
|
tl = body.target_lifecycle or "active"
|
||||||
mp = {"active": LC_ACTIVE, "trash_soft": LC_TRASH_SOFT, "trash_hidden": LC_TRASH_HIDDEN}
|
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 action == "purge":
|
||||||
if not is_superadmin(role_raw):
|
if not is_superadmin(role_raw):
|
||||||
|
|
@ -658,16 +686,32 @@ def _apply_lifecycle_action(
|
||||||
|
|
||||||
if action == "trash_soft":
|
if action == "trash_soft":
|
||||||
assert_can_trash_soft(cur, tenant, asset)
|
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":
|
if action == "trash_hidden":
|
||||||
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
|
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":
|
if action == "recover":
|
||||||
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
|
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":
|
if action == "reactivate":
|
||||||
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
|
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")
|
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
|
cur, asset_id, profile_id, _p06_action, next_vis, _p06_pending_decl
|
||||||
)
|
)
|
||||||
update_rights_quick_fields(cur, asset_id, next_vis)
|
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()
|
conn.commit()
|
||||||
has_tags = _media_assets_tags_column_present(cur)
|
has_tags = _media_assets_tags_column_present(cur)
|
||||||
if has_tags:
|
if has_tags:
|
||||||
|
|
@ -1699,14 +1769,17 @@ def get_media_asset_journal(
|
||||||
asset_id: int,
|
asset_id: int,
|
||||||
tenant: TenantContext = Depends(get_tenant_context),
|
tenant: TenantContext = Depends(get_tenant_context),
|
||||||
):
|
):
|
||||||
"""P-06 Superadmin: Vollständiges Deklarationsjournal für ein Medium."""
|
"""Vollständiges Medien-Journal: Einwilligungen + alle Änderungen chronologisch.
|
||||||
if not is_superadmin(tenant.global_role):
|
|
||||||
raise HTTPException(status_code=403, detail="Keine Berechtigung (Superadmin erforderlich)")
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
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,
|
rights_declared_for_visibility, rights_declared_at,
|
||||||
copyright_notice, mime_type, lifecycle_state,
|
copyright_notice, mime_type, lifecycle_state,
|
||||||
uploaded_by_profile_id, created_at
|
uploaded_by_profile_id, created_at
|
||||||
|
|
@ -1719,6 +1792,23 @@ def get_media_asset_journal(
|
||||||
if not asset_row:
|
if not asset_row:
|
||||||
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
||||||
asset = r2d(asset_row)
|
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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT d.id, d.declared_at, d.action_type, d.target_visibility,
|
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.music_rights_context,
|
||||||
d.contains_third_party_content, d.third_party_rights_confirmed,
|
d.contains_third_party_content, d.third_party_rights_confirmed,
|
||||||
d.third_party_rights_context,
|
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.name AS declared_by_name,
|
||||||
p.email AS declared_by_email
|
p.email AS declared_by_email
|
||||||
FROM media_asset_rights_declarations d
|
FROM media_asset_rights_declarations d
|
||||||
LEFT JOIN profiles p ON p.id = d.declared_by_profile_id
|
LEFT JOIN profiles p ON p.id = d.declared_by_profile_id
|
||||||
WHERE d.media_asset_id = %s
|
WHERE d.media_asset_id = %s
|
||||||
ORDER BY d.declared_at DESC
|
ORDER BY d.declared_at ASC
|
||||||
""",
|
""",
|
||||||
(asset_id,),
|
(asset_id,),
|
||||||
)
|
)
|
||||||
declarations = [r2d(r) for r in cur.fetchall()]
|
events: list[dict] = []
|
||||||
return {"asset": asset, "declarations": declarations}
|
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
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.81"
|
APP_VERSION = "0.8.82"
|
||||||
BUILD_DATE = "2026-05-11"
|
BUILD_DATE = "2026-05-11"
|
||||||
DB_SCHEMA_VERSION = "20260511048"
|
DB_SCHEMA_VERSION = "20260511050"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"legal_documents": "1.2.0", # jsPDF-Download auf LegalPage (oeffentlich) + Admin; Abschnitts-Sortierung/-Einfuegen
|
"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)
|
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
||||||
"admin_users": "1.0.0", # GET /api/admin/users
|
"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)
|
"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_rights": "1.2.0", # P-06+: write_audit_log_entry + write_rights_correction_declaration (Migration 050)
|
||||||
"media_assets": "1.15.0", # P-06+: Superadmin-Journal GET /api/admin/media-rights/assets/{id}/journal
|
"media_assets": "1.16.0", # P-06+: Volljournal (audit_log + Korrektur); PATCH/lifecycle loggen Aenderungen
|
||||||
"groups": "0.1.0",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.0",
|
"skills": "0.1.0",
|
||||||
"methods": "0.1.0",
|
"methods": "0.1.0",
|
||||||
|
|
@ -31,6 +31,15 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.81",
|
||||||
"date": "2026-05-11",
|
"date": "2026-05-11",
|
||||||
|
|
|
||||||
|
|
@ -6619,3 +6619,58 @@ a.analysis-split__nav-item {
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
font-style: italic;
|
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 }) {
|
function JournalBoolField({ label, val, context }) {
|
||||||
if (val === null || val === undefined) return null
|
if (val === null || val === undefined) return null
|
||||||
return (
|
return (
|
||||||
|
|
@ -292,6 +328,9 @@ export default function MediaLibraryPage() {
|
||||||
const [pendingPatchBody, setPendingPatchBody] = useState(null)
|
const [pendingPatchBody, setPendingPatchBody] = useState(null)
|
||||||
const [journalModal, setJournalModal] = useState(null)
|
const [journalModal, setJournalModal] = useState(null)
|
||||||
const [journalLoading, setJournalLoading] = useState(false)
|
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 mediaListFetchSeqRef = useRef(0)
|
||||||
const gridTopAnchorRef = useRef(null)
|
const gridTopAnchorRef = useRef(null)
|
||||||
|
|
||||||
|
|
@ -1432,7 +1471,7 @@ export default function MediaLibraryPage() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="media-library__icon-btn"
|
className="media-library__icon-btn"
|
||||||
onClick={() => setJournalModal(null)}
|
onClick={() => { setJournalModal(null); setJournalCorrectionOpen(false) }}
|
||||||
aria-label="Schließen"
|
aria-label="Schließen"
|
||||||
>
|
>
|
||||||
<X size={22} />
|
<X size={22} />
|
||||||
|
|
@ -1456,17 +1495,66 @@ export default function MediaLibraryPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{journalModal.declarations.length === 0 ? (
|
{/* Chronologische Event-Timeline */}
|
||||||
<p className="media-library__hint">Noch keine Einwilligungserklärungen für dieses Medium erfasst.</p>
|
{(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">
|
<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
|
const byLabel = d.declared_by_name || d.declared_by_email
|
||||||
|| (d.declared_by_profile_id ? `Profil #${d.declared_by_profile_id}` : '—')
|
|| (d.declared_by_profile_id ? `Profil #${d.declared_by_profile_id}` : '—')
|
||||||
return (
|
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">
|
<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-arrow">→</span>
|
||||||
<span className="media-library__journal-vis">{visibilityUiLabel(d.target_visibility)}</span>
|
<span className="media-library__journal-vis">{visibilityUiLabel(d.target_visibility)}</span>
|
||||||
<span className="media-library__journal-date">
|
<span className="media-library__journal-date">
|
||||||
|
|
@ -1477,6 +1565,11 @@ export default function MediaLibraryPage() {
|
||||||
{byLabel}
|
{byLabel}
|
||||||
<span className="media-library__journal-version"> · {d.declaration_version}</span>
|
<span className="media-library__journal-version"> · {d.declaration_version}</span>
|
||||||
</div>
|
</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">
|
<div className="media-library__journal-fields">
|
||||||
<JournalBoolField label="Rechteinhaber bestätigt" val={d.rights_holder_confirmed} />
|
<JournalBoolField label="Rechteinhaber bestätigt" val={d.rights_holder_confirmed} />
|
||||||
<JournalBoolField label="Erkennbare Personen" val={d.contains_identifiable_persons} />
|
<JournalBoolField label="Erkennbare Personen" val={d.contains_identifiable_persons} />
|
||||||
|
|
@ -1517,6 +1610,199 @@ export default function MediaLibraryPage() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -669,6 +669,13 @@ export async function getMediaAssetJournal(assetId) {
|
||||||
return request(`/api/admin/media-rights/assets/${assetId}/journal`)
|
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) {
|
export async function attachExerciseMediaFromAsset(exerciseId, body) {
|
||||||
return request(`/api/exercises/${exerciseId}/media/from-asset`, {
|
return request(`/api/exercises/${exerciseId}/media/from-asset`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -1421,6 +1428,7 @@ export const api = {
|
||||||
bulkPatchMediaAssets,
|
bulkPatchMediaAssets,
|
||||||
bulkUploadMediaAssets,
|
bulkUploadMediaAssets,
|
||||||
getMediaAssetJournal,
|
getMediaAssetJournal,
|
||||||
|
addMediaAssetDeclarationCorrection,
|
||||||
attachExerciseMediaFromAsset,
|
attachExerciseMediaFromAsset,
|
||||||
listExerciseProgressionGraphs,
|
listExerciseProgressionGraphs,
|
||||||
getExerciseProgressionGraph,
|
getExerciseProgressionGraph,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Shinkan Jinkendo Frontend Version
|
// 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 BUILD_DATE = "2026-05-11"
|
||||||
|
|
||||||
export const PAGE_VERSIONS = {
|
export const PAGE_VERSIONS = {
|
||||||
|
|
@ -20,7 +20,7 @@ export const PAGE_VERSIONS = {
|
||||||
TrainingCoachPage: "1.0.0",
|
TrainingCoachPage: "1.0.0",
|
||||||
AdminCatalogsPage: "2.2.0",
|
AdminCatalogsPage: "2.2.0",
|
||||||
TrainerContextsPage: "1.0.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
|
ExerciseInlineFileMediaModal: "1.1.0", // P-06: RightsDeclarationDialog vor Upload
|
||||||
ExerciseInlineEmbedModal: "1.1.0", // P-06: RightsDeclarationDialog vor Embed-Upload
|
ExerciseInlineEmbedModal: "1.1.0", // P-06: RightsDeclarationDialog vor Embed-Upload
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user