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

- 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:
Lars 2026-05-11 12:33:13 +02:00
parent 1640fe6045
commit 1ce6d929ce
16 changed files with 1180 additions and 42 deletions

View File

@ -206,6 +206,7 @@ app.include_router(admin_users.router)
app.include_router(platform_media_storage.router) app.include_router(platform_media_storage.router)
app.include_router(media_assets.router) app.include_router(media_assets.router)
app.include_router(media_assets.admin_rights_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(skills.router)
app.include_router(training_planning.router) app.include_router(training_planning.router)
app.include_router(training_framework_programs.router) app.include_router(training_framework_programs.router)

247
backend/media_legal_hold.py Normal file
View 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

View File

@ -279,6 +279,10 @@ def run_retention_pass(cur: Any, conn: Any) -> dict:
""" """
Automatik: trash_soft älter als SOFT_TO_HIDDEN_DAYS trash_hidden; Automatik: trash_soft älter als SOFT_TO_HIDDEN_DAYS trash_hidden;
trash_hidden mit purge_after_at in der Vergangenheit purge. 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) cutoff_soft = datetime.now(timezone.utc) - timedelta(days=SOFT_TO_HIDDEN_DAYS)
cur.execute( 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(), SET lifecycle_state = %s, trash_hidden_at = NOW(), updated_at = NOW(),
purge_after_at = NOW() + (%s * INTERVAL '1 day') purge_after_at = NOW() + (%s * INTERVAL '1 day')
WHERE lifecycle_state = %s AND trash_soft_at IS NOT NULL AND trash_soft_at <= %s 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""", RETURNING id""",
(LC_TRASH_HIDDEN, HIDDEN_TO_PURGE_DAYS, LC_TRASH_SOFT, cutoff_soft), (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( cur.execute(
"""SELECT id FROM media_assets """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,), (LC_TRASH_HIDDEN,),
) )
purge_ids = [r2d(r)["id"] for r in cur.fetchall()] purge_ids = [r2d(r)["id"] for r in cur.fetchall()]

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

View File

@ -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 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_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_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 ( from exercise_rich_text import (
RICH_HTML_EXERCISE_FIELDS, RICH_HTML_EXERCISE_FIELDS,
assert_no_inline_media_references_on_create, assert_no_inline_media_references_on_create,
@ -2880,7 +2881,7 @@ def attach_exercise_media_from_asset(
cur.execute( cur.execute(
"""SELECT id, mime_type, byte_size, original_filename, visibility, club_id, """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""", FROM media_assets WHERE id = %s""",
(body.media_asset_id,), (body.media_asset_id,),
) )
@ -2893,6 +2894,8 @@ def attach_exercise_media_from_asset(
status_code=400, status_code=400,
detail="Nur aktive Archiv-Medien können verknüpft werden", 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( if not library_content_visible_to_profile(
cur, cur,

View File

@ -48,6 +48,14 @@ from media_rights import (
check_rights_coverage, check_rights_coverage,
VISIBILITY_LEVELS, 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 from routers.exercises import _upload_limit_bytes, resolve_upload_mime_type
router = APIRouter(prefix="/api/media-assets", tags=["media-assets"]) 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]]: def _list_active_visibility_clause(
"""Sichtbare aktive Einträge: official; private (eigen oder Plattform-Admin); Verein wie Bibliotheks-SQL.""" 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'"] parts = ["lower(trim(ma.visibility)) = 'official'"]
vals: list[Any] = [] vals: list[Any] = []
@ -491,8 +507,13 @@ def _list_active_visibility_clause(is_plat: bool, profile_id: int) -> tuple[str,
) )
vals.append(profile_id) vals.append(profile_id)
sql = "(" + " OR ".join(parts) + ")" vis_sql = "(" + " OR ".join(parts) + ")"
return sql, vals
# 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( def _list_trash_visibility_clause(
@ -530,12 +551,15 @@ def _list_main_visibility_where(
) -> tuple[str, list[Any]]: ) -> tuple[str, list[Any]]:
""" """
Kombiniert lifecycle mit Leserechten. Papierkorb-Stufen für normale Nutzer stark eingeschränkt. 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() lc = (lifecycle or "active").strip().lower()
if lc not in _LIFECYCLE_LIST_FILTERS: if lc not in _LIFECYCLE_LIST_FILTERS:
raise HTTPException(status_code=400, detail="Ungültiger lifecycle-Filter") 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( trash_sql, trash_params = _list_trash_visibility_clause(
is_plat, is_sup, profile_id, admin_club_ids is_plat, is_sup, profile_id, admin_club_ids
) )
@ -1187,9 +1211,30 @@ def download_media_asset_file(
with get_db() as conn: with get_db() as conn:
cur = get_cursor(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: if not asset:
raise HTTPException(status_code=404, detail="Medium nicht gefunden") 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() lc = (asset.get("lifecycle_state") or "").strip().lower()
if lc == "active": if lc == "active":
_assert_can_view_archive_asset(cur, tenant, asset) _assert_can_view_archive_asset(cur, tenant, asset)
@ -1909,3 +1954,124 @@ def add_rights_correction(
conn.commit() conn.commit()
return {"ok": True, "asset_id": asset_id} 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}

View 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
# ---------------------------------------------------------------------------
# 12: 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
# ---------------------------------------------------------------------------
# 34: 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
# ---------------------------------------------------------------------------
# 56: 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
# ---------------------------------------------------------------------------
# 710: 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"
# ---------------------------------------------------------------------------
# 1114: 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"
)

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.83" APP_VERSION = "0.8.84"
BUILD_DATE = "2026-05-11" BUILD_DATE = "2026-05-11"
DB_SCHEMA_VERSION = "20260511050" DB_SCHEMA_VERSION = "20260511051"
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,12 +14,14 @@ 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.2.0", # P-06+: write_audit_log_entry + write_rights_correction_declaration (Migration 050) "media_rights": "1.3.0", # P-11: write_audit_log_entry + legal_hold_set/released events
"media_assets": "1.16.1", # Fix: journal/correction club_admin check via has_club_role (role column doesn't exist on club_members) "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", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "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_units": "0.2.0",
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@ -31,6 +33,20 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.83",
"date": "2026-05-11", "date": "2026-05-11",

View File

@ -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 ✅ ### P-12 sessionStorage bei Logout bereinigen ✅
**Status:** Umgesetzt (2026-05-10, Version 0.8.68) **Status:** Umgesetzt (2026-05-10, Version 0.8.68)

View File

@ -3,7 +3,7 @@
**Typ:** Kanonisches Referenzdokument **Typ:** Kanonisches Referenzdokument
**Erstellt:** 2026-05-10 **Erstellt:** 2026-05-10
**Basisdokument:** `docs/compliance-audit.md` (Initial-Audit 2026-05-09, App-Version 0.8.65) **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) | | **Kanonischer Titel** | Legal-Hold Lifecycle-Status (Sofortsperrung bei Rechtsverletzung) |
| **Findings** | MITT-02 | | **Findings** | MITT-02 |
| **Etappe** | 2 | | **Etappe** | 2 |
| **Status** | ❌ open | | **Status** | ✅ implemented |
| **Letzter Stand** | Nicht umgesetzt. Stufe-1-Papierkorb dauert 30 Tage bis zur vollständigen Unsichtbarkeit. Kein direkter Sperr-Status. | | **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 | | **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** | **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`. | | **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. |
--- ---

View File

@ -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:_ Als-Entwurf-kopieren (`copy-as-draft`) | 0.8.72 |
| P-01c++ | _Erweiterung:_ Echter PDF-Download (jsPDF) + Abschnitts-Sortierung/-Einfügen | 0.8.74 | | 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 **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 ### 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. 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. 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. 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. **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. **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 (T1T10 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. **Nächster Schritt:** Rechtsanwalt für juristische Prüfung der Feldtexte (T1T10 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 **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. **Abgeschlossen:** 2026-05-11. Migration 051, `media_legal_hold.py`, Retention-Schutz, Superadmin-API + Frontend. Details: `docs/compliance-package-register.md` §P-11.
**Abhängigkeit:** Sinnvoll mit P-13 zusammen umzusetzen (Moderationssystem braucht direkten Sperr-Status).
### Blocker 4 — P-13: Content-Melde-Backend (Minimalversion) ### Blocker 3 (neu) — P-13: Content-Melde-Backend (Minimalversion)
**Finding:** KRIT-03 **Finding:** KRIT-03
**Warum vorgezogen (Abweichung vom Initial-Audit):** → Siehe §4 **Warum vorgezogen (Abweichung vom Initial-Audit):** → Siehe §4
**Minimalumfang:** Meldung einreichen + Moderations-Queue für Admin. Nicht der volle P-14P-16-Stack. **Minimalumfang:** Meldung einreichen + Moderations-Queue für Admin. Nicht der volle P-14P-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) ### 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) | 24 h Technik | Inhalt durch Rechtsanwalt separat | „Freigabe zur Umsetzung P-01: Rechtstexte" | | P-01 | Rechtstexte technisch anlegen (Platzhalter-Seiten, Routen) | 24 h Technik | Inhalt durch Rechtsanwalt separat | „Freigabe zur Umsetzung P-01: Rechtstexte" |
| P-06 | Upload-Einwilligungsdialog | 24 Tage | — | „Freigabe zur Umsetzung P-06: Upload-Einwilligungsdialog" | | P-06 | Upload-Einwilligungsdialog | 24 Tage | — | „Freigabe zur Umsetzung P-06: Upload-Einwilligungsdialog" |
| P-11 | Legal-Hold Lifecycle-Status | 23 Tage | sinnvoll vor P-13 | „Freigabe zur Umsetzung P-11: Legal-Hold Lifecycle-Status" | | ~~P-11~~ | ~~Legal-Hold Lifecycle-Status~~ | — | ✅ implementiert (v0.8.84) | — |
| P-13 | Content-Melde-Backend (Minimalversion) | 35 Tage | P-11 empfohlen | „Freigabe zur Umsetzung P-13: Content-Melde-Backend" | | P-13 | Content-Melde-Backend (Minimalversion) | 35 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. **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 | | Impressum und Datenschutzerklärung mit juristisch geprüftem Inhalt | P-01 | ⚠️ Routen vorhanden — Inhalte durch Rechtsanwalt erforderlich |
| Upload-Einwilligungsdialog aktiv | P-06 | Code | | Upload-Einwilligungsdialog aktiv | P-06 | Code |
| DSGVO-Löschprozess spezifiziert (Spec akzeptiert, auch wenn nicht vollständig implementiert) | P-02 | Spezifikation | | 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 | | Content-Melde-Backend (Minimalversion) aktiv | P-13 | Code |
| HSTS am externen Reverse-Proxy nachgewiesen | P-08 | Betreiber | | HSTS am externen Reverse-Proxy nachgewiesen | P-08 | Betreiber |
| Papierkorb-Retention-Job läuft (Monitoring aktiv) | P-03 | Bereits erfüllt | | 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 | | Gate 1 vollständig erfüllt | — | Voraussetzung |
| Upload-Einwilligungsdialog für Personenbilder aktiv | P-06 | Code | | 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 | | Content-Melde-Backend aktiv | P-13 | Code |
| Copyright-Pflicht bei Promotion aktiv und getestet | P-04 | Bereits erfüllt | | Copyright-Pflicht bei Promotion aktiv und getestet | P-04 | Bereits erfüllt |
| Moderationsprozess organisatorisch dokumentiert (wer, wie schnell, Eskalationspfad) | — | Betreiber | | 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-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-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.750.8.83) — technisch umgesetzt unter `p06-v1-conservative` inkl. P-06+ Volljournal + Korrektur; KRIT-04 bleibt bis juristische Validierung | | **P-06** | **„Freigabe zur Umsetzung P-06 auf Basis konservativer Erstannahmen"** | ✅ erteilt + vollständig umgesetzt (2026-05-11, v0.8.750.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 | | P-02 Spezifikation | „Freigabe zur Spezifikation P-02: DSGVO-Self-Service-Prozess" | offen |
--- ---

View File

@ -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 | | `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 | Kontext für Moderationsentscheidung | | 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) | Relevanzmerkmal für Meldekategorie | | `contains_identifiable_persons` | Relevant für Löschbegründung (KUG), `reason_code=consent_withdrawn` | Relevanzmerkmal für Meldekategorie |
| `contains_minors` | Höchste Priorität bei P-11-Sofortsperrung | CSAM-Eskalationspfad in P-13 | | `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.
--- ---

View File

@ -6307,6 +6307,65 @@ a.analysis-split__nav-item {
min-width: 0; 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) */ /* Vorschau-Modal (Vollbild nah) */
.media-library__overlay--preview { .media-library__overlay--preview {
background: rgba(0, 0, 0, 0.72); background: rgba(0, 0, 0, 0.72);

View File

@ -250,10 +250,24 @@ function eventTypeLabel(et) {
copyright_change: 'Copyright geändert', copyright_change: 'Copyright geändert',
metadata_change: 'Metadaten geändert', metadata_change: 'Metadaten geändert',
lifecycle_change: 'Lifecycle geändert', lifecycle_change: 'Lifecycle geändert',
legal_hold_set: 'Sofortsperre gesetzt',
legal_hold_released: 'Sofortsperre aufgehoben',
} }
return MAP[et] || et 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) { function visLabel(v) {
if (v === 'private') return 'Privat' if (v === 'private') return 'Privat'
if (v === 'club') return 'Verein' if (v === 'club') return 'Verein'
@ -324,6 +338,12 @@ export default function MediaLibraryPage() {
const [journalCorrectionOpen, setJournalCorrectionOpen] = useState(false) const [journalCorrectionOpen, setJournalCorrectionOpen] = useState(false)
const [journalCorrectionDraft, setJournalCorrectionDraft] = useState(null) const [journalCorrectionDraft, setJournalCorrectionDraft] = useState(null)
const [journalCorrectionBusy, setJournalCorrectionBusy] = useState(false) 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 mediaListFetchSeqRef = useRef(0)
const gridTopAnchorRef = useRef(null) 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) { function parseApiErrorCode(msg) {
try { try {
const d = JSON.parse(msg) const d = JSON.parse(msg)
@ -902,7 +964,12 @@ export default function MediaLibraryPage() {
</div> </div>
<div className="media-library__card-footer-row"> <div className="media-library__card-footer-row">
<MediaCardScopeStatus visibility={it.visibility} lifecycleState={it.lifecycle_state} /> <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 <span
style={{ fontSize: '0.7rem', color: 'var(--danger)', marginLeft: 4 }} style={{ fontSize: '0.7rem', color: 'var(--danger)', marginLeft: 4 }}
title="Altbestand Rechtserklärung nach neuem Standard (P-06) noch nicht erfasst" title="Altbestand Rechtserklärung nach neuem Standard (P-06) noch nicht erfasst"
@ -1401,6 +1468,43 @@ export default function MediaLibraryPage() {
</div> </div>
) : null} ) : 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 ? ( {modal.permissions?.superadmin_lifecycle ? (
<div className="media-library__lc-block media-library__lc-block--danger"> <div className="media-library__lc-block media-library__lc-block--danger">
<div className="media-library__lc-title">Superadmin</div> <div className="media-library__lc-title">Superadmin</div>
@ -1526,6 +1630,32 @@ export default function MediaLibraryPage() {
<span>{nw.copyright_notice || '—'}</span> <span>{nw.copyright_notice || '—'}</span>
</div> </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) => ( Object.keys(nw).map((k) => (
<div key={k} className="media-library__journal-audit-row"> <div key={k} className="media-library__journal-audit-row">
@ -1800,6 +1930,102 @@ export default function MediaLibraryPage() {
</div> </div>
</div> </div>
) : null} ) : 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> </div>
) )
} }

View File

@ -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) { export async function getExercise(id) {
return request(`/api/exercises/${id}`) return request(`/api/exercises/${id}`)
} }
@ -1430,6 +1449,9 @@ export const api = {
getMediaAssetJournal, getMediaAssetJournal,
addMediaAssetDeclarationCorrection, addMediaAssetDeclarationCorrection,
attachExerciseMediaFromAsset, attachExerciseMediaFromAsset,
setMediaAssetLegalHold,
releaseMediaAssetLegalHold,
listMediaAssetsWithLegalHold,
listExerciseProgressionGraphs, listExerciseProgressionGraphs,
getExerciseProgressionGraph, getExerciseProgressionGraph,
createExerciseProgressionGraph, createExerciseProgressionGraph,

View File

@ -1,6 +1,6 @@
// Shinkan Jinkendo Frontend Version // 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 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.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 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
} }