Some checks failed
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Failing after 1m23s
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>
259 lines
9.8 KiB
Python
259 lines
9.8 KiB
Python
"""
|
||
P-04: Copyright-Pflicht bei Promotion auf club/official.
|
||
PATCH /api/media-assets/{id} und POST /api/media-assets/bulk-patch
|
||
lehnen eine Sichtbarkeits-Promotion auf club oder official ab,
|
||
wenn keine copyright_notice vorhanden ist.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from contextlib import ExitStack
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
||
|
||
from main import app
|
||
from tenant_context import TenantContext, get_tenant_context
|
||
|
||
_SUPERADMIN_TENANT = TenantContext(
|
||
profile_id=1,
|
||
global_role="superadmin",
|
||
effective_club_id=None,
|
||
club_ids=frozenset(),
|
||
memberships=[],
|
||
)
|
||
|
||
_PRIVATE_ASSET: dict = {
|
||
"id": 42,
|
||
"visibility": "private",
|
||
"club_id": 7,
|
||
"uploaded_by_profile_id": 1,
|
||
"lifecycle_state": "active",
|
||
"copyright_notice": None,
|
||
"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": [],
|
||
}
|
||
|
||
_ASSET_WITH_COPYRIGHT: dict = {**_PRIVATE_ASSET, "copyright_notice": "Verein 2026"}
|
||
|
||
|
||
@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}),
|
||
# P-06: bestehende Tests testen Copyright, nicht Rechteerklaerung – "ok" mocken
|
||
("routers.media_assets.check_rights_coverage", {"return_value": "ok"}),
|
||
]
|
||
|
||
|
||
def _enter_permission_patches(stack: ExitStack) -> None:
|
||
for target, kwargs in _PERMISSION_PATCHES:
|
||
stack.enter_context(patch(target, **kwargs))
|
||
|
||
|
||
# ── Single PATCH ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_patch_promote_to_club_without_copyright_returns_400(client: TestClient) -> None:
|
||
"""Promotion private -> club ohne copyright_notice muss 400 liefern."""
|
||
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))
|
||
_enter_permission_patches(stack)
|
||
r = client.patch(
|
||
"/api/media-assets/42",
|
||
json={"visibility": "club", "club_id": 7},
|
||
headers={"X-Auth-Token": "t"},
|
||
)
|
||
|
||
assert r.status_code == 400
|
||
detail = r.json()["detail"].lower()
|
||
assert "copyright" in detail or "urheberrecht" in detail
|
||
|
||
|
||
def test_patch_promote_to_official_without_copyright_returns_400(client: TestClient) -> None:
|
||
"""Promotion private -> official ohne copyright_notice muss 400 liefern."""
|
||
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))
|
||
_enter_permission_patches(stack)
|
||
r = client.patch(
|
||
"/api/media-assets/42",
|
||
json={"visibility": "official"},
|
||
headers={"X-Auth-Token": "t"},
|
||
)
|
||
|
||
assert r.status_code == 400
|
||
detail = r.json()["detail"].lower()
|
||
assert "copyright" in detail or "urheberrecht" in detail
|
||
|
||
|
||
def test_patch_promote_to_club_with_copyright_in_body_allowed(client: TestClient) -> None:
|
||
"""Promotion private -> club MIT copyright_notice im Body muss 200 liefern."""
|
||
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
|
||
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
|
||
updated_asset = {**_PRIVATE_ASSET, "visibility": "club", "copyright_notice": "Verein 2026"}
|
||
mock_cur.fetchone.side_effect = [_PRIVATE_ASSET, 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)
|
||
r = client.patch(
|
||
"/api/media-assets/42",
|
||
json={"visibility": "club", "club_id": 7, "copyright_notice": "Verein 2026"},
|
||
headers={"X-Auth-Token": "t"},
|
||
)
|
||
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["id"] == 42
|
||
assert body["visibility"] == "club"
|
||
assert body["copyright_notice"] == "Verein 2026"
|
||
|
||
|
||
def test_patch_promote_to_club_existing_copyright_allowed(client: TestClient) -> None:
|
||
"""Asset hat bereits copyright_notice -> Promotion ohne Body-Copyright muss 200 liefern."""
|
||
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
|
||
promoted_asset = {**_ASSET_WITH_COPYRIGHT, "visibility": "club"}
|
||
mock_cm, mock_cur = _make_db_mocks(_ASSET_WITH_COPYRIGHT)
|
||
mock_cur.fetchone.side_effect = [_ASSET_WITH_COPYRIGHT, promoted_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)
|
||
r = client.patch(
|
||
"/api/media-assets/42",
|
||
json={"visibility": "club", "club_id": 7},
|
||
headers={"X-Auth-Token": "t"},
|
||
)
|
||
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["id"] == 42
|
||
assert body["visibility"] == "club"
|
||
assert body["copyright_notice"] == "Verein 2026"
|
||
|
||
|
||
def test_patch_filename_only_no_copyright_check(client: TestClient) -> None:
|
||
"""Kein Visibility-Wechsel -> keine Copyright-Prufung."""
|
||
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,
|
||
{**_PRIVATE_ASSET, "original_filename": "neu.jpg"},
|
||
]
|
||
|
||
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={"original_filename": "neu.jpg"},
|
||
headers={"X-Auth-Token": "t"},
|
||
)
|
||
|
||
assert r.status_code == 200
|
||
|
||
|
||
# ── Bulk PATCH ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_bulk_patch_promote_to_club_without_copyright_in_failed(client: TestClient) -> None:
|
||
"""Bulk-Promotion ohne copyright_notice -> Asset in failed-Liste."""
|
||
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
|
||
mock_cur = MagicMock()
|
||
mock_cur.fetchone.return_value = _PRIVATE_ASSET
|
||
mock_conn = MagicMock()
|
||
mock_cm = MagicMock()
|
||
mock_cm.__enter__.return_value = mock_conn
|
||
mock_cm.__exit__.return_value = False
|
||
|
||
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.post(
|
||
"/api/media-assets/bulk-patch",
|
||
json={"media_asset_ids": [42], "visibility": "club", "club_id": 7},
|
||
headers={"X-Auth-Token": "t"},
|
||
)
|
||
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["updated_count"] == 0
|
||
assert body["failed_count"] == 1
|
||
detail = body["failed"][0]["detail"].lower()
|
||
assert "copyright" in detail or "urheberrecht" in detail
|
||
|
||
|
||
def test_bulk_patch_promote_to_club_with_copyright_in_updated(client: TestClient) -> None:
|
||
"""Bulk-Promotion MIT copyright_notice -> Asset in updated-Liste."""
|
||
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
|
||
mock_cur = MagicMock()
|
||
mock_cur.fetchone.return_value = _PRIVATE_ASSET
|
||
mock_conn = MagicMock()
|
||
mock_cm = MagicMock()
|
||
mock_cm.__enter__.return_value = mock_conn
|
||
mock_cm.__exit__.return_value = False
|
||
|
||
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.post(
|
||
"/api/media-assets/bulk-patch",
|
||
json={
|
||
"media_asset_ids": [42],
|
||
"visibility": "club",
|
||
"club_id": 7,
|
||
"copyright_notice": "Verein 2026",
|
||
},
|
||
headers={"X-Auth-Token": "t"},
|
||
)
|
||
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert 42 in body["updated"]
|
||
assert body["updated_count"] == 1
|