feat(compliance): P-06 Upload-Einwilligungsdialog v1-conservative
Implementiert server-seitige Rechteerklärungspflicht für alle Medien-Uploads
und Sichtbarkeits-Promotions (konservative Erstannahme: alle Uploads).
Backend:
- backend/media_rights.py (NEU): Kernmodul — validate_rights_declaration,
check_rights_coverage, assert_rights_for_promotion, assert_rights_for_exercise_link,
write_rights_declaration, update_rights_quick_fields
- backend/migrations/048_media_rights_declarations.sql (NEU): Tabelle
media_asset_rights_declarations (Append-only Audit-Log), Felder
rights_status/rights_visibility_level in media_assets
- backend/routers/media_assets.py: P-06-Pflichtprüfung in PATCH (single + bulk),
POST /api/media-assets/{id}/rights-declarations (Re-Deklaration),
GET /api/admin/media-rights/legacy-summary|legacy-assets (Admin-Endpoints)
- backend/routers/exercises.py: P-06-Felder in upload_exercise_media,
assert_rights_for_exercise_link in attach_exercise_media_from_asset
- backend/main.py: admin_rights_router registriert
Frontend:
- frontend/src/components/RightsDeclarationDialog.jsx (NEU): 9-Felder-Dialog
(konservativ: immer alle Fragen), Client-Validierung, VORLÄUFIG-Hinweis
- frontend/src/pages/MediaLibraryPage.jsx: Dialog-Intercept vor Upload,
Altbestand-Indikator (legacy_unreviewed)
- frontend/src/utils/api.js: P-06-Felder in bulkUploadMediaAssets weitergeleitet
Tests:
- backend/tests/test_media_rights_declaration.py (NEU): 28 Unit-/Integrationstests
- backend/tests/test_media_assets_archive.py: P-06 fetchone-Slots + Mock ergänzt
- backend/tests/test_media_assets_copyright_promotion.py: check_rights_coverage gemockt
- tests/dev-smoke-test.spec.js: 5 P-06 E2E-Tests ergänzt
Dokumentation:
- docs/compliance-implementation.md: P-06-Abschnitt
- docs/compliance-package-register.md: Status ⚠️ teilweise umgesetzt (KRIT-04 offen)
- docs/compliance-roadmap.md: P-06 im Freigaben-Log
Offen: KRIT-04 (rechtliche Finalisierung Einwilligungsformulierung) — technisch
vollständig, Rechtstext VORLÄUFIG.
version: 0.8.75
module: media_rights 1.0.0, media_assets 1.13.0, exercises 2.20.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 08:12:44 +02:00
|
|
|
|
"""
|
|
|
|
|
|
P-06: Rechte-Erklaerung – Backend-Tests.
|
|
|
|
|
|
|
|
|
|
|
|
Abgedeckt:
|
|
|
|
|
|
1. validate_rights_declaration – alle Pflichtfelder
|
|
|
|
|
|
2. check_rights_coverage – ok / insufficient / legacy / blocked
|
|
|
|
|
|
3. assert_rights_for_promotion – richtiges Fehlermuster
|
|
|
|
|
|
4. PATCH /api/media-assets/{id} – Promotion mit und ohne P-06
|
|
|
|
|
|
5. POST /api/media-assets/{id}/rights-declarations – Re-Deklaration
|
|
|
|
|
|
6. POST /api/media-assets/bulk-patch – P-06-Pfad im Bulk
|
|
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
from contextlib import ExitStack
|
|
|
|
|
|
from unittest.mock import MagicMock, patch, call
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
from fastapi import HTTPException
|
|
|
|
|
|
|
|
|
|
|
|
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
|
|
|
|
|
|
|
|
|
|
|
from main import app
|
|
|
|
|
|
from media_rights import (
|
|
|
|
|
|
validate_rights_declaration,
|
|
|
|
|
|
check_rights_coverage,
|
|
|
|
|
|
assert_rights_for_promotion,
|
|
|
|
|
|
rights_covers_target,
|
|
|
|
|
|
visibility_level,
|
|
|
|
|
|
)
|
|
|
|
|
|
from tenant_context import TenantContext, get_tenant_context
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Helpers
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
_SUPERADMIN_TENANT = TenantContext(
|
|
|
|
|
|
profile_id=1,
|
|
|
|
|
|
global_role="superadmin",
|
|
|
|
|
|
effective_club_id=None,
|
|
|
|
|
|
club_ids=frozenset(),
|
|
|
|
|
|
memberships=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
_FULL_DECL = {
|
|
|
|
|
|
"rights_holder_confirmed": True,
|
|
|
|
|
|
"contains_identifiable_persons": False,
|
|
|
|
|
|
"person_consent_confirmed": None,
|
|
|
|
|
|
"contains_minors": False,
|
|
|
|
|
|
"parental_consent_confirmed": None,
|
|
|
|
|
|
"contains_music": False,
|
|
|
|
|
|
"music_rights_confirmed": None,
|
|
|
|
|
|
"contains_third_party_content": False,
|
|
|
|
|
|
"third_party_rights_confirmed": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_PRIVATE_ASSET = {
|
|
|
|
|
|
"id": 42,
|
|
|
|
|
|
"visibility": "private",
|
|
|
|
|
|
"club_id": 7,
|
|
|
|
|
|
"uploaded_by_profile_id": 1,
|
|
|
|
|
|
"lifecycle_state": "active",
|
|
|
|
|
|
"copyright_notice": "Rechteinhaber 2026",
|
|
|
|
|
|
"original_filename": "foto.jpg",
|
|
|
|
|
|
"sha256": "a" * 64,
|
|
|
|
|
|
"storage_key": f"library/verein-7/image/{'a' * 64}.jpg",
|
|
|
|
|
|
"storage_backend": "local",
|
|
|
|
|
|
"mime_type": "image/jpeg",
|
|
|
|
|
|
"byte_size": 1024,
|
|
|
|
|
|
"created_at": None,
|
|
|
|
|
|
"tags": [],
|
|
|
|
|
|
"rights_status": "legacy_unreviewed",
|
|
|
|
|
|
"rights_declared_for_visibility": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_DECLARED_ASSET = {**_PRIVATE_ASSET, "rights_status": "declared", "rights_declared_for_visibility": "private"}
|
|
|
|
|
|
_BLOCKED_ASSET = {**_PRIVATE_ASSET, "rights_status": "blocked", "rights_declared_for_visibility": None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def client() -> TestClient:
|
|
|
|
|
|
return TestClient(app)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
|
def _clear_overrides():
|
|
|
|
|
|
yield
|
|
|
|
|
|
app.dependency_overrides.pop(get_tenant_context, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_db_mocks(asset: dict) -> tuple[MagicMock, MagicMock]:
|
|
|
|
|
|
mock_cur = MagicMock()
|
|
|
|
|
|
mock_cur.fetchone.return_value = asset
|
|
|
|
|
|
mock_conn = MagicMock()
|
|
|
|
|
|
mock_cm = MagicMock()
|
|
|
|
|
|
mock_cm.__enter__.return_value = mock_conn
|
|
|
|
|
|
mock_cm.__exit__.return_value = False
|
|
|
|
|
|
return mock_cm, mock_cur
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_PERMISSION_PATCHES = [
|
|
|
|
|
|
("routers.media_assets.assert_can_edit_media_asset_metadata", {}),
|
|
|
|
|
|
("routers.media_assets.assert_valid_governance_visibility", {}),
|
|
|
|
|
|
("routers.media_assets._media_assets_tags_column_present", {"return_value": False}),
|
|
|
|
|
|
("routers.media_assets.get_effective_media_root", {"return_value": "/tmp/media"}),
|
|
|
|
|
|
("routers.media_assets._relocate_asset_file_if_governance_changed", {"return_value": None}),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _enter_permission_patches(stack: ExitStack) -> None:
|
|
|
|
|
|
for target, kwargs in _PERMISSION_PATCHES:
|
|
|
|
|
|
stack.enter_context(patch(target, **kwargs))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
# 1. validate_rights_declaration – Unit-Tests (kein HTTP)
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
class TestValidateRightsDeclaration:
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_rights_holder_raises(self):
|
|
|
|
|
|
decl = {**_FULL_DECL, "rights_holder_confirmed": False}
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
validate_rights_declaration(decl, "private")
|
|
|
|
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
assert exc.value.detail["code"] == "RIGHTS_DECLARATION_REQUIRED"
|
|
|
|
|
|
|
|
|
|
|
|
def test_identifiable_persons_none_raises(self):
|
|
|
|
|
|
decl = {**_FULL_DECL, "contains_identifiable_persons": None}
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
validate_rights_declaration(decl, "private")
|
|
|
|
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
assert exc.value.detail["code"] == "RIGHTS_DECLARATION_REQUIRED"
|
|
|
|
|
|
|
|
|
|
|
|
def test_person_consent_required_when_persons_present(self):
|
|
|
|
|
|
decl = {**_FULL_DECL, "contains_identifiable_persons": True, "person_consent_confirmed": None}
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
validate_rights_declaration(decl, "club")
|
|
|
|
|
|
assert exc.value.detail["code"] == "PERSON_CONSENT_REQUIRED"
|
|
|
|
|
|
|
|
|
|
|
|
def test_minors_none_raises(self):
|
|
|
|
|
|
decl = {**_FULL_DECL, "contains_minors": None}
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
validate_rights_declaration(decl, "private")
|
|
|
|
|
|
assert exc.value.detail["code"] == "RIGHTS_DECLARATION_REQUIRED"
|
|
|
|
|
|
|
|
|
|
|
|
def test_parental_consent_required_when_minors_present(self):
|
|
|
|
|
|
decl = {**_FULL_DECL, "contains_minors": True, "parental_consent_confirmed": False}
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
validate_rights_declaration(decl, "official")
|
|
|
|
|
|
assert exc.value.detail["code"] == "PARENTAL_CONSENT_REQUIRED"
|
|
|
|
|
|
|
|
|
|
|
|
def test_music_none_raises(self):
|
|
|
|
|
|
decl = {**_FULL_DECL, "contains_music": None}
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
validate_rights_declaration(decl, "private")
|
|
|
|
|
|
assert exc.value.detail["code"] == "RIGHTS_DECLARATION_REQUIRED"
|
|
|
|
|
|
|
|
|
|
|
|
def test_music_rights_required_when_music_present(self):
|
|
|
|
|
|
decl = {**_FULL_DECL, "contains_music": True, "music_rights_confirmed": False}
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
validate_rights_declaration(decl, "private")
|
|
|
|
|
|
assert exc.value.detail["code"] == "MUSIC_RIGHTS_REQUIRED"
|
|
|
|
|
|
|
|
|
|
|
|
def test_third_party_none_raises(self):
|
|
|
|
|
|
decl = {**_FULL_DECL, "contains_third_party_content": None}
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
validate_rights_declaration(decl, "private")
|
|
|
|
|
|
assert exc.value.detail["code"] == "RIGHTS_DECLARATION_REQUIRED"
|
|
|
|
|
|
|
|
|
|
|
|
def test_third_party_rights_required_when_content_present(self):
|
|
|
|
|
|
decl = {**_FULL_DECL, "contains_third_party_content": True, "third_party_rights_confirmed": None}
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
validate_rights_declaration(decl, "official")
|
|
|
|
|
|
assert exc.value.detail["code"] == "THIRD_PARTY_RIGHTS_REQUIRED"
|
|
|
|
|
|
|
|
|
|
|
|
def test_full_clean_decl_passes(self):
|
|
|
|
|
|
validate_rights_declaration(_FULL_DECL, "official")
|
|
|
|
|
|
|
|
|
|
|
|
def test_full_decl_with_all_true_passes(self):
|
|
|
|
|
|
decl = {
|
|
|
|
|
|
"rights_holder_confirmed": True,
|
|
|
|
|
|
"contains_identifiable_persons": True,
|
|
|
|
|
|
"person_consent_confirmed": True,
|
|
|
|
|
|
"contains_minors": True,
|
|
|
|
|
|
"parental_consent_confirmed": True,
|
|
|
|
|
|
"contains_music": True,
|
|
|
|
|
|
"music_rights_confirmed": True,
|
|
|
|
|
|
"contains_third_party_content": True,
|
|
|
|
|
|
"third_party_rights_confirmed": True,
|
|
|
|
|
|
}
|
|
|
|
|
|
validate_rights_declaration(decl, "official")
|
|
|
|
|
|
|
|
|
|
|
|
def test_private_also_requires_full_declaration(self):
|
|
|
|
|
|
"""Konservative Erstannahme: private erfordert dieselbe Erklaerung wie official."""
|
|
|
|
|
|
decl = {**_FULL_DECL, "contains_identifiable_persons": None}
|
|
|
|
|
|
with pytest.raises(HTTPException):
|
|
|
|
|
|
validate_rights_declaration(decl, "private")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
# 2. check_rights_coverage – Unit-Tests mit Mock-Cursor
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
class TestCheckRightsCoverage:
|
|
|
|
|
|
|
|
|
|
|
|
def _cur(self, row):
|
|
|
|
|
|
cur = MagicMock()
|
|
|
|
|
|
cur.fetchone.return_value = row
|
|
|
|
|
|
return cur
|
|
|
|
|
|
|
|
|
|
|
|
def test_no_asset_returns_no_declaration(self):
|
|
|
|
|
|
cur = self._cur(None)
|
|
|
|
|
|
assert check_rights_coverage(cur, 1, "private") == "no_declaration"
|
|
|
|
|
|
|
|
|
|
|
|
def test_blocked_returns_blocked(self):
|
2026-05-11 09:45:06 +02:00
|
|
|
|
cur = self._cur({"rights_status": "blocked"})
|
feat(compliance): P-06 Upload-Einwilligungsdialog v1-conservative
Implementiert server-seitige Rechteerklärungspflicht für alle Medien-Uploads
und Sichtbarkeits-Promotions (konservative Erstannahme: alle Uploads).
Backend:
- backend/media_rights.py (NEU): Kernmodul — validate_rights_declaration,
check_rights_coverage, assert_rights_for_promotion, assert_rights_for_exercise_link,
write_rights_declaration, update_rights_quick_fields
- backend/migrations/048_media_rights_declarations.sql (NEU): Tabelle
media_asset_rights_declarations (Append-only Audit-Log), Felder
rights_status/rights_visibility_level in media_assets
- backend/routers/media_assets.py: P-06-Pflichtprüfung in PATCH (single + bulk),
POST /api/media-assets/{id}/rights-declarations (Re-Deklaration),
GET /api/admin/media-rights/legacy-summary|legacy-assets (Admin-Endpoints)
- backend/routers/exercises.py: P-06-Felder in upload_exercise_media,
assert_rights_for_exercise_link in attach_exercise_media_from_asset
- backend/main.py: admin_rights_router registriert
Frontend:
- frontend/src/components/RightsDeclarationDialog.jsx (NEU): 9-Felder-Dialog
(konservativ: immer alle Fragen), Client-Validierung, VORLÄUFIG-Hinweis
- frontend/src/pages/MediaLibraryPage.jsx: Dialog-Intercept vor Upload,
Altbestand-Indikator (legacy_unreviewed)
- frontend/src/utils/api.js: P-06-Felder in bulkUploadMediaAssets weitergeleitet
Tests:
- backend/tests/test_media_rights_declaration.py (NEU): 28 Unit-/Integrationstests
- backend/tests/test_media_assets_archive.py: P-06 fetchone-Slots + Mock ergänzt
- backend/tests/test_media_assets_copyright_promotion.py: check_rights_coverage gemockt
- tests/dev-smoke-test.spec.js: 5 P-06 E2E-Tests ergänzt
Dokumentation:
- docs/compliance-implementation.md: P-06-Abschnitt
- docs/compliance-package-register.md: Status ⚠️ teilweise umgesetzt (KRIT-04 offen)
- docs/compliance-roadmap.md: P-06 im Freigaben-Log
Offen: KRIT-04 (rechtliche Finalisierung Einwilligungsformulierung) — technisch
vollständig, Rechtstext VORLÄUFIG.
version: 0.8.75
module: media_rights 1.0.0, media_assets 1.13.0, exercises 2.20.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 08:12:44 +02:00
|
|
|
|
assert check_rights_coverage(cur, 1, "private") == "blocked"
|
|
|
|
|
|
|
|
|
|
|
|
def test_legacy_returns_legacy(self):
|
2026-05-11 09:45:06 +02:00
|
|
|
|
cur = self._cur({"rights_status": "legacy_unreviewed"})
|
feat(compliance): P-06 Upload-Einwilligungsdialog v1-conservative
Implementiert server-seitige Rechteerklärungspflicht für alle Medien-Uploads
und Sichtbarkeits-Promotions (konservative Erstannahme: alle Uploads).
Backend:
- backend/media_rights.py (NEU): Kernmodul — validate_rights_declaration,
check_rights_coverage, assert_rights_for_promotion, assert_rights_for_exercise_link,
write_rights_declaration, update_rights_quick_fields
- backend/migrations/048_media_rights_declarations.sql (NEU): Tabelle
media_asset_rights_declarations (Append-only Audit-Log), Felder
rights_status/rights_visibility_level in media_assets
- backend/routers/media_assets.py: P-06-Pflichtprüfung in PATCH (single + bulk),
POST /api/media-assets/{id}/rights-declarations (Re-Deklaration),
GET /api/admin/media-rights/legacy-summary|legacy-assets (Admin-Endpoints)
- backend/routers/exercises.py: P-06-Felder in upload_exercise_media,
assert_rights_for_exercise_link in attach_exercise_media_from_asset
- backend/main.py: admin_rights_router registriert
Frontend:
- frontend/src/components/RightsDeclarationDialog.jsx (NEU): 9-Felder-Dialog
(konservativ: immer alle Fragen), Client-Validierung, VORLÄUFIG-Hinweis
- frontend/src/pages/MediaLibraryPage.jsx: Dialog-Intercept vor Upload,
Altbestand-Indikator (legacy_unreviewed)
- frontend/src/utils/api.js: P-06-Felder in bulkUploadMediaAssets weitergeleitet
Tests:
- backend/tests/test_media_rights_declaration.py (NEU): 28 Unit-/Integrationstests
- backend/tests/test_media_assets_archive.py: P-06 fetchone-Slots + Mock ergänzt
- backend/tests/test_media_assets_copyright_promotion.py: check_rights_coverage gemockt
- tests/dev-smoke-test.spec.js: 5 P-06 E2E-Tests ergänzt
Dokumentation:
- docs/compliance-implementation.md: P-06-Abschnitt
- docs/compliance-package-register.md: Status ⚠️ teilweise umgesetzt (KRIT-04 offen)
- docs/compliance-roadmap.md: P-06 im Freigaben-Log
Offen: KRIT-04 (rechtliche Finalisierung Einwilligungsformulierung) — technisch
vollständig, Rechtstext VORLÄUFIG.
version: 0.8.75
module: media_rights 1.0.0, media_assets 1.13.0, exercises 2.20.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 08:12:44 +02:00
|
|
|
|
assert check_rights_coverage(cur, 1, "club") == "legacy"
|
|
|
|
|
|
|
2026-05-11 09:45:06 +02:00
|
|
|
|
def test_declared_covers_any_visibility(self):
|
|
|
|
|
|
# Eine bestehende Erklaerung gilt sichtbarkeitsunabhaengig
|
|
|
|
|
|
for target in ("private", "club", "official"):
|
|
|
|
|
|
cur = self._cur({"rights_status": "declared"})
|
|
|
|
|
|
assert check_rights_coverage(cur, 1, target) == "ok", f"failed for target={target}"
|
feat(compliance): P-06 Upload-Einwilligungsdialog v1-conservative
Implementiert server-seitige Rechteerklärungspflicht für alle Medien-Uploads
und Sichtbarkeits-Promotions (konservative Erstannahme: alle Uploads).
Backend:
- backend/media_rights.py (NEU): Kernmodul — validate_rights_declaration,
check_rights_coverage, assert_rights_for_promotion, assert_rights_for_exercise_link,
write_rights_declaration, update_rights_quick_fields
- backend/migrations/048_media_rights_declarations.sql (NEU): Tabelle
media_asset_rights_declarations (Append-only Audit-Log), Felder
rights_status/rights_visibility_level in media_assets
- backend/routers/media_assets.py: P-06-Pflichtprüfung in PATCH (single + bulk),
POST /api/media-assets/{id}/rights-declarations (Re-Deklaration),
GET /api/admin/media-rights/legacy-summary|legacy-assets (Admin-Endpoints)
- backend/routers/exercises.py: P-06-Felder in upload_exercise_media,
assert_rights_for_exercise_link in attach_exercise_media_from_asset
- backend/main.py: admin_rights_router registriert
Frontend:
- frontend/src/components/RightsDeclarationDialog.jsx (NEU): 9-Felder-Dialog
(konservativ: immer alle Fragen), Client-Validierung, VORLÄUFIG-Hinweis
- frontend/src/pages/MediaLibraryPage.jsx: Dialog-Intercept vor Upload,
Altbestand-Indikator (legacy_unreviewed)
- frontend/src/utils/api.js: P-06-Felder in bulkUploadMediaAssets weitergeleitet
Tests:
- backend/tests/test_media_rights_declaration.py (NEU): 28 Unit-/Integrationstests
- backend/tests/test_media_assets_archive.py: P-06 fetchone-Slots + Mock ergänzt
- backend/tests/test_media_assets_copyright_promotion.py: check_rights_coverage gemockt
- tests/dev-smoke-test.spec.js: 5 P-06 E2E-Tests ergänzt
Dokumentation:
- docs/compliance-implementation.md: P-06-Abschnitt
- docs/compliance-package-register.md: Status ⚠️ teilweise umgesetzt (KRIT-04 offen)
- docs/compliance-roadmap.md: P-06 im Freigaben-Log
Offen: KRIT-04 (rechtliche Finalisierung Einwilligungsformulierung) — technisch
vollständig, Rechtstext VORLÄUFIG.
version: 0.8.75
module: media_rights 1.0.0, media_assets 1.13.0, exercises 2.20.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 08:12:44 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
# 3. assert_rights_for_promotion – Fehlermuster
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
class TestAssertRightsForPromotion:
|
|
|
|
|
|
|
|
|
|
|
|
def _cur(self, row):
|
|
|
|
|
|
cur = MagicMock()
|
|
|
|
|
|
cur.fetchone.return_value = row
|
|
|
|
|
|
return cur
|
|
|
|
|
|
|
2026-05-11 09:45:06 +02:00
|
|
|
|
def test_ok_passes_for_any_target(self):
|
|
|
|
|
|
for target in ("private", "club", "official"):
|
|
|
|
|
|
cur = self._cur({"rights_status": "declared"})
|
|
|
|
|
|
assert_rights_for_promotion(cur, 1, target) # no raise
|
feat(compliance): P-06 Upload-Einwilligungsdialog v1-conservative
Implementiert server-seitige Rechteerklärungspflicht für alle Medien-Uploads
und Sichtbarkeits-Promotions (konservative Erstannahme: alle Uploads).
Backend:
- backend/media_rights.py (NEU): Kernmodul — validate_rights_declaration,
check_rights_coverage, assert_rights_for_promotion, assert_rights_for_exercise_link,
write_rights_declaration, update_rights_quick_fields
- backend/migrations/048_media_rights_declarations.sql (NEU): Tabelle
media_asset_rights_declarations (Append-only Audit-Log), Felder
rights_status/rights_visibility_level in media_assets
- backend/routers/media_assets.py: P-06-Pflichtprüfung in PATCH (single + bulk),
POST /api/media-assets/{id}/rights-declarations (Re-Deklaration),
GET /api/admin/media-rights/legacy-summary|legacy-assets (Admin-Endpoints)
- backend/routers/exercises.py: P-06-Felder in upload_exercise_media,
assert_rights_for_exercise_link in attach_exercise_media_from_asset
- backend/main.py: admin_rights_router registriert
Frontend:
- frontend/src/components/RightsDeclarationDialog.jsx (NEU): 9-Felder-Dialog
(konservativ: immer alle Fragen), Client-Validierung, VORLÄUFIG-Hinweis
- frontend/src/pages/MediaLibraryPage.jsx: Dialog-Intercept vor Upload,
Altbestand-Indikator (legacy_unreviewed)
- frontend/src/utils/api.js: P-06-Felder in bulkUploadMediaAssets weitergeleitet
Tests:
- backend/tests/test_media_rights_declaration.py (NEU): 28 Unit-/Integrationstests
- backend/tests/test_media_assets_archive.py: P-06 fetchone-Slots + Mock ergänzt
- backend/tests/test_media_assets_copyright_promotion.py: check_rights_coverage gemockt
- tests/dev-smoke-test.spec.js: 5 P-06 E2E-Tests ergänzt
Dokumentation:
- docs/compliance-implementation.md: P-06-Abschnitt
- docs/compliance-package-register.md: Status ⚠️ teilweise umgesetzt (KRIT-04 offen)
- docs/compliance-roadmap.md: P-06 im Freigaben-Log
Offen: KRIT-04 (rechtliche Finalisierung Einwilligungsformulierung) — technisch
vollständig, Rechtstext VORLÄUFIG.
version: 0.8.75
module: media_rights 1.0.0, media_assets 1.13.0, exercises 2.20.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 08:12:44 +02:00
|
|
|
|
|
|
|
|
|
|
def test_legacy_raises_legacy_code(self):
|
2026-05-11 09:45:06 +02:00
|
|
|
|
cur = self._cur({"rights_status": "legacy_unreviewed"})
|
feat(compliance): P-06 Upload-Einwilligungsdialog v1-conservative
Implementiert server-seitige Rechteerklärungspflicht für alle Medien-Uploads
und Sichtbarkeits-Promotions (konservative Erstannahme: alle Uploads).
Backend:
- backend/media_rights.py (NEU): Kernmodul — validate_rights_declaration,
check_rights_coverage, assert_rights_for_promotion, assert_rights_for_exercise_link,
write_rights_declaration, update_rights_quick_fields
- backend/migrations/048_media_rights_declarations.sql (NEU): Tabelle
media_asset_rights_declarations (Append-only Audit-Log), Felder
rights_status/rights_visibility_level in media_assets
- backend/routers/media_assets.py: P-06-Pflichtprüfung in PATCH (single + bulk),
POST /api/media-assets/{id}/rights-declarations (Re-Deklaration),
GET /api/admin/media-rights/legacy-summary|legacy-assets (Admin-Endpoints)
- backend/routers/exercises.py: P-06-Felder in upload_exercise_media,
assert_rights_for_exercise_link in attach_exercise_media_from_asset
- backend/main.py: admin_rights_router registriert
Frontend:
- frontend/src/components/RightsDeclarationDialog.jsx (NEU): 9-Felder-Dialog
(konservativ: immer alle Fragen), Client-Validierung, VORLÄUFIG-Hinweis
- frontend/src/pages/MediaLibraryPage.jsx: Dialog-Intercept vor Upload,
Altbestand-Indikator (legacy_unreviewed)
- frontend/src/utils/api.js: P-06-Felder in bulkUploadMediaAssets weitergeleitet
Tests:
- backend/tests/test_media_rights_declaration.py (NEU): 28 Unit-/Integrationstests
- backend/tests/test_media_assets_archive.py: P-06 fetchone-Slots + Mock ergänzt
- backend/tests/test_media_assets_copyright_promotion.py: check_rights_coverage gemockt
- tests/dev-smoke-test.spec.js: 5 P-06 E2E-Tests ergänzt
Dokumentation:
- docs/compliance-implementation.md: P-06-Abschnitt
- docs/compliance-package-register.md: Status ⚠️ teilweise umgesetzt (KRIT-04 offen)
- docs/compliance-roadmap.md: P-06 im Freigaben-Log
Offen: KRIT-04 (rechtliche Finalisierung Einwilligungsformulierung) — technisch
vollständig, Rechtstext VORLÄUFIG.
version: 0.8.75
module: media_rights 1.0.0, media_assets 1.13.0, exercises 2.20.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 08:12:44 +02:00
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
assert_rights_for_promotion(cur, 1, "club")
|
|
|
|
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
assert exc.value.detail["code"] == "LEGACY_REDECLARATION_REQUIRED"
|
|
|
|
|
|
|
|
|
|
|
|
def test_blocked_raises_403(self):
|
2026-05-11 09:45:06 +02:00
|
|
|
|
cur = self._cur({"rights_status": "blocked"})
|
feat(compliance): P-06 Upload-Einwilligungsdialog v1-conservative
Implementiert server-seitige Rechteerklärungspflicht für alle Medien-Uploads
und Sichtbarkeits-Promotions (konservative Erstannahme: alle Uploads).
Backend:
- backend/media_rights.py (NEU): Kernmodul — validate_rights_declaration,
check_rights_coverage, assert_rights_for_promotion, assert_rights_for_exercise_link,
write_rights_declaration, update_rights_quick_fields
- backend/migrations/048_media_rights_declarations.sql (NEU): Tabelle
media_asset_rights_declarations (Append-only Audit-Log), Felder
rights_status/rights_visibility_level in media_assets
- backend/routers/media_assets.py: P-06-Pflichtprüfung in PATCH (single + bulk),
POST /api/media-assets/{id}/rights-declarations (Re-Deklaration),
GET /api/admin/media-rights/legacy-summary|legacy-assets (Admin-Endpoints)
- backend/routers/exercises.py: P-06-Felder in upload_exercise_media,
assert_rights_for_exercise_link in attach_exercise_media_from_asset
- backend/main.py: admin_rights_router registriert
Frontend:
- frontend/src/components/RightsDeclarationDialog.jsx (NEU): 9-Felder-Dialog
(konservativ: immer alle Fragen), Client-Validierung, VORLÄUFIG-Hinweis
- frontend/src/pages/MediaLibraryPage.jsx: Dialog-Intercept vor Upload,
Altbestand-Indikator (legacy_unreviewed)
- frontend/src/utils/api.js: P-06-Felder in bulkUploadMediaAssets weitergeleitet
Tests:
- backend/tests/test_media_rights_declaration.py (NEU): 28 Unit-/Integrationstests
- backend/tests/test_media_assets_archive.py: P-06 fetchone-Slots + Mock ergänzt
- backend/tests/test_media_assets_copyright_promotion.py: check_rights_coverage gemockt
- tests/dev-smoke-test.spec.js: 5 P-06 E2E-Tests ergänzt
Dokumentation:
- docs/compliance-implementation.md: P-06-Abschnitt
- docs/compliance-package-register.md: Status ⚠️ teilweise umgesetzt (KRIT-04 offen)
- docs/compliance-roadmap.md: P-06 im Freigaben-Log
Offen: KRIT-04 (rechtliche Finalisierung Einwilligungsformulierung) — technisch
vollständig, Rechtstext VORLÄUFIG.
version: 0.8.75
module: media_rights 1.0.0, media_assets 1.13.0, exercises 2.20.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 08:12:44 +02:00
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
|
assert_rights_for_promotion(cur, 1, "official")
|
|
|
|
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
assert exc.value.detail["code"] == "RIGHTS_BLOCKED"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
# 4. PATCH /api/media-assets/{id} – P-06-Promotion via HTTP
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
class TestPatchP06Promotion:
|
|
|
|
|
|
|
|
|
|
|
|
def test_promote_legacy_without_decl_returns_400(self, client):
|
|
|
|
|
|
"""PATCH private->club ohne P-06-Felder muss LEGACY_REDECLARATION_REQUIRED liefern."""
|
|
|
|
|
|
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
|
|
|
|
|
|
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
|
|
|
|
|
|
mock_cur.fetchone.side_effect = [_PRIVATE_ASSET, {"rights_status": "legacy_unreviewed", "rights_declared_for_visibility": None}]
|
|
|
|
|
|
|
|
|
|
|
|
with ExitStack() as stack:
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
|
|
|
|
|
|
_enter_permission_patches(stack)
|
|
|
|
|
|
r = client.patch(
|
|
|
|
|
|
"/api/media-assets/42",
|
|
|
|
|
|
json={"visibility": "club", "club_id": 7, "copyright_notice": "Rechteinhaber 2026"},
|
|
|
|
|
|
headers={"X-Auth-Token": "t"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert r.status_code == 400
|
|
|
|
|
|
d = r.json()["detail"]
|
|
|
|
|
|
assert d["code"] == "LEGACY_REDECLARATION_REQUIRED"
|
|
|
|
|
|
|
|
|
|
|
|
def test_promote_legacy_with_full_decl_calls_write_declaration(self, client):
|
|
|
|
|
|
"""PATCH private->club mit vollstaendiger P-06-Erklaerung schreibt Declaration-Log."""
|
|
|
|
|
|
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
|
|
|
|
|
|
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
|
|
|
|
|
|
updated_asset = {**_PRIVATE_ASSET, "visibility": "club"}
|
|
|
|
|
|
mock_cur.fetchone.side_effect = [
|
|
|
|
|
|
_PRIVATE_ASSET,
|
|
|
|
|
|
{"rights_status": "legacy_unreviewed", "rights_declared_for_visibility": None},
|
|
|
|
|
|
updated_asset,
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
with ExitStack() as stack:
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
|
|
|
|
|
|
_enter_permission_patches(stack)
|
|
|
|
|
|
wr = stack.enter_context(patch("routers.media_assets.write_rights_declaration", return_value=1))
|
|
|
|
|
|
uq = stack.enter_context(patch("routers.media_assets.update_rights_quick_fields"))
|
|
|
|
|
|
r = client.patch(
|
|
|
|
|
|
"/api/media-assets/42",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"visibility": "club",
|
|
|
|
|
|
"club_id": 7,
|
|
|
|
|
|
"copyright_notice": "Rechteinhaber 2026",
|
|
|
|
|
|
**_FULL_DECL,
|
|
|
|
|
|
},
|
|
|
|
|
|
headers={"X-Auth-Token": "t"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
|
assert wr.called
|
|
|
|
|
|
call_args = wr.call_args
|
|
|
|
|
|
assert call_args.args[3] == "legacy_re_declaration"
|
|
|
|
|
|
assert call_args.args[4] == "club"
|
|
|
|
|
|
assert uq.called
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
# 5. POST /api/media-assets/{id}/rights-declarations – Re-Deklaration
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
class TestPostRightsDeclaration:
|
|
|
|
|
|
|
|
|
|
|
|
def test_redeclaration_for_legacy_asset_succeeds(self, client):
|
|
|
|
|
|
"""Nachdeklaration fuer Altmedium setzt action_type='legacy_re_declaration'."""
|
|
|
|
|
|
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
|
|
|
|
|
|
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
|
|
|
|
|
|
mock_cur.fetchone.return_value = _PRIVATE_ASSET
|
|
|
|
|
|
|
|
|
|
|
|
with ExitStack() as stack:
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.assert_can_edit_media_asset_metadata"))
|
|
|
|
|
|
wr = stack.enter_context(patch("routers.media_assets.write_rights_declaration", return_value=99))
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.update_rights_quick_fields"))
|
|
|
|
|
|
r = client.post(
|
|
|
|
|
|
"/api/media-assets/42/rights-declarations",
|
|
|
|
|
|
json={"target_visibility": "club", **_FULL_DECL},
|
|
|
|
|
|
headers={"X-Auth-Token": "t"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
|
data = r.json()
|
|
|
|
|
|
assert data["action_type"] == "legacy_re_declaration"
|
|
|
|
|
|
assert data["declaration_id"] == 99
|
|
|
|
|
|
assert wr.called
|
|
|
|
|
|
|
|
|
|
|
|
def test_redeclaration_incomplete_decl_returns_400_or_422(self, client):
|
|
|
|
|
|
"""Fehlende Erklaerungsfelder fuehren zu 400 (business logic) oder 422 (Pydantic)."""
|
|
|
|
|
|
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
|
|
|
|
|
|
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
|
|
|
|
|
|
|
|
|
|
|
|
with ExitStack() as stack:
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.assert_can_edit_media_asset_metadata"))
|
|
|
|
|
|
r = client.post(
|
|
|
|
|
|
"/api/media-assets/42/rights-declarations",
|
|
|
|
|
|
# Fehlende Pflichtfelder (contains_identifiable_persons etc.) -> Pydantic 422 ODER
|
|
|
|
|
|
# validate_rights_declaration 400 je nach welche Felder fehlen
|
|
|
|
|
|
json={"target_visibility": "private", "rights_holder_confirmed": True},
|
|
|
|
|
|
headers={"X-Auth-Token": "t"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert r.status_code in (400, 422)
|
|
|
|
|
|
|
|
|
|
|
|
def test_redeclaration_asset_not_found_returns_404(self, client):
|
|
|
|
|
|
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
|
|
|
|
|
|
mock_cm, mock_cur = _make_db_mocks(None)
|
|
|
|
|
|
|
|
|
|
|
|
with ExitStack() as stack:
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.assert_can_edit_media_asset_metadata"))
|
|
|
|
|
|
r = client.post(
|
|
|
|
|
|
"/api/media-assets/999/rights-declarations",
|
|
|
|
|
|
json={"target_visibility": "private", **_FULL_DECL},
|
|
|
|
|
|
headers={"X-Auth-Token": "t"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert r.status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
# 6. Bulk-Patch – P-06-Promotion im Batch
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
class TestBulkPatchP06:
|
|
|
|
|
|
|
|
|
|
|
|
def test_bulk_promote_legacy_without_decl_reports_failure(self, client):
|
|
|
|
|
|
"""Bulk-Patch: Legacy-Asset ohne P-06-Felder landet in 'failed', nicht 422."""
|
|
|
|
|
|
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
|
|
|
|
|
|
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
|
|
|
|
|
|
mock_cur.fetchone.side_effect = [
|
|
|
|
|
|
_PRIVATE_ASSET,
|
|
|
|
|
|
{"rights_status": "legacy_unreviewed", "rights_declared_for_visibility": None},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
with ExitStack() as stack:
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.assert_can_edit_media_asset_metadata"))
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.assert_valid_governance_visibility"))
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets._media_assets_tags_column_present", return_value=False))
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets.get_effective_media_root", return_value="/tmp"))
|
|
|
|
|
|
stack.enter_context(patch("routers.media_assets._relocate_asset_file_if_governance_changed", return_value=None))
|
|
|
|
|
|
r = client.post(
|
|
|
|
|
|
"/api/media-assets/bulk-patch",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"media_asset_ids": [42],
|
|
|
|
|
|
"visibility": "club",
|
|
|
|
|
|
"club_id": 7,
|
|
|
|
|
|
"copyright_notice": "Rechteinhaber 2026",
|
|
|
|
|
|
},
|
|
|
|
|
|
headers={"X-Auth-Token": "t"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
|
data = r.json()
|
|
|
|
|
|
assert data["failed_count"] == 1
|
|
|
|
|
|
assert data["updated_count"] == 0
|
|
|
|
|
|
assert "LEGACY_REDECLARATION_REQUIRED" in data["failed"][0]["detail"] or \
|
|
|
|
|
|
"RIGHTS_SCOPE_INSUFFICIENT" in data["failed"][0]["detail"]
|