feat(P-11): Implement Legal Hold functionality for media assets
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 10s
Test Suite / playwright-tests (push) Successful in 54s
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 10s
Test Suite / playwright-tests (push) Successful in 54s
- Added backend support for Legal Hold with new endpoints to set and release holds on media assets. - Introduced new database columns for managing Legal Hold status and reasons. - Updated frontend to include UI elements for setting and releasing Legal Holds, including a confirmation dialog. - Enhanced Media Library page to display Legal Hold status and actions for superadmins. - Implemented comprehensive backend tests covering all aspects of Legal Hold functionality. - Updated documentation to reflect changes in the upload rights specification and interface models. - Bumped version to 0.8.84 and updated MediaLibraryPage version to 1.6.0.
This commit is contained in:
parent
1640fe6045
commit
1ce6d929ce
|
|
@ -206,6 +206,7 @@ app.include_router(admin_users.router)
|
|||
app.include_router(platform_media_storage.router)
|
||||
app.include_router(media_assets.router)
|
||||
app.include_router(media_assets.admin_rights_router)
|
||||
app.include_router(media_assets.admin_legal_hold_router)
|
||||
app.include_router(skills.router)
|
||||
app.include_router(training_planning.router)
|
||||
app.include_router(training_framework_programs.router)
|
||||
|
|
|
|||
247
backend/media_legal_hold.py
Normal file
247
backend/media_legal_hold.py
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
"""P-11: Legal-Hold-Services fuer Medien-Assets.
|
||||
|
||||
Sofortsperrung bei Rechtsverletzungen (Compliance-Paket P-11).
|
||||
|
||||
Klare Trennung:
|
||||
- P-06: Rechteerklaerungs-/Deklarationsstatus (rights_status='declared'/'legacy_unreviewed')
|
||||
- P-11: Legal-Hold-Sperre (legal_hold_active + Metadaten)
|
||||
- P-03: Normaler Papierkorb-Lifecycle (lifecycle_state)
|
||||
- P-13: Meldeverfahren (spaeter; nutzt denselben set_legal_hold-Service)
|
||||
|
||||
Berechtigungen:
|
||||
- Setzen und Aufheben: ausschliesslich Superadmin.
|
||||
- Lesezugriff auf Legal-Hold-Status: Superadmin und Plattform-Admin.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from club_tenancy import is_superadmin
|
||||
from media_rights import write_audit_log_entry
|
||||
|
||||
LEGAL_HOLD_REASON_CODES = {
|
||||
"rights_dispute",
|
||||
"consent_withdrawn",
|
||||
"privacy_complaint",
|
||||
"copyright_complaint",
|
||||
"youth_protection",
|
||||
"illegal_content",
|
||||
"other",
|
||||
}
|
||||
|
||||
|
||||
def assert_superadmin_for_legal_hold(role: Optional[str]) -> None:
|
||||
if not is_superadmin(role):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Legal-Hold-Aktionen sind nur fuer Superadmins verfuegbar.",
|
||||
)
|
||||
|
||||
|
||||
def is_media_available_for_normal_use(asset: dict) -> bool:
|
||||
"""True wenn das Medium fuer normale Nutzerpfade verfuegbar ist.
|
||||
|
||||
Ein Medium ist NICHT verfuegbar wenn legal_hold_active = True,
|
||||
unabhaengig vom lifecycle_state.
|
||||
"""
|
||||
return not bool(asset.get("legal_hold_active"))
|
||||
|
||||
|
||||
def assert_not_under_legal_hold(asset: dict) -> None:
|
||||
"""Wirft HTTPException 403 wenn das Medium unter Legal Hold steht."""
|
||||
if bool(asset.get("legal_hold_active")):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"code": "LEGAL_HOLD_ACTIVE",
|
||||
"message": (
|
||||
"Dieses Medium ist durch einen Administrator sofort gesperrt und "
|
||||
"kann nicht verwendet werden."
|
||||
),
|
||||
"asset_id": asset.get("id"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def set_legal_hold(
|
||||
cur: Any,
|
||||
conn: Any,
|
||||
asset_id: int,
|
||||
acting_profile_id: int,
|
||||
reason_code: str,
|
||||
reason_note: Optional[str],
|
||||
) -> dict:
|
||||
"""Setzt Legal Hold auf ein Medium. Nur Superadmin.
|
||||
|
||||
Wirkung:
|
||||
- legal_hold_active = TRUE
|
||||
- legal_hold_reason_code, legal_hold_reason_note, legal_hold_set_by_profile_id, legal_hold_set_at
|
||||
- rights_status = 'blocked' (Spiegel fuer schnelle Checks)
|
||||
- Audit-Log-Eintrag 'legal_hold_set'
|
||||
|
||||
Gibt das aktualisierte Asset-Dict zurueck.
|
||||
"""
|
||||
if reason_code not in LEGAL_HOLD_REASON_CODES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Ungueltiger reason_code. Erlaubt: {sorted(LEGAL_HOLD_REASON_CODES)}",
|
||||
)
|
||||
if not reason_note or not reason_note.strip():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="reason_note (Begruendung) ist Pflicht beim Setzen eines Legal Holds.",
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""SELECT id, legal_hold_active, rights_status, lifecycle_state, visibility
|
||||
FROM media_assets WHERE id = %s""",
|
||||
(asset_id,),
|
||||
)
|
||||
from db import r2d
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
||||
asset = r2d(row)
|
||||
|
||||
if bool(asset.get("legal_hold_active")):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Dieses Medium befindet sich bereits unter Legal Hold.",
|
||||
)
|
||||
|
||||
old_rights_status = asset.get("rights_status") or "legacy_unreviewed"
|
||||
|
||||
cur.execute(
|
||||
"""UPDATE media_assets
|
||||
SET legal_hold_active = TRUE,
|
||||
legal_hold_reason_code = %s,
|
||||
legal_hold_reason_note = %s,
|
||||
legal_hold_set_by_profile_id = %s,
|
||||
legal_hold_set_at = NOW(),
|
||||
legal_hold_released_by_profile_id = NULL,
|
||||
legal_hold_released_at = NULL,
|
||||
legal_hold_release_note = NULL,
|
||||
rights_status = 'blocked',
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
RETURNING id, legal_hold_active, legal_hold_reason_code, legal_hold_reason_note,
|
||||
legal_hold_set_by_profile_id, legal_hold_set_at, rights_status,
|
||||
lifecycle_state, visibility""",
|
||||
(reason_code, reason_note.strip()[:2000], acting_profile_id, asset_id),
|
||||
)
|
||||
updated_row = cur.fetchone()
|
||||
updated = r2d(updated_row)
|
||||
|
||||
write_audit_log_entry(
|
||||
cur,
|
||||
asset_id=asset_id,
|
||||
acting_profile_id=acting_profile_id,
|
||||
event_type="legal_hold_set",
|
||||
old_values={"rights_status": old_rights_status, "legal_hold_active": False},
|
||||
new_values={
|
||||
"rights_status": "blocked",
|
||||
"legal_hold_active": True,
|
||||
"reason_code": reason_code,
|
||||
"reason_note": reason_note.strip()[:2000] if reason_note else None,
|
||||
},
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return updated
|
||||
|
||||
|
||||
def release_legal_hold(
|
||||
cur: Any,
|
||||
conn: Any,
|
||||
asset_id: int,
|
||||
acting_profile_id: int,
|
||||
release_note: Optional[str],
|
||||
) -> dict:
|
||||
"""Hebt Legal Hold auf. Nur Superadmin.
|
||||
|
||||
Wirkung:
|
||||
- legal_hold_active = FALSE
|
||||
- legal_hold_released_by_profile_id, legal_hold_released_at, legal_hold_release_note
|
||||
- rights_status: zurueck auf 'declared' wenn vorher declared war, sonst 'legacy_unreviewed'
|
||||
(Entscheidungslogik: war vor dem Hold ein Deklarations-Eintrag vorhanden?)
|
||||
- Audit-Log-Eintrag 'legal_hold_released'
|
||||
|
||||
Gibt das aktualisierte Asset-Dict zurueck.
|
||||
"""
|
||||
if not release_note or not release_note.strip():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="release_note (Freigabe-Begruendung) ist Pflicht.",
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""SELECT id, legal_hold_active, rights_status, lifecycle_state, visibility,
|
||||
legal_hold_reason_code, legal_hold_reason_note
|
||||
FROM media_assets WHERE id = %s""",
|
||||
(asset_id,),
|
||||
)
|
||||
from db import r2d
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
||||
asset = r2d(row)
|
||||
|
||||
if not bool(asset.get("legal_hold_active")):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Dieses Medium befindet sich nicht unter Legal Hold.",
|
||||
)
|
||||
|
||||
# Bestimme den rights_status nach Freigabe:
|
||||
# Gibt es eine gueltige Deklaration? → 'declared', sonst 'legacy_unreviewed'
|
||||
cur.execute(
|
||||
"""SELECT COUNT(*) AS cnt FROM media_asset_rights_declarations
|
||||
WHERE media_asset_id = %s
|
||||
AND action_type NOT IN ('correction')
|
||||
AND rights_holder_confirmed = TRUE""",
|
||||
(asset_id,),
|
||||
)
|
||||
decl_row = cur.fetchone()
|
||||
decl_count = int(decl_row[0] if not hasattr(decl_row, "keys") else decl_row["cnt"])
|
||||
restored_rights_status = "declared" if decl_count > 0 else "legacy_unreviewed"
|
||||
|
||||
cur.execute(
|
||||
"""UPDATE media_assets
|
||||
SET legal_hold_active = FALSE,
|
||||
legal_hold_released_by_profile_id = %s,
|
||||
legal_hold_released_at = NOW(),
|
||||
legal_hold_release_note = %s,
|
||||
rights_status = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
RETURNING id, legal_hold_active, rights_status, lifecycle_state, visibility,
|
||||
legal_hold_reason_code, legal_hold_reason_note,
|
||||
legal_hold_set_by_profile_id, legal_hold_set_at,
|
||||
legal_hold_released_by_profile_id, legal_hold_released_at,
|
||||
legal_hold_release_note""",
|
||||
(acting_profile_id, release_note.strip()[:2000], restored_rights_status, asset_id),
|
||||
)
|
||||
updated_row = cur.fetchone()
|
||||
updated = r2d(updated_row)
|
||||
|
||||
write_audit_log_entry(
|
||||
cur,
|
||||
asset_id=asset_id,
|
||||
acting_profile_id=acting_profile_id,
|
||||
event_type="legal_hold_released",
|
||||
old_values={
|
||||
"rights_status": "blocked",
|
||||
"legal_hold_active": True,
|
||||
"reason_code": asset.get("legal_hold_reason_code"),
|
||||
},
|
||||
new_values={
|
||||
"rights_status": restored_rights_status,
|
||||
"legal_hold_active": False,
|
||||
"release_note": release_note.strip()[:2000] if release_note else None,
|
||||
},
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return updated
|
||||
|
|
@ -279,6 +279,10 @@ def run_retention_pass(cur: Any, conn: Any) -> dict:
|
|||
"""
|
||||
Automatik: trash_soft älter als SOFT_TO_HIDDEN_DAYS → trash_hidden;
|
||||
trash_hidden mit purge_after_at in der Vergangenheit → purge.
|
||||
|
||||
P-11: Medien unter aktivem Legal Hold werden NICHT gerpurged (legal_hold_active = TRUE).
|
||||
Die Retention verschiebt sie auch nicht automatisch von trash_soft nach trash_hidden —
|
||||
Legal-Hold-Status hat Vorrang vor dem Papierkorb-Lifecycle.
|
||||
"""
|
||||
cutoff_soft = datetime.now(timezone.utc) - timedelta(days=SOFT_TO_HIDDEN_DAYS)
|
||||
cur.execute(
|
||||
|
|
@ -286,6 +290,7 @@ def run_retention_pass(cur: Any, conn: Any) -> dict:
|
|||
SET lifecycle_state = %s, trash_hidden_at = NOW(), updated_at = NOW(),
|
||||
purge_after_at = NOW() + (%s * INTERVAL '1 day')
|
||||
WHERE lifecycle_state = %s AND trash_soft_at IS NOT NULL AND trash_soft_at <= %s
|
||||
AND (legal_hold_active = FALSE OR legal_hold_active IS NULL)
|
||||
RETURNING id""",
|
||||
(LC_TRASH_HIDDEN, HIDDEN_TO_PURGE_DAYS, LC_TRASH_SOFT, cutoff_soft),
|
||||
)
|
||||
|
|
@ -294,7 +299,8 @@ def run_retention_pass(cur: Any, conn: Any) -> dict:
|
|||
|
||||
cur.execute(
|
||||
"""SELECT id FROM media_assets
|
||||
WHERE lifecycle_state = %s AND purge_after_at IS NOT NULL AND purge_after_at <= NOW()""",
|
||||
WHERE lifecycle_state = %s AND purge_after_at IS NOT NULL AND purge_after_at <= NOW()
|
||||
AND (legal_hold_active = FALSE OR legal_hold_active IS NULL)""",
|
||||
(LC_TRASH_HIDDEN,),
|
||||
)
|
||||
purge_ids = [r2d(r)["id"] for r in cur.fetchall()]
|
||||
|
|
|
|||
73
backend/migrations/051_legal_hold.sql
Normal file
73
backend/migrations/051_legal_hold.sql
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
-- Migration 051: P-11 Legal-Hold Lifecycle-Status
|
||||
-- Sofortsperrung fuer rechtlich problematische Medien (Compliance-Paket P-11)
|
||||
--
|
||||
-- Architekturentscheidung:
|
||||
-- rights_status='blocked' bleibt als Spiegel-Schnellstatus (P-06-Kompatibilitaet).
|
||||
-- Primaere Wahrheit: legal_hold_active + dedizierte Metadaten-Felder in media_assets.
|
||||
-- Dies ermoeglicht klare Trennung: P-06 Deklarationsstatus / P-11 Legal Hold / P-03 Lifecycle.
|
||||
-- P-13 kann spaeter denselben set_legal_hold-Service nutzen.
|
||||
|
||||
ALTER TABLE media_assets
|
||||
ADD COLUMN IF NOT EXISTS legal_hold_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS legal_hold_reason_code VARCHAR(50)
|
||||
CHECK (legal_hold_reason_code IN (
|
||||
'rights_dispute',
|
||||
'consent_withdrawn',
|
||||
'privacy_complaint',
|
||||
'copyright_complaint',
|
||||
'youth_protection',
|
||||
'illegal_content',
|
||||
'other'
|
||||
)),
|
||||
ADD COLUMN IF NOT EXISTS legal_hold_reason_note TEXT,
|
||||
ADD COLUMN IF NOT EXISTS legal_hold_set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS legal_hold_set_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS legal_hold_released_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS legal_hold_released_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS legal_hold_release_note TEXT;
|
||||
|
||||
COMMENT ON COLUMN media_assets.legal_hold_active IS
|
||||
'P-11: TRUE = Medium unter Legal Hold; sofortige Sperrung fuer alle normalen Nutzerpfade. '
|
||||
'Retention-Job darf dieses Medium nicht purgen. '
|
||||
'rights_status wird bei Aktivierung auf ''blocked'' gespiegelt.';
|
||||
|
||||
COMMENT ON COLUMN media_assets.legal_hold_reason_code IS
|
||||
'P-11: Kategorie des Legal Holds. Pflicht beim Setzen.';
|
||||
|
||||
COMMENT ON COLUMN media_assets.legal_hold_reason_note IS
|
||||
'P-11: Freitext-Begruendung fuer den Legal Hold.';
|
||||
|
||||
COMMENT ON COLUMN media_assets.legal_hold_set_by_profile_id IS
|
||||
'P-11: Profil das den Legal Hold gesetzt hat (Superadmin).';
|
||||
|
||||
COMMENT ON COLUMN media_assets.legal_hold_set_at IS
|
||||
'P-11: Zeitpunkt der Legal-Hold-Aktivierung.';
|
||||
|
||||
COMMENT ON COLUMN media_assets.legal_hold_released_by_profile_id IS
|
||||
'P-11: Profil das den Legal Hold aufgehoben hat.';
|
||||
|
||||
COMMENT ON COLUMN media_assets.legal_hold_released_at IS
|
||||
'P-11: Zeitpunkt der Legal-Hold-Freigabe.';
|
||||
|
||||
COMMENT ON COLUMN media_assets.legal_hold_release_note IS
|
||||
'P-11: Begruendung fuer die Aufhebung des Legal Holds.';
|
||||
|
||||
-- Index fuer Admin-Liste aktiver Legal Holds
|
||||
CREATE INDEX IF NOT EXISTS idx_media_assets_legal_hold_active
|
||||
ON media_assets (legal_hold_active)
|
||||
WHERE legal_hold_active = TRUE;
|
||||
|
||||
-- Neue event_types fuer media_asset_audit_log
|
||||
ALTER TABLE media_asset_audit_log
|
||||
DROP CONSTRAINT IF EXISTS media_asset_audit_log_event_type_check;
|
||||
|
||||
ALTER TABLE media_asset_audit_log
|
||||
ADD CONSTRAINT media_asset_audit_log_event_type_check
|
||||
CHECK (event_type IN (
|
||||
'visibility_change',
|
||||
'copyright_change',
|
||||
'metadata_change',
|
||||
'lifecycle_change',
|
||||
'legal_hold_set',
|
||||
'legal_hold_released'
|
||||
));
|
||||
|
|
@ -30,6 +30,7 @@ from club_tenancy import (
|
|||
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible, library_content_visibility_sql
|
||||
from media_storage import get_effective_media_root, library_storage_key, path_under_media_root
|
||||
from media_rights import assert_rights_for_exercise_link, validate_rights_declaration, write_rights_declaration, update_rights_quick_fields
|
||||
from media_legal_hold import assert_not_under_legal_hold
|
||||
from exercise_rich_text import (
|
||||
RICH_HTML_EXERCISE_FIELDS,
|
||||
assert_no_inline_media_references_on_create,
|
||||
|
|
@ -2880,7 +2881,7 @@ def attach_exercise_media_from_asset(
|
|||
|
||||
cur.execute(
|
||||
"""SELECT id, mime_type, byte_size, original_filename, visibility, club_id,
|
||||
uploaded_by_profile_id, storage_key, lifecycle_state
|
||||
uploaded_by_profile_id, storage_key, lifecycle_state, legal_hold_active
|
||||
FROM media_assets WHERE id = %s""",
|
||||
(body.media_asset_id,),
|
||||
)
|
||||
|
|
@ -2893,6 +2894,8 @@ def attach_exercise_media_from_asset(
|
|||
status_code=400,
|
||||
detail="Nur aktive Archiv-Medien können verknüpft werden",
|
||||
)
|
||||
# P-11: Legal Hold verhindert neue Einbindung in Übungen
|
||||
assert_not_under_legal_hold(asset)
|
||||
|
||||
if not library_content_visible_to_profile(
|
||||
cur,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,14 @@ from media_rights import (
|
|||
check_rights_coverage,
|
||||
VISIBILITY_LEVELS,
|
||||
)
|
||||
from media_legal_hold import (
|
||||
LEGAL_HOLD_REASON_CODES,
|
||||
assert_not_under_legal_hold,
|
||||
assert_superadmin_for_legal_hold,
|
||||
is_media_available_for_normal_use,
|
||||
set_legal_hold,
|
||||
release_legal_hold,
|
||||
)
|
||||
from routers.exercises import _upload_limit_bytes, resolve_upload_mime_type
|
||||
|
||||
router = APIRouter(prefix="/api/media-assets", tags=["media-assets"])
|
||||
|
|
@ -456,8 +464,16 @@ def _relocate_asset_file_if_governance_changed(
|
|||
|
||||
|
||||
|
||||
def _list_active_visibility_clause(is_plat: bool, profile_id: int) -> tuple[str, list[Any]]:
|
||||
"""Sichtbare aktive Einträge: official; private (eigen oder Plattform-Admin); Verein wie Bibliotheks-SQL."""
|
||||
def _list_active_visibility_clause(
|
||||
is_plat: bool,
|
||||
profile_id: int,
|
||||
include_legal_hold: bool = False,
|
||||
) -> tuple[str, list[Any]]:
|
||||
"""Sichtbare aktive Einträge: official; private (eigen oder Plattform-Admin); Verein wie Bibliotheks-SQL.
|
||||
|
||||
P-11: Legal-Hold-Medien sind für normale Nutzer nicht sichtbar.
|
||||
is_plat/is_sup können include_legal_hold=True übergeben, um sie weiterhin zu sehen.
|
||||
"""
|
||||
parts = ["lower(trim(ma.visibility)) = 'official'"]
|
||||
vals: list[Any] = []
|
||||
|
||||
|
|
@ -491,8 +507,13 @@ def _list_active_visibility_clause(is_plat: bool, profile_id: int) -> tuple[str,
|
|||
)
|
||||
vals.append(profile_id)
|
||||
|
||||
sql = "(" + " OR ".join(parts) + ")"
|
||||
return sql, vals
|
||||
vis_sql = "(" + " OR ".join(parts) + ")"
|
||||
|
||||
# P-11: Legal-Hold-Medien für normale Nutzer ausblenden
|
||||
if not include_legal_hold:
|
||||
vis_sql = f"({vis_sql} AND (ma.legal_hold_active = FALSE OR ma.legal_hold_active IS NULL))"
|
||||
|
||||
return vis_sql, vals
|
||||
|
||||
|
||||
def _list_trash_visibility_clause(
|
||||
|
|
@ -530,12 +551,15 @@ def _list_main_visibility_where(
|
|||
) -> tuple[str, list[Any]]:
|
||||
"""
|
||||
Kombiniert lifecycle mit Leserechten. Papierkorb-Stufen für normale Nutzer stark eingeschränkt.
|
||||
P-11: Plattform-Admins und Superadmins sehen Legal-Hold-Medien in der aktiven Liste.
|
||||
"""
|
||||
lc = (lifecycle or "active").strip().lower()
|
||||
if lc not in _LIFECYCLE_LIST_FILTERS:
|
||||
raise HTTPException(status_code=400, detail="Ungültiger lifecycle-Filter")
|
||||
|
||||
active_sql, active_params = _list_active_visibility_clause(is_plat, profile_id)
|
||||
active_sql, active_params = _list_active_visibility_clause(
|
||||
is_plat, profile_id, include_legal_hold=(is_plat or is_sup)
|
||||
)
|
||||
trash_sql, trash_params = _list_trash_visibility_clause(
|
||||
is_plat, is_sup, profile_id, admin_club_ids
|
||||
)
|
||||
|
|
@ -1187,9 +1211,30 @@ def download_media_asset_file(
|
|||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
asset = _fetch_asset_file_row(cur, asset_id)
|
||||
# _fetch_asset_file_row wird erweitert um legal_hold_active
|
||||
cur.execute(
|
||||
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
|
||||
storage_key, mime_type, original_filename, legal_hold_active
|
||||
FROM media_assets WHERE id = %s""",
|
||||
(asset_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
asset = r2d(row) if row else None
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
||||
|
||||
# P-11: Legal Hold blockiert normalen Dateizugriff (nicht für Superadmin)
|
||||
if bool(asset.get("legal_hold_active")):
|
||||
role = tenant.global_role if hasattr(tenant, "global_role") else None
|
||||
if not is_superadmin(role):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"code": "LEGAL_HOLD_ACTIVE",
|
||||
"message": "Dieses Medium ist sofort gesperrt und nicht verfügbar.",
|
||||
},
|
||||
)
|
||||
|
||||
lc = (asset.get("lifecycle_state") or "").strip().lower()
|
||||
if lc == "active":
|
||||
_assert_can_view_archive_asset(cur, tenant, asset)
|
||||
|
|
@ -1909,3 +1954,124 @@ def add_rights_correction(
|
|||
conn.commit()
|
||||
|
||||
return {"ok": True, "asset_id": asset_id}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# P-11: Legal-Hold-Endpoints (Superadmin only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
admin_legal_hold_router = APIRouter(prefix="/api/admin/media-assets", tags=["admin", "legal-hold"])
|
||||
|
||||
|
||||
class LegalHoldSetBody(BaseModel):
|
||||
reason_code: str = Field(..., description="Kategorie des Legal Holds")
|
||||
reason_note: str = Field(..., min_length=5, max_length=2000, description="Pflichtbegründung")
|
||||
|
||||
|
||||
class LegalHoldReleaseBody(BaseModel):
|
||||
release_note: str = Field(..., min_length=5, max_length=2000, description="Freigabe-Begründung")
|
||||
|
||||
|
||||
@admin_legal_hold_router.post("/{asset_id}/legal-hold")
|
||||
def post_legal_hold_set(
|
||||
asset_id: int,
|
||||
body: LegalHoldSetBody,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""P-11: Legal Hold auf ein Medium setzen. Nur Superadmin.
|
||||
|
||||
Wirkung: Sofortige Unsichtbarkeit in normalen Nutzerpfaden, rights_status='blocked',
|
||||
Audit-Log-Eintrag. Keine automatische physische Löschung. Keine Löschfristen.
|
||||
"""
|
||||
assert_superadmin_for_legal_hold(tenant.global_role)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
result = set_legal_hold(
|
||||
cur, conn,
|
||||
asset_id=asset_id,
|
||||
acting_profile_id=int(tenant.profile_id),
|
||||
reason_code=body.reason_code,
|
||||
reason_note=body.reason_note,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"asset_id": asset_id,
|
||||
"legal_hold_active": result.get("legal_hold_active"),
|
||||
"rights_status": result.get("rights_status"),
|
||||
"legal_hold_reason_code": result.get("legal_hold_reason_code"),
|
||||
"legal_hold_set_at": str(result.get("legal_hold_set_at", "")),
|
||||
}
|
||||
|
||||
|
||||
@admin_legal_hold_router.post("/{asset_id}/legal-hold/release")
|
||||
def post_legal_hold_release(
|
||||
asset_id: int,
|
||||
body: LegalHoldReleaseBody,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""P-11: Legal Hold aufheben. Nur Superadmin.
|
||||
|
||||
Wirkung: legal_hold_active=FALSE, rights_status wiederhergestellt,
|
||||
Audit-Log-Eintrag. Vorheriger Lifecycle-Zustand bleibt erhalten.
|
||||
"""
|
||||
assert_superadmin_for_legal_hold(tenant.global_role)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
result = release_legal_hold(
|
||||
cur, conn,
|
||||
asset_id=asset_id,
|
||||
acting_profile_id=int(tenant.profile_id),
|
||||
release_note=body.release_note,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"asset_id": asset_id,
|
||||
"legal_hold_active": result.get("legal_hold_active"),
|
||||
"rights_status": result.get("rights_status"),
|
||||
"legal_hold_released_at": str(result.get("legal_hold_released_at", "")),
|
||||
}
|
||||
|
||||
|
||||
@admin_legal_hold_router.get("/legal-hold")
|
||||
def list_legal_hold_assets(
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""P-11: Admin-Liste aller Medien unter aktivem Legal Hold. Superadmin oder Plattform-Admin."""
|
||||
if not is_platform_admin(tenant.global_role):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Keine Berechtigung (Plattform-Admin oder Superadmin erforderlich)",
|
||||
)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT ma.id, ma.original_filename, ma.visibility, ma.club_id,
|
||||
ma.lifecycle_state, ma.rights_status,
|
||||
ma.legal_hold_active, ma.legal_hold_reason_code,
|
||||
ma.legal_hold_reason_note, ma.legal_hold_set_at,
|
||||
ma.legal_hold_set_by_profile_id,
|
||||
p_set.name AS set_by_name,
|
||||
ma.uploaded_by_profile_id,
|
||||
p_upl.name AS uploader_name,
|
||||
cl.name AS club_name,
|
||||
ma.created_at
|
||||
FROM media_assets ma
|
||||
LEFT JOIN profiles p_set ON p_set.id = ma.legal_hold_set_by_profile_id
|
||||
LEFT JOIN profiles p_upl ON p_upl.id = ma.uploaded_by_profile_id
|
||||
LEFT JOIN clubs cl ON cl.id = ma.club_id
|
||||
WHERE ma.legal_hold_active = TRUE
|
||||
ORDER BY ma.legal_hold_set_at DESC NULLS LAST, ma.id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(limit, offset),
|
||||
)
|
||||
assets = [r2d(r) for r in cur.fetchall()]
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM media_assets WHERE legal_hold_active = TRUE"
|
||||
)
|
||||
total_row = cur.fetchone()
|
||||
total = int(r2d(total_row)["cnt"]) if total_row else 0
|
||||
return {"total": total, "limit": limit, "offset": offset, "assets": assets}
|
||||
|
|
|
|||
281
backend/tests/test_p11_legal_hold.py
Normal file
281
backend/tests/test_p11_legal_hold.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
"""
|
||||
P-11: Legal-Hold – Backend-Tests.
|
||||
|
||||
Abgedeckt (15 Faelle):
|
||||
1. assert_superadmin_for_legal_hold – Superadmin darf
|
||||
2. assert_superadmin_for_legal_hold – Nicht-Superadmin wird geblockt
|
||||
3. is_media_available_for_normal_use – ohne Hold True
|
||||
4. is_media_available_for_normal_use – mit Hold False
|
||||
5. assert_not_under_legal_hold – ohne Hold passiert nichts
|
||||
6. assert_not_under_legal_hold – mit Hold wirft 403 LEGAL_HOLD_ACTIVE
|
||||
7. set_legal_hold – ungueltige reason_code → 400
|
||||
8. set_legal_hold – leere reason_note → 400
|
||||
9. set_legal_hold – Asset bereits unter Hold → 409
|
||||
10. set_legal_hold – Erfolgspfad: DB-Update + Audit-Log
|
||||
11. release_legal_hold – leere release_note → 400
|
||||
12. release_legal_hold – Asset nicht unter Hold → 409
|
||||
13. release_legal_hold – Erfolgspfad ohne Deklaration → legacy_unreviewed
|
||||
14. release_legal_hold – Erfolgspfad mit Deklaration → declared
|
||||
15. Retention-Job ueberspringt Legal-Hold-Assets (run_retention_pass query)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
||||
|
||||
from media_legal_hold import (
|
||||
LEGAL_HOLD_REASON_CODES,
|
||||
assert_not_under_legal_hold,
|
||||
assert_superadmin_for_legal_hold,
|
||||
is_media_available_for_normal_use,
|
||||
release_legal_hold,
|
||||
set_legal_hold,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _asset(legal_hold_active=False, rights_status="legacy_unreviewed", **kw) -> dict:
|
||||
base = {
|
||||
"id": 99,
|
||||
"visibility": "private",
|
||||
"lifecycle_state": "active",
|
||||
"rights_status": rights_status,
|
||||
"legal_hold_active": legal_hold_active,
|
||||
"legal_hold_reason_code": None,
|
||||
"legal_hold_reason_note": None,
|
||||
}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def _make_cur(fetchone_val, fetchone_seq=None):
|
||||
"""Erstellt einen Mock-Cursor.
|
||||
|
||||
fetchone_seq: wenn angegeben, wird fetchone() sequenziell diese Werte liefern.
|
||||
"""
|
||||
cur = MagicMock()
|
||||
if fetchone_seq is not None:
|
||||
cur.fetchone.side_effect = fetchone_seq
|
||||
else:
|
||||
cur.fetchone.return_value = fetchone_val
|
||||
return cur
|
||||
|
||||
|
||||
def _dict_row(d: dict):
|
||||
"""Simuliert ein psycopg2-DictRow-Objekt (keys() + Subscript)."""
|
||||
m = MagicMock()
|
||||
m.keys.return_value = list(d.keys())
|
||||
m.__getitem__ = lambda self, k: d[k]
|
||||
m.__iter__ = lambda self: iter(d.values())
|
||||
return m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1–2: assert_superadmin_for_legal_hold
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAssertSuperadmin:
|
||||
def test_superadmin_passes(self):
|
||||
# kein Fehler
|
||||
assert_superadmin_for_legal_hold("superadmin")
|
||||
|
||||
def test_non_superadmin_raises_403(self):
|
||||
for role in ("admin", "user", "club_admin", None, ""):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
assert_superadmin_for_legal_hold(role)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3–4: is_media_available_for_normal_use
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsMediaAvailable:
|
||||
def test_no_hold_is_available(self):
|
||||
assert is_media_available_for_normal_use(_asset(legal_hold_active=False)) is True
|
||||
|
||||
def test_hold_active_is_not_available(self):
|
||||
assert is_media_available_for_normal_use(_asset(legal_hold_active=True)) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5–6: assert_not_under_legal_hold
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAssertNotUnderLegalHold:
|
||||
def test_no_hold_does_not_raise(self):
|
||||
assert_not_under_legal_hold(_asset(legal_hold_active=False))
|
||||
|
||||
def test_hold_active_raises_403_with_code(self):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
assert_not_under_legal_hold(_asset(legal_hold_active=True, id=55))
|
||||
assert exc.value.status_code == 403
|
||||
detail = exc.value.detail
|
||||
assert isinstance(detail, dict)
|
||||
assert detail["code"] == "LEGAL_HOLD_ACTIVE"
|
||||
assert detail["asset_id"] == 55
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7–10: set_legal_hold
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSetLegalHold:
|
||||
def test_invalid_reason_code_raises_400(self):
|
||||
cur = MagicMock()
|
||||
conn = MagicMock()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
set_legal_hold(cur, conn, asset_id=1, acting_profile_id=1,
|
||||
reason_code="totally_made_up", reason_note="some reason")
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
def test_empty_reason_note_raises_400(self):
|
||||
cur = MagicMock()
|
||||
conn = MagicMock()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
set_legal_hold(cur, conn, asset_id=1, acting_profile_id=1,
|
||||
reason_code="rights_dispute", reason_note="")
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
def test_already_under_hold_raises_409(self):
|
||||
asset = _asset(legal_hold_active=True)
|
||||
cur = _make_cur(_dict_row(asset))
|
||||
conn = MagicMock()
|
||||
with patch("db.r2d", return_value=asset), \
|
||||
patch("media_legal_hold.write_audit_log_entry"):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
set_legal_hold(cur, conn, asset_id=99, acting_profile_id=1,
|
||||
reason_code="rights_dispute", reason_note="Verletzung bestätigt")
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
def test_success_sets_hold_and_writes_audit(self):
|
||||
asset_before = _asset(legal_hold_active=False, rights_status="declared")
|
||||
asset_after = {
|
||||
**asset_before,
|
||||
"legal_hold_active": True,
|
||||
"legal_hold_reason_code": "copyright_complaint",
|
||||
"legal_hold_reason_note": "Urheberrechtsverletzung gemeldet",
|
||||
"legal_hold_set_by_profile_id": 1,
|
||||
"legal_hold_set_at": "2026-05-11T12:00:00Z",
|
||||
"rights_status": "blocked",
|
||||
}
|
||||
cur = _make_cur(None)
|
||||
cur.fetchone.side_effect = [_dict_row(asset_before), _dict_row(asset_after)]
|
||||
conn = MagicMock()
|
||||
|
||||
with patch("db.r2d", side_effect=[asset_before, asset_after]), \
|
||||
patch("media_legal_hold.write_audit_log_entry") as mock_audit:
|
||||
result = set_legal_hold(
|
||||
cur, conn,
|
||||
asset_id=99,
|
||||
acting_profile_id=1,
|
||||
reason_code="copyright_complaint",
|
||||
reason_note="Urheberrechtsverletzung gemeldet",
|
||||
)
|
||||
|
||||
assert result["legal_hold_active"] is True
|
||||
assert result["rights_status"] == "blocked"
|
||||
conn.commit.assert_called_once()
|
||||
mock_audit.assert_called_once()
|
||||
audit_call_kwargs = mock_audit.call_args
|
||||
assert audit_call_kwargs[1]["event_type"] == "legal_hold_set"
|
||||
assert audit_call_kwargs[1]["new_values"]["legal_hold_active"] is True
|
||||
assert audit_call_kwargs[1]["new_values"]["reason_code"] == "copyright_complaint"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11–14: release_legal_hold
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReleaseLegalHold:
|
||||
def test_empty_release_note_raises_400(self):
|
||||
cur = MagicMock()
|
||||
conn = MagicMock()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
release_legal_hold(cur, conn, asset_id=99, acting_profile_id=1, release_note="")
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
def test_not_under_hold_raises_409(self):
|
||||
asset = _asset(legal_hold_active=False)
|
||||
cur = _make_cur(_dict_row(asset))
|
||||
conn = MagicMock()
|
||||
with patch("db.r2d", return_value=asset):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
release_legal_hold(cur, conn, asset_id=99, acting_profile_id=1,
|
||||
release_note="Falschmeldung")
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
def test_release_without_declaration_restores_legacy_unreviewed(self):
|
||||
asset = _asset(legal_hold_active=True, rights_status="blocked")
|
||||
asset_after = {**asset, "legal_hold_active": False, "rights_status": "legacy_unreviewed",
|
||||
"legal_hold_released_by_profile_id": 1, "legal_hold_release_note": "Klaerung abgeschlossen"}
|
||||
cur = _make_cur(None)
|
||||
# fetchone-Sequenz: 1) Asset (fuer r2d), 2) decl count row, 3) updated asset row (fuer r2d)
|
||||
decl_cnt_row = type("Row", (), {"__getitem__": staticmethod(lambda k: 0), "__iter__": staticmethod(lambda: iter([0]))})()
|
||||
cur.fetchone.side_effect = [_dict_row(asset), decl_cnt_row, _dict_row(asset_after)]
|
||||
conn = MagicMock()
|
||||
|
||||
with patch("db.r2d", side_effect=[asset, asset_after]), \
|
||||
patch("media_legal_hold.write_audit_log_entry") as mock_audit:
|
||||
result = release_legal_hold(
|
||||
cur, conn, asset_id=99, acting_profile_id=1,
|
||||
release_note="Klaerung abgeschlossen",
|
||||
)
|
||||
|
||||
assert result["legal_hold_active"] is False
|
||||
assert result["rights_status"] == "legacy_unreviewed"
|
||||
conn.commit.assert_called_once()
|
||||
mock_audit.assert_called_once()
|
||||
assert mock_audit.call_args[1]["event_type"] == "legal_hold_released"
|
||||
|
||||
def test_release_with_declaration_restores_declared(self):
|
||||
asset = _asset(legal_hold_active=True, rights_status="blocked")
|
||||
asset_after = {**asset, "legal_hold_active": False, "rights_status": "declared",
|
||||
"legal_hold_released_by_profile_id": 1, "legal_hold_release_note": "Einigung erzielt"}
|
||||
cur = _make_cur(None)
|
||||
decl_cnt_row = type("Row", (), {"__getitem__": staticmethod(lambda k: 1), "__iter__": staticmethod(lambda: iter([1]))})()
|
||||
cur.fetchone.side_effect = [_dict_row(asset), decl_cnt_row, _dict_row(asset_after)]
|
||||
conn = MagicMock()
|
||||
|
||||
with patch("db.r2d", side_effect=[asset, asset_after]), \
|
||||
patch("media_legal_hold.write_audit_log_entry") as mock_audit:
|
||||
result = release_legal_hold(
|
||||
cur, conn, asset_id=99, acting_profile_id=1,
|
||||
release_note="Einigung erzielt",
|
||||
)
|
||||
|
||||
assert result["legal_hold_active"] is False
|
||||
assert result["rights_status"] == "declared"
|
||||
mock_audit.assert_called_once()
|
||||
assert mock_audit.call_args[1]["new_values"]["rights_status"] == "declared"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 15: Retention-Job ueberspringt Legal-Hold-Assets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRetentionJobSkipsLegalHold:
|
||||
def test_retention_queries_exclude_legal_hold(self):
|
||||
"""Prueft dass run_retention_pass keine Legal-Hold-Assets anfasst."""
|
||||
import inspect
|
||||
import media_lifecycle
|
||||
|
||||
source = inspect.getsource(media_lifecycle.run_retention_pass)
|
||||
|
||||
# Beide Queries (trash_soft→trash_hidden und trash_hidden→purge)
|
||||
# muessen den Legal-Hold-Filter enthalten
|
||||
assert "legal_hold_active" in source, (
|
||||
"run_retention_pass muss legal_hold_active in den Retention-Queries filtern"
|
||||
)
|
||||
# Sicherstellen, dass der Filter korrekt lautet (FALSE oder IS NULL)
|
||||
assert "legal_hold_active = FALSE" in source or "legal_hold_active IS NULL" in source, (
|
||||
"Filter muss 'legal_hold_active = FALSE' oder 'IS NULL' enthalten"
|
||||
)
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.83"
|
||||
APP_VERSION = "0.8.84"
|
||||
BUILD_DATE = "2026-05-11"
|
||||
DB_SCHEMA_VERSION = "20260511050"
|
||||
DB_SCHEMA_VERSION = "20260511051"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"legal_documents": "1.2.0", # jsPDF-Download auf LegalPage (oeffentlich) + Admin; Abschnitts-Sortierung/-Einfuegen
|
||||
|
|
@ -14,12 +14,14 @@ 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.2.0", # P-06+: write_audit_log_entry + write_rights_correction_declaration (Migration 050)
|
||||
"media_assets": "1.16.1", # Fix: journal/correction club_admin check via has_club_role (role column doesn't exist on club_members)
|
||||
"media_rights": "1.3.0", # P-11: write_audit_log_entry + legal_hold_set/released events
|
||||
"media_assets": "1.17.0", # P-11: Legal-Hold-Sichtbarkeitsfilter + Admin-Endpoints (set/release/list)
|
||||
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
|
||||
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
||||
"groups": "0.1.0",
|
||||
"skills": "0.1.0",
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.21.0", # P-06+: copyright_notice + 4 Kontext-Felder in upload_exercise_media
|
||||
"exercises": "2.22.0", # P-11: assert_not_under_legal_hold bei from-asset Medienverknuepfung
|
||||
"training_units": "0.2.0",
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
||||
|
|
@ -31,6 +33,20 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.84",
|
||||
"date": "2026-05-11",
|
||||
"changes": [
|
||||
"Feat P-11: Legal-Hold-Sofortsperre — Migration 051 (legal_hold_active + Metadaten in media_assets; audit_log um legal_hold_set/released erweitert).",
|
||||
"Feat P-11: media_legal_hold.py — zentrale Services set_legal_hold, release_legal_hold, assert_not_under_legal_hold.",
|
||||
"Feat P-11: Retention-Job ueberspringt Assets mit legal_hold_active=TRUE (keine automatische Loeschung unter Hold).",
|
||||
"Feat P-11: Neue Admin-Endpoints POST /api/admin/media-assets/{id}/legal-hold, POST …/legal-hold/release, GET …/legal-hold (nur Superadmin).",
|
||||
"Feat P-11: Sichtbarkeitsfilter erweitert — normale Nutzer sehen Legal-Hold-Assets nicht; Superadmin/Plattform-Admin sehen sie.",
|
||||
"Feat P-11: exercises from-asset prueft Legal-Hold (LEGAL_HOLD_ACTIVE 403) vor Verknuepfung.",
|
||||
"Feat P-11: Frontend MediaLibraryPage — Legal-Hold-Badge, Superadmin-Aktionen, Bestaetigungs-Dialog, Journal-Renderpfad fuer legal_hold_set/released.",
|
||||
"Tests: 15 Backend-Unit-Tests test_p11_legal_hold.py.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.83",
|
||||
"date": "2026-05-11",
|
||||
|
|
|
|||
|
|
@ -284,6 +284,37 @@ FastAPI lehnt Requests mit `new_password < 8 Zeichen` nun mit HTTP **422** (Pyda
|
|||
|
||||
---
|
||||
|
||||
### P-11 – Legal-Hold Lifecycle-Status ✅
|
||||
|
||||
**Status:** Umgesetzt (2026-05-11, Version 0.8.84)
|
||||
|
||||
**Betroffene Dateien:**
|
||||
- `backend/migrations/051_legal_hold.sql` – neue Spalten `legal_hold_active`, `legal_hold_reason_code`, `legal_hold_reason_note`, `legal_hold_set_by_profile_id`, `legal_hold_set_at`, `legal_hold_released_by_profile_id`, `legal_hold_released_at`, `legal_hold_release_note` in `media_assets`; Audit-Log-CHECK um `legal_hold_set`/`legal_hold_released` erweitert
|
||||
- `backend/media_legal_hold.py` – Neu: zentrale Services `set_legal_hold`, `release_legal_hold`, `assert_not_under_legal_hold`, `is_media_available_for_normal_use`, `assert_superadmin_for_legal_hold`
|
||||
- `backend/media_lifecycle.py` – `run_retention_pass()` filtert `AND (legal_hold_active = FALSE OR legal_hold_active IS NULL)` in beiden Retention-Queries
|
||||
- `backend/routers/media_assets.py` – `admin_legal_hold_router` mit drei Endpoints; `_list_active_visibility_clause()` berücksichtigt `include_legal_hold`-Parameter; `download_media_asset_file()` prüft Legal-Hold für Nicht-Superadmins
|
||||
- `backend/routers/exercises.py` – `attach_exercise_media_from_asset()` ruft `assert_not_under_legal_hold()` vor Verknüpfung auf
|
||||
- `backend/main.py` – `app.include_router(media_assets.admin_legal_hold_router)`
|
||||
- `frontend/src/utils/api.js` – `setMediaAssetLegalHold`, `releaseMediaAssetLegalHold`, `listMediaAssetsWithLegalHold`
|
||||
- `frontend/src/pages/MediaLibraryPage.jsx` – Legal-Hold-Badge auf Kacheln; Superadmin-Aktionen „Sofort sperren"/„Sperre aufheben" im Edit-Modal; Bestätigungs-Dialog mit Pflichtfeldern; Journal-Renderpfad für `legal_hold_set`/`legal_hold_released`
|
||||
- `frontend/src/app.css` – CSS-Klassen für Legal-Hold-Badge, -Dialog, -Button
|
||||
|
||||
**Neue API-Endpoints (Superadmin):**
|
||||
- `POST /api/admin/media-assets/{asset_id}/legal-hold` — Sofortsperre setzen (reason_code + reason_note Pflicht)
|
||||
- `POST /api/admin/media-assets/{asset_id}/legal-hold/release` — Sofortsperre aufheben (release_note Pflicht)
|
||||
- `GET /api/admin/media-assets/legal-hold` — Liste aller aktuell gesperrten Assets
|
||||
|
||||
**Sicherheitsarchitektur:**
|
||||
- Legal-Hold ist orthogonal zum normalen Papierkorb-Lifecycle (P-03) — kein 30-Tage-Warten
|
||||
- `rights_status='blocked'` wird als Schnell-Spiegel gesetzt und bei Aufhebung basierend auf vorhandenen Deklarationen wiederhergestellt (`declared` wenn Deklaration vorhanden, sonst `legacy_unreviewed`)
|
||||
- Nur Superadmin darf setzen/aufheben; Plattform-Admin sieht Assets in Listen; normale Nutzer nicht
|
||||
- Retention-Job überspringt Legal-Hold-Assets (verhindert versehentliche Löschung unter laufender Sperrmaßnahme)
|
||||
- `assert_not_under_legal_hold()` blockiert das Verknüpfen von Legal-Hold-Assets mit Übungen
|
||||
|
||||
**Tests:** 15 Backend-Unit-Tests in `backend/tests/test_p11_legal_hold.py` — alle grün.
|
||||
|
||||
---
|
||||
|
||||
### P-12 – sessionStorage bei Logout bereinigen ✅
|
||||
|
||||
**Status:** Umgesetzt (2026-05-10, Version 0.8.68)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
**Typ:** Kanonisches Referenzdokument
|
||||
**Erstellt:** 2026-05-10
|
||||
**Basisdokument:** `docs/compliance-audit.md` (Initial-Audit 2026-05-09, App-Version 0.8.65)
|
||||
**Letzte Aktualisierung:** 2026-05-11 (App-Version 0.8.83)
|
||||
**Letzte Aktualisierung:** 2026-05-11 (App-Version 0.8.84)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -195,10 +195,10 @@
|
|||
| **Kanonischer Titel** | Legal-Hold Lifecycle-Status (Sofortsperrung bei Rechtsverletzung) |
|
||||
| **Findings** | MITT-02 |
|
||||
| **Etappe** | 2 |
|
||||
| **Status** | ❌ open |
|
||||
| **Letzter Stand** | Nicht umgesetzt. Stufe-1-Papierkorb dauert 30 Tage bis zur vollständigen Unsichtbarkeit. Kein direkter Sperr-Status. |
|
||||
| **Verweise** | `docs/compliance-audit.md` §9.2, §16 (MITT-02), §17 |
|
||||
| **Hinweise** | **Drift-Hinweis:** In `docs/compliance-implementation.md` (vor Korrektur 2026-05-10) wurde P-11 fälschlich als „HttpOnly-Cookie-Migration" beschrieben. Der korrekte Titel ist „Legal-Hold Lifecycle-Status". HttpOnly-Cookie gehört zu P-18. Korrigiert in `docs/compliance-implementation.md`. |
|
||||
| **Status** | ✅ implemented |
|
||||
| **Letzter Stand** | Vollständig umgesetzt in Version 0.8.84 (2026-05-11). Migration 051: neue Felder `legal_hold_active`, `legal_hold_reason_code`, `legal_hold_reason_note`, `legal_hold_set_by_profile_id`, `legal_hold_set_at`, `legal_hold_released_by_profile_id`, `legal_hold_released_at`, `legal_hold_release_note` in `media_assets`; Audit-Log-Ereignistypen `legal_hold_set`/`legal_hold_released`. Zentrales Service-Modul `media_legal_hold.py`. Retention-Job überspringt Legal-Hold-Assets. Nur Superadmin darf setzen/aufheben; Admins sehen Legal-Hold-Assets in Listen; normale Nutzer nicht. Frontend: Badge „Sofort gesperrt", Superadmin-Aktionen mit Pflichtfeldern und Bestätigungsdialog, Journal-Renderpfad. |
|
||||
| **Verweise** | `docs/compliance-audit.md` §9.2, §16 (MITT-02), §17; `docs/compliance-implementation.md` §P-11; `backend/media_legal_hold.py`; `backend/routers/media_assets.py` (admin_legal_hold_router); `backend/migrations/051_legal_hold.sql`; `backend/tests/test_p11_legal_hold.py` |
|
||||
| **Hinweise** | Orthogonal zum normalen Papierkorb-Lifecycle (P-03). `rights_status='blocked'` wird als Schnell-Spiegel gesetzt und bei Aufhebung basierend auf vorhandenen Deklarationen wiederhergestellt. Scope abgegrenzt: P-13 (Meldeverfahren), P-14 (DSA-Transparenz), P-15 (DSA-Moderationslog), P-16 (DSA-Beschwerdeweg) bleiben eigene Pakete. |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -56,12 +56,12 @@ Diese Roadmap ist nach jedem Re-Audit zu aktualisieren. Abweichungen von der bis
|
|||
| P-01c+ | _Erweiterung:_ Als-Entwurf-kopieren (`copy-as-draft`) | 0.8.72 |
|
||||
| P-01c++ | _Erweiterung:_ Echter PDF-Download (jsPDF) + Abschnitts-Sortierung/-Einfügen | 0.8.74 |
|
||||
|
||||
**Vollständig abgeschlossen:** 7 Hauptpakete + 4 Nacharbeiten + 2 Erweiterungen = 13 Umsetzungseinheiten
|
||||
**Vollständig abgeschlossen:** 8 Hauptpakete + 4 Nacharbeiten + 2 Erweiterungen = 14 Umsetzungseinheiten
|
||||
**Teilweise umgesetzt (technisch):** P-01, P-06
|
||||
|
||||
### Offene Pakete (15)
|
||||
### Offene Pakete (14)
|
||||
|
||||
P-02, P-08, P-09, P-10, P-11, P-13, P-14, P-15, P-16, P-17, P-18, P-19, P-20, P-21, P-22
|
||||
P-02, P-08, P-09, P-10, P-13, P-14, P-15, P-16, P-17, P-18, P-19, P-20, P-21, P-22
|
||||
|
||||
### Gesamtstatus
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ P-02, P-08, P-09, P-10, P-11, P-13, P-14, P-15, P-16, P-17, P-18, P-19, P-20, P-
|
|||
2. **P-06:** Keine Einwilligungserklärung beim Medienupload. Personenbilder können ohne jede Rechteabklärung hochgeladen werden.
|
||||
3. **P-02:** Nutzer können ihr Konto nicht selbst löschen (DSGVO Art. 17). Der Prozess ist noch nicht einmal fachlich spezifiziert.
|
||||
4. **P-13:** Keine Möglichkeit, rechtswidrige Inhalte zu melden. Bei einer UGC-Plattform mit öffentlichen Inhalten ist dies ein erhebliches Restrisiko, unabhängig davon, ob der DSA formal anwendbar ist.
|
||||
5. **P-11:** Kein Legal-Hold-Status. Bei erkannten Rechtsverletzungen dauert es bis zu 30 Tage, bis Inhalte vollständig ausgeblendet sind.
|
||||
5. ~~**P-11:** Kein Legal-Hold-Status.~~ ✅ **Implementiert (v0.8.84).**
|
||||
|
||||
**Was geöffnet bleiben kann (mit Dokumentation):** Vereinsinterne Nutzung durch bekannte Trainer-Gruppen ist mit dem aktuellen Stand vertretbar, solange keine öffentliche Vermarktung stattfindet und kein `official`-Content aktiviert wird.
|
||||
|
||||
|
|
@ -107,18 +107,17 @@ Die folgenden Pakete sind vor der Freigabe für allgemeine öffentliche Registri
|
|||
**Warum noch KRIT-04 offen:** Juristische Validierung der Einwilligungsformulierungen (§7.7), KUG-Anforderungen im Vereinskontext (§7.2), Minderjährigenschutz (§7.4) und Altbestand-Behandlung (§7.8) steht aus. Texte sind Arbeitsfassungen (`p06-v1-conservative`) ohne anwaltliche Freigabe.
|
||||
**Nächster Schritt:** Rechtsanwalt für juristische Prüfung der Feldtexte (T1–T10 in `docs/p06-upload-rights-spec.md` §10.3) und der offenen KUG/DSGVO-Fragen beauftragen. Nach Klärung: Textfreigabe eintragen → KRIT-04 kann geschlossen werden.
|
||||
|
||||
### Blocker 3 — P-11: Legal-Hold Lifecycle-Status
|
||||
### ~~Blocker 3 — P-11: Legal-Hold Lifecycle-Status~~ ✅ Implementiert (v0.8.84)
|
||||
|
||||
**Finding:** MITT-02
|
||||
**Warum jetzt statt Etappe 2:** Der aktuelle Papierkorb-Mechanismus reicht für reaktive Maßnahmen bei Rechtsverletzungen nicht aus. Zwischen Meldung und vollständiger Unsichtbarkeit liegen bis zu 30 Tage (Stufe 1). Für eine Plattform mit öffentlichen Inhalten ist das nicht akzeptabel.
|
||||
**Abhängigkeit:** Sinnvoll mit P-13 zusammen umzusetzen (Moderationssystem braucht direkten Sperr-Status).
|
||||
**Abgeschlossen:** 2026-05-11. Migration 051, `media_legal_hold.py`, Retention-Schutz, Superadmin-API + Frontend. Details: `docs/compliance-package-register.md` §P-11.
|
||||
|
||||
### Blocker 4 — P-13: Content-Melde-Backend (Minimalversion)
|
||||
### Blocker 3 (neu) — P-13: Content-Melde-Backend (Minimalversion)
|
||||
|
||||
**Finding:** KRIT-03
|
||||
**Warum vorgezogen (Abweichung vom Initial-Audit):** → Siehe §4
|
||||
**Minimalumfang:** Meldung einreichen + Moderations-Queue für Admin. Nicht der volle P-14–P-16-Stack.
|
||||
**Abhängigkeit:** P-11 (Legal-Hold) für direkte Reaktion auf Meldungen empfohlen.
|
||||
**Abhängigkeit:** ~~P-11 (Legal-Hold)~~ ✅ bereits implementiert.
|
||||
|
||||
### Blocker 5 — P-02: DSGVO-Self-Service (fachliche Spezifikation zuerst)
|
||||
|
||||
|
|
@ -176,8 +175,8 @@ Reihenfolge innerhalb der Etappe ist flexibel; P-01 und P-06 haben keine gegense
|
|||
|-------|-------|---------|-------------|----------------------|
|
||||
| P-01 | Rechtstexte technisch anlegen (Platzhalter-Seiten, Routen) | 2–4 h Technik | Inhalt durch Rechtsanwalt separat | „Freigabe zur Umsetzung P-01: Rechtstexte" |
|
||||
| P-06 | Upload-Einwilligungsdialog | 2–4 Tage | — | „Freigabe zur Umsetzung P-06: Upload-Einwilligungsdialog" |
|
||||
| P-11 | Legal-Hold Lifecycle-Status | 2–3 Tage | sinnvoll vor P-13 | „Freigabe zur Umsetzung P-11: Legal-Hold Lifecycle-Status" |
|
||||
| P-13 | Content-Melde-Backend (Minimalversion) | 3–5 Tage | P-11 empfohlen | „Freigabe zur Umsetzung P-13: Content-Melde-Backend" |
|
||||
| ~~P-11~~ | ~~Legal-Hold Lifecycle-Status~~ | — | ✅ implementiert (v0.8.84) | — |
|
||||
| P-13 | Content-Melde-Backend (Minimalversion) | 3–5 Tage | P-11 ✅ bereits erfüllt | „Freigabe zur Umsetzung P-13: Content-Melde-Backend" |
|
||||
|
||||
**Hinweis P-01:** Die technische Anlage (leere Seiten, Routen `/impressum`, `/datenschutz`, `/nutzungsbedingungen`) ist von der juristischen Ausarbeitung des Inhalts zu trennen. Beide Teilschritte können unabhängig voneinander freigegeben und durchgeführt werden.
|
||||
|
||||
|
|
@ -259,7 +258,7 @@ Folgende Bedingungen müssen **alle** erfüllt sein:
|
|||
| Impressum und Datenschutzerklärung mit juristisch geprüftem Inhalt | P-01 | ⚠️ Routen vorhanden — Inhalte durch Rechtsanwalt erforderlich |
|
||||
| Upload-Einwilligungsdialog aktiv | P-06 | Code |
|
||||
| DSGVO-Löschprozess spezifiziert (Spec akzeptiert, auch wenn nicht vollständig implementiert) | P-02 | Spezifikation |
|
||||
| Legal-Hold-Status vorhanden | P-11 | Code |
|
||||
| Legal-Hold-Status vorhanden | P-11 | ✅ Bereits erfüllt (Version 0.8.84) |
|
||||
| Content-Melde-Backend (Minimalversion) aktiv | P-13 | Code |
|
||||
| HSTS am externen Reverse-Proxy nachgewiesen | P-08 | Betreiber |
|
||||
| Papierkorb-Retention-Job läuft (Monitoring aktiv) | P-03 | Bereits erfüllt |
|
||||
|
|
@ -284,7 +283,7 @@ Bevor Inhalte mit `official`-Sichtbarkeit (plattformweit sichtbar, ohne Login) a
|
|||
|-----------|-------|-----|
|
||||
| Gate 1 vollständig erfüllt | — | Voraussetzung |
|
||||
| Upload-Einwilligungsdialog für Personenbilder aktiv | P-06 | Code |
|
||||
| Legal-Hold-Status aktiv (Sofortsperrung möglich) | P-11 | Code |
|
||||
| Legal-Hold-Status aktiv (Sofortsperrung möglich) | P-11 | ✅ Bereits erfüllt (Version 0.8.84) |
|
||||
| Content-Melde-Backend aktiv | P-13 | Code |
|
||||
| Copyright-Pflicht bei Promotion aktiv und getestet | P-04 | Bereits erfüllt |
|
||||
| Moderationsprozess organisatorisch dokumentiert (wer, wie schnell, Eskalationspfad) | — | Betreiber |
|
||||
|
|
@ -362,7 +361,8 @@ Diese Punkte liegen außerhalb des Code-Scopes und erfordern organisatorische Ma
|
|||
| ~~P-01b~~ | ~~„Freigabe zur Umsetzung P-01b: Mobile/PWA-Zugriff auf Rechtliches"~~ | ✅ historisch abgeschlossen (Version 0.8.70) |
|
||||
| ~~P-01c~~ | ~~„Freigabe zur Umsetzung P-01c: Admin-konfigurierbare Rechtstexte"~~ | ✅ historisch abgeschlossen (Version 0.8.71); Erweiterungen copy-as-draft (0.8.72) + jsPDF/Sortierung (0.8.74) |
|
||||
| **P-06** | **„Freigabe zur Umsetzung P-06 auf Basis konservativer Erstannahmen"** | ✅ erteilt + vollständig umgesetzt (2026-05-11, v0.8.75–0.8.83) — technisch umgesetzt unter `p06-v1-conservative` inkl. P-06+ Volljournal + Korrektur; KRIT-04 bleibt bis juristische Validierung |
|
||||
| Etappe B komplett | „Freigabe zur Umsetzung Etappe B: P-11, P-13" | ⬅ nächste empfohlene Freigabe (nach juristischer Klärung P-06/KRIT-04 und Rechtstexten P-01) |
|
||||
| **P-11** | **„Freigabe zur Umsetzung P-11: Legal-Hold Lifecycle-Status"** | ✅ vollständig umgesetzt (2026-05-11, v0.8.84) — Migration 051, Retention-Schutz, Superadmin-API + Frontend; 15 Backend-Tests |
|
||||
| P-13 | „Freigabe zur Umsetzung P-13: Content-Melde-Backend" | ⬅ nächste empfohlene Freigabe (nach juristischer Klärung P-06/KRIT-04 und Rechtstexten P-01) |
|
||||
| P-02 Spezifikation | „Freigabe zur Spezifikation P-02: DSGVO-Self-Service-Prozess" | offen |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -498,16 +498,23 @@ P-06 führt keine copyright_notice-Redundanz ein. copyright_notice (P-04) = Attr
|
|||
|
||||
---
|
||||
|
||||
### Flow 10: Zusammenspiel mit späterem P-11 und P-13
|
||||
### Flow 10: Zusammenspiel mit P-11 (implementiert) und P-13
|
||||
|
||||
| P-06-Element | P-11-Anschluss | P-13-Anschluss |
|
||||
**P-11 Status: ✅ Implementiert (v0.8.84)**
|
||||
|
||||
| P-06-Element | P-11-Umsetzung | P-13-Anschluss |
|
||||
|-------------|---------------|---------------|
|
||||
| `rights_status = 'blocked'` | P-11 setzt diesen Status (Legal-Hold) | P-13-Moderator triggert P-11-Sperre |
|
||||
| Declaration-Log | Zeigt, welche Version bei Sperrung galt | Kontext für Moderationsentscheidung |
|
||||
| `contains_identifiable_persons` | Relevant für Löschbegründung (KUG) | Relevanzmerkmal für Meldekategorie |
|
||||
| `contains_minors` | Höchste Priorität bei P-11-Sofortsperrung | CSAM-Eskalationspfad in P-13 |
|
||||
| `rights_status = 'blocked'` | P-11 setzt diesen Status als Spiegel bei `legal_hold_active=TRUE`; bei Freigabe auf `declared`/`legacy_unreviewed` zurückgesetzt | P-13-Moderator triggert `set_legal_hold()` |
|
||||
| Declaration-Log | Zeigt, welche Version bei Sperrung galt; wird bei `release_legal_hold` zur Wiederherstellung von `rights_status` genutzt | Kontext für Moderationsentscheidung |
|
||||
| `contains_identifiable_persons` | Relevant für Löschbegründung (KUG), `reason_code=consent_withdrawn` | Relevanzmerkmal für Meldekategorie |
|
||||
| `contains_minors` | Höchste Priorität: `reason_code=youth_protection` | CSAM-Eskalationspfad in P-13 |
|
||||
|
||||
P-06 darf nicht im Widerspruch zu den späteren Paketen implementiert werden. Der `rights_status`-Enum muss `'blocked'` bereits in P-06a (Migration) enthalten.
|
||||
**Implementiertes Schnittstellenmodell P-06 ↔ P-11:**
|
||||
- `media_legal_hold.py`: `set_legal_hold()` schreibt `rights_status='blocked'` als Spiegel
|
||||
- `media_legal_hold.py`: `release_legal_hold()` stellt `rights_status` anhand vorhandener Deklarationen wieder her (`declared` wenn `COUNT(rights_holder_confirmed=TRUE)>0`, sonst `legacy_unreviewed`)
|
||||
- Audit-Log `media_asset_audit_log` protokolliert `legal_hold_set`/`legal_hold_released` als eigene Ereignistypen (orthogonal zu `copyright_change`, `visibility_change` etc.)
|
||||
|
||||
**P-13-Vorbereitung:** `set_legal_hold()` in `media_legal_hold.py` ist eine generische Funktion — P-13 ruft sie mit `reason_code` aus der Moderationsqueue auf, ohne eigene Sperr-Logik implementieren zu müssen.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -6307,6 +6307,65 @@ a.analysis-split__nav-item {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
/* P-11: Legal Hold */
|
||||
.media-library__lc-block--legal-hold {
|
||||
background: rgba(216, 90, 48, 0.08);
|
||||
border: 1px solid rgba(216, 90, 48, 0.3);
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 12px 14px 14px;
|
||||
}
|
||||
.media-library__legal-hold-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
color: #fff;
|
||||
background: var(--danger);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
margin-left: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.media-library__legal-hold-info {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text2);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.media-library__btn--legal-hold {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.media-library__btn--legal-hold:hover {
|
||||
background: rgba(216, 90, 48, 0.08);
|
||||
}
|
||||
.media-library__btn--legal-hold-confirm {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
.media-library__btn--legal-hold-confirm:hover {
|
||||
background: #b84a22;
|
||||
}
|
||||
.media-library__legal-hold-dialog-asset {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text1);
|
||||
}
|
||||
.media-library__legal-hold-warning {
|
||||
background: rgba(216, 90, 48, 0.08);
|
||||
border-left: 3px solid var(--danger);
|
||||
padding: 8px 12px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text2);
|
||||
}
|
||||
.media-library__modal--narrow {
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
/* Vorschau-Modal (Vollbild nah) */
|
||||
.media-library__overlay--preview {
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
|
|
|
|||
|
|
@ -250,10 +250,24 @@ function eventTypeLabel(et) {
|
|||
copyright_change: 'Copyright geändert',
|
||||
metadata_change: 'Metadaten geändert',
|
||||
lifecycle_change: 'Lifecycle geändert',
|
||||
legal_hold_set: 'Sofortsperre gesetzt',
|
||||
legal_hold_released: 'Sofortsperre aufgehoben',
|
||||
}
|
||||
return MAP[et] || et
|
||||
}
|
||||
|
||||
const LEGAL_HOLD_REASON_LABELS = {
|
||||
rights_dispute: 'Rechtsstreit',
|
||||
consent_withdrawn: 'Einwilligung widerrufen',
|
||||
privacy_complaint: 'Datenschutzbeschwerde',
|
||||
copyright_complaint: 'Urheberrechtsbeschwerde',
|
||||
youth_protection: 'Jugendschutz',
|
||||
illegal_content: 'Illegaler Inhalt',
|
||||
other: 'Sonstige',
|
||||
}
|
||||
|
||||
const LEGAL_HOLD_REASON_CODES = Object.keys(LEGAL_HOLD_REASON_LABELS)
|
||||
|
||||
function visLabel(v) {
|
||||
if (v === 'private') return 'Privat'
|
||||
if (v === 'club') return 'Verein'
|
||||
|
|
@ -324,6 +338,12 @@ export default function MediaLibraryPage() {
|
|||
const [journalCorrectionOpen, setJournalCorrectionOpen] = useState(false)
|
||||
const [journalCorrectionDraft, setJournalCorrectionDraft] = useState(null)
|
||||
const [journalCorrectionBusy, setJournalCorrectionBusy] = useState(false)
|
||||
// P-11: Legal Hold Dialog
|
||||
const [legalHoldDialog, setLegalHoldDialog] = useState(null) // { mode: 'set'|'release', assetId, assetName }
|
||||
const [legalHoldReasonCode, setLegalHoldReasonCode] = useState('rights_dispute')
|
||||
const [legalHoldReasonNote, setLegalHoldReasonNote] = useState('')
|
||||
const [legalHoldReleaseNote, setLegalHoldReleaseNote] = useState('')
|
||||
const [legalHoldBusy, setLegalHoldBusy] = useState(false)
|
||||
const mediaListFetchSeqRef = useRef(0)
|
||||
const gridTopAnchorRef = useRef(null)
|
||||
|
||||
|
|
@ -454,6 +474,48 @@ export default function MediaLibraryPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const openLegalHoldSet = (it) => {
|
||||
setLegalHoldReasonCode('rights_dispute')
|
||||
setLegalHoldReasonNote('')
|
||||
setLegalHoldDialog({ mode: 'set', assetId: it.id, assetName: it.original_filename || `#${it.id}` })
|
||||
}
|
||||
|
||||
const openLegalHoldRelease = (it) => {
|
||||
setLegalHoldReleaseNote('')
|
||||
setLegalHoldDialog({ mode: 'release', assetId: it.id, assetName: it.original_filename || `#${it.id}` })
|
||||
}
|
||||
|
||||
const submitLegalHold = async () => {
|
||||
if (!legalHoldDialog) return
|
||||
if (legalHoldDialog.mode === 'set') {
|
||||
if (!legalHoldReasonNote.trim() || legalHoldReasonNote.trim().length < 5) {
|
||||
alert('Bitte gib eine Begründung ein (mind. 5 Zeichen).')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!legalHoldReleaseNote.trim() || legalHoldReleaseNote.trim().length < 5) {
|
||||
alert('Bitte gib eine Freigabe-Begründung ein (mind. 5 Zeichen).')
|
||||
return
|
||||
}
|
||||
}
|
||||
setLegalHoldBusy(true)
|
||||
try {
|
||||
if (legalHoldDialog.mode === 'set') {
|
||||
await api.setMediaAssetLegalHold(legalHoldDialog.assetId, legalHoldReasonCode, legalHoldReasonNote)
|
||||
} else {
|
||||
await api.releaseMediaAssetLegalHold(legalHoldDialog.assetId, legalHoldReleaseNote)
|
||||
}
|
||||
setLegalHoldDialog(null)
|
||||
setModal(null)
|
||||
setModalDraft(null)
|
||||
await loadMedia()
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
} finally {
|
||||
setLegalHoldBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function parseApiErrorCode(msg) {
|
||||
try {
|
||||
const d = JSON.parse(msg)
|
||||
|
|
@ -902,7 +964,12 @@ export default function MediaLibraryPage() {
|
|||
</div>
|
||||
<div className="media-library__card-footer-row">
|
||||
<MediaCardScopeStatus visibility={it.visibility} lifecycleState={it.lifecycle_state} />
|
||||
{it.rights_status === 'legacy_unreviewed' && (
|
||||
{it.legal_hold_active && (
|
||||
<span className="media-library__legal-hold-badge" title="Sofortsperre aktiv (Legal Hold)">
|
||||
Sofort gesperrt
|
||||
</span>
|
||||
)}
|
||||
{!it.legal_hold_active && it.rights_status === 'legacy_unreviewed' && (
|
||||
<span
|
||||
style={{ fontSize: '0.7rem', color: 'var(--danger)', marginLeft: 4 }}
|
||||
title="Altbestand – Rechtserklärung nach neuem Standard (P-06) noch nicht erfasst"
|
||||
|
|
@ -1401,6 +1468,43 @@ export default function MediaLibraryPage() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{isSuperadmin ? (
|
||||
<div className="media-library__lc-block media-library__lc-block--legal-hold">
|
||||
<div className="media-library__lc-title">Sofortsperre (Legal Hold)</div>
|
||||
{modal.legal_hold_active ? (
|
||||
<div>
|
||||
<p className="media-library__legal-hold-info">
|
||||
<strong>Aktiv seit:</strong>{' '}
|
||||
{modal.legal_hold_set_at ? new Date(modal.legal_hold_set_at).toLocaleString('de-DE') : '—'}
|
||||
{modal.legal_hold_reason_code ? (
|
||||
<> · <strong>Grund:</strong> {LEGAL_HOLD_REASON_LABELS[modal.legal_hold_reason_code] || modal.legal_hold_reason_code}</>
|
||||
) : null}
|
||||
</p>
|
||||
{modal.legal_hold_reason_note ? (
|
||||
<p className="media-library__legal-hold-info">{modal.legal_hold_reason_note}</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() => openLegalHoldRelease(modal)}
|
||||
>
|
||||
Sperre aufheben
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary media-library__btn--legal-hold"
|
||||
disabled={busy}
|
||||
onClick={() => openLegalHoldSet(modal)}
|
||||
>
|
||||
Sofort sperren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{modal.permissions?.superadmin_lifecycle ? (
|
||||
<div className="media-library__lc-block media-library__lc-block--danger">
|
||||
<div className="media-library__lc-title">Superadmin</div>
|
||||
|
|
@ -1526,6 +1630,32 @@ export default function MediaLibraryPage() {
|
|||
<span>{nw.copyright_notice || '—'}</span>
|
||||
</div>
|
||||
</>
|
||||
) : evt.event_type === 'legal_hold_set' ? (
|
||||
<>
|
||||
<div className="media-library__journal-audit-row">
|
||||
<span className="media-library__journal-audit-label">Grund:</span>
|
||||
<span>{LEGAL_HOLD_REASON_LABELS[nw.legal_hold_reason_code] || nw.legal_hold_reason_code || '—'}</span>
|
||||
</div>
|
||||
{nw.legal_hold_reason_note ? (
|
||||
<div className="media-library__journal-audit-row">
|
||||
<span className="media-library__journal-audit-label">Begründung:</span>
|
||||
<span>{nw.legal_hold_reason_note}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : evt.event_type === 'legal_hold_released' ? (
|
||||
<>
|
||||
{nw.legal_hold_release_note ? (
|
||||
<div className="media-library__journal-audit-row">
|
||||
<span className="media-library__journal-audit-label">Freigabegrund:</span>
|
||||
<span>{nw.legal_hold_release_note}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="media-library__journal-audit-row">
|
||||
<span className="media-library__journal-audit-label">Rechtsstatus wiederhergestellt:</span>
|
||||
<span>{nw.rights_status || '—'}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
Object.keys(nw).map((k) => (
|
||||
<div key={k} className="media-library__journal-audit-row">
|
||||
|
|
@ -1800,6 +1930,102 @@ export default function MediaLibraryPage() {
|
|||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{legalHoldDialog ? (
|
||||
<div className="media-library__overlay" role="dialog" aria-modal="true" aria-labelledby="legal-hold-dialog-title">
|
||||
<div className="media-library__modal media-library__modal--narrow">
|
||||
<div className="media-library__modal-head">
|
||||
<h2 id="legal-hold-dialog-title">
|
||||
{legalHoldDialog.mode === 'set' ? 'Sofortsperre setzen' : 'Sofortsperre aufheben'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="media-library__icon-btn"
|
||||
onClick={() => setLegalHoldDialog(null)}
|
||||
aria-label="Schließen"
|
||||
disabled={legalHoldBusy}
|
||||
>
|
||||
<X size={22} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="media-library__modal-body">
|
||||
<p className="media-library__legal-hold-dialog-asset">
|
||||
Medium: <strong>{legalHoldDialog.assetName}</strong>
|
||||
</p>
|
||||
|
||||
{legalHoldDialog.mode === 'set' ? (
|
||||
<>
|
||||
<p className="media-library__hint media-library__legal-hold-warning">
|
||||
Die Sofortsperre sperrt das Medium sofort für alle normalen Nutzer. Der Rechtsstatus wird auf „blocked" gesetzt. Die Sperre kann nur von Superadmins aufgehoben werden.
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Grund *</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={legalHoldReasonCode}
|
||||
onChange={(e) => setLegalHoldReasonCode(e.target.value)}
|
||||
disabled={legalHoldBusy}
|
||||
>
|
||||
{LEGAL_HOLD_REASON_CODES.map((code) => (
|
||||
<option key={code} value={code}>{LEGAL_HOLD_REASON_LABELS[code]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Begründung * (mind. 5 Zeichen)</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={3}
|
||||
value={legalHoldReasonNote}
|
||||
onChange={(e) => setLegalHoldReasonNote(e.target.value)}
|
||||
placeholder="Konkrete Begründung für die Sofortsperre..."
|
||||
disabled={legalHoldBusy}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="media-library__hint">
|
||||
Die Aufhebung der Sofortsperre gibt das Medium wieder frei. Der Rechtsstatus wird wiederhergestellt.
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Freigabe-Begründung * (mind. 5 Zeichen)</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={3}
|
||||
value={legalHoldReleaseNote}
|
||||
onChange={(e) => setLegalHoldReleaseNote(e.target.value)}
|
||||
placeholder="Grund für die Aufhebung der Sofortsperre..."
|
||||
disabled={legalHoldBusy}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${legalHoldDialog.mode === 'set' ? 'media-library__btn--legal-hold-confirm' : 'btn-primary'}`}
|
||||
disabled={legalHoldBusy}
|
||||
onClick={submitLegalHold}
|
||||
>
|
||||
{legalHoldBusy
|
||||
? (legalHoldDialog.mode === 'set' ? 'Sperren…' : 'Aufheben…')
|
||||
: (legalHoldDialog.mode === 'set' ? 'Jetzt sperren' : 'Sperre aufheben')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={legalHoldBusy}
|
||||
onClick={() => setLegalHoldDialog(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -683,6 +683,25 @@ export async function attachExerciseMediaFromAsset(exerciseId, body) {
|
|||
})
|
||||
}
|
||||
|
||||
// P-11: Legal-Hold-Endpunkte
|
||||
export async function setMediaAssetLegalHold(assetId, reasonCode, reasonNote) {
|
||||
return request(`/api/admin/media-assets/${assetId}/legal-hold`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason_code: reasonCode, reason_note: reasonNote }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function releaseMediaAssetLegalHold(assetId, releaseNote) {
|
||||
return request(`/api/admin/media-assets/${assetId}/legal-hold/release`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ release_note: releaseNote }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listMediaAssetsWithLegalHold(limit = 100, offset = 0) {
|
||||
return request(`/api/admin/media-assets/legal-hold?limit=${limit}&offset=${offset}`)
|
||||
}
|
||||
|
||||
export async function getExercise(id) {
|
||||
return request(`/api/exercises/${id}`)
|
||||
}
|
||||
|
|
@ -1430,6 +1449,9 @@ export const api = {
|
|||
getMediaAssetJournal,
|
||||
addMediaAssetDeclarationCorrection,
|
||||
attachExerciseMediaFromAsset,
|
||||
setMediaAssetLegalHold,
|
||||
releaseMediaAssetLegalHold,
|
||||
listMediaAssetsWithLegalHold,
|
||||
listExerciseProgressionGraphs,
|
||||
getExerciseProgressionGraph,
|
||||
createExerciseProgressionGraph,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Shinkan Jinkendo Frontend Version
|
||||
|
||||
export const APP_VERSION = "0.8.83"
|
||||
export const APP_VERSION = "0.8.84"
|
||||
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.5.0", // P-06: Volljournal mit Audit-Log und Korrektur-Formular
|
||||
MediaLibraryPage: "1.6.0", // P-11: Legal-Hold-Badge, Superadmin-Aktionen, Bestaetigungs-Dialog
|
||||
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