From 4bc24b4caf82049787546ded6ff3b1e794d44f0d Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 11 May 2026 09:06:47 +0200 Subject: [PATCH] feat(p06): Copyright-Feld und Einwilligungskontext in Rechte-Erklaerung Migration 049: 4 optionale TEXT-Spalten in media_asset_rights_declarations (person_consent_context, parental_consent_context, music_rights_context, third_party_rights_context) fuer Freitext zum Einwilligungskontext. Backend: - media_rights.py: write_rights_declaration speichert 4 Kontextfelder - media_assets.py: copyright_notice + 4 Kontextfelder in Bulk-Upload, RightsDeclarationBody, MediaAssetPatch, MediaBulkPatchBody - exercises.py: copyright_notice + 4 Kontextfelder in upload_exercise_media, wird in INSERT gespeichert Frontend (alle 3 Formulare): - RightsDeclarationDialog: Copyright-Eingabefeld (immer sichtbar) + Freitext-Textarea bei jeder Ja-Antwort (Personen, Minderjaehrige, Musik, Fremdinhalte) - ExerciseInlineFileMediaModal: gleiche Felder inline im Upload-Tab - ExerciseInlineEmbedModal: gleiche Felder inline - api.js: copyright_notice + 4 Kontextfelder in bulkUploadMediaAssets version: 0.8.77 module: media_rights 1.1.0, media_assets 1.14.0, exercises 2.21.0 Co-Authored-By: Claude Sonnet 4.6 --- backend/media_rights.py | 20 ++- .../049_media_rights_consent_context.sql | 18 +++ backend/routers/exercises.py | 13 +- backend/routers/media_assets.py | 28 +++- backend/version.py | 15 +- .../components/ExerciseInlineEmbedModal.jsx | 125 +++++++-------- .../ExerciseInlineFileMediaModal.jsx | 127 +++++++-------- .../components/RightsDeclarationDialog.jsx | 148 +++++++++++++----- frontend/src/utils/api.js | 10 +- frontend/src/version.js | 2 +- 10 files changed, 313 insertions(+), 193 deletions(-) create mode 100644 backend/migrations/049_media_rights_consent_context.sql diff --git a/backend/media_rights.py b/backend/media_rights.py index 8ba75b9..feb49d7 100644 --- a/backend/media_rights.py +++ b/backend/media_rights.py @@ -290,6 +290,12 @@ def assert_rights_for_exercise_link(cur: Any, asset_id: int, exercise_visibility # Declaration-Log schreiben + Schnellfelder aktualisieren # -------------------------------------------------------------------------- +def _clean_context(val: Any) -> Optional[str]: + """Leere Strings → None, sonst auf 2000 Zeichen kuerzen.""" + s = (val or "").strip() + return s[:2000] if s else None + + def write_rights_declaration( cur: Any, asset_id: int, @@ -307,11 +313,11 @@ def write_rights_declaration( media_asset_id, declared_by_profile_id, action_type, target_visibility, declaration_version, rights_holder_confirmed, - contains_identifiable_persons, person_consent_confirmed, - contains_minors, parental_consent_confirmed, - contains_music, music_rights_confirmed, - contains_third_party_content, third_party_rights_confirmed - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + contains_identifiable_persons, person_consent_confirmed, person_consent_context, + contains_minors, parental_consent_confirmed, parental_consent_context, + contains_music, music_rights_confirmed, music_rights_context, + contains_third_party_content, third_party_rights_confirmed, third_party_rights_context + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""", ( asset_id, @@ -322,12 +328,16 @@ def write_rights_declaration( bool(decl.get("rights_holder_confirmed")), decl.get("contains_identifiable_persons"), decl.get("person_consent_confirmed"), + _clean_context(decl.get("person_consent_context")), decl.get("contains_minors"), decl.get("parental_consent_confirmed"), + _clean_context(decl.get("parental_consent_context")), decl.get("contains_music"), decl.get("music_rights_confirmed"), + _clean_context(decl.get("music_rights_context")), decl.get("contains_third_party_content"), decl.get("third_party_rights_confirmed"), + _clean_context(decl.get("third_party_rights_context")), ), ) row = cur.fetchone() diff --git a/backend/migrations/049_media_rights_consent_context.sql b/backend/migrations/049_media_rights_consent_context.sql new file mode 100644 index 0000000..be0c726 --- /dev/null +++ b/backend/migrations/049_media_rights_consent_context.sql @@ -0,0 +1,18 @@ +-- Migration 049: P-06 Erweiterung – Einwilligungskontext-Felder und Copyright im Upload-Dialog +-- Optionale Freitextfelder fuer den Kontext der Einwilligung (z.B. "Schriftliche Einwilligung +-- vom 2026-05-01 liegt vor") sowie copyright_notice direkt beim Upload erfassbar. + +ALTER TABLE media_asset_rights_declarations + ADD COLUMN IF NOT EXISTS person_consent_context TEXT, + ADD COLUMN IF NOT EXISTS parental_consent_context TEXT, + ADD COLUMN IF NOT EXISTS music_rights_context TEXT, + ADD COLUMN IF NOT EXISTS third_party_rights_context TEXT; + +COMMENT ON COLUMN media_asset_rights_declarations.person_consent_context IS + 'Optionaler Freitext: In welchem Zusammenhang liegt die Einwilligung der abgebildeten Personen vor?'; +COMMENT ON COLUMN media_asset_rights_declarations.parental_consent_context IS + 'Optionaler Freitext: In welchem Zusammenhang liegt die Einwilligung der Sorgeberechtigten vor?'; +COMMENT ON COLUMN media_asset_rights_declarations.music_rights_context IS + 'Optionaler Freitext: Welche Lizenz / GEMA-Regelung liegt fuer die Musik vor?'; +COMMENT ON COLUMN media_asset_rights_declarations.third_party_rights_context IS + 'Optionaler Freitext: Auf welcher Grundlage duerfen die enthaltenen Fremdinhalte verwendet werden?'; diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 53e985e..5191949 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -2527,16 +2527,21 @@ async def upload_exercise_media( description: str = Form(""), context: str = Form("ablauf"), is_primary: bool = Form(False), + copyright_notice: Optional[str] = Form(None), # P-06: Rechte-Erklaerung (Pflicht bei Datei-Upload mit neuem media_asset) rights_holder_confirmed: Optional[bool] = Form(None), contains_identifiable_persons: Optional[bool] = Form(None), person_consent_confirmed: Optional[bool] = Form(None), + person_consent_context: Optional[str] = Form(None), contains_minors: Optional[bool] = Form(None), parental_consent_confirmed: Optional[bool] = Form(None), + parental_consent_context: Optional[str] = Form(None), contains_music: Optional[bool] = Form(None), music_rights_confirmed: Optional[bool] = Form(None), + music_rights_context: Optional[str] = Form(None), contains_third_party_content: Optional[bool] = Form(None), third_party_rights_confirmed: Optional[bool] = Form(None), + third_party_rights_context: Optional[str] = Form(None), ): profile_id = tenant.profile_id if media_type not in ("image", "video", "document", "sketch"): @@ -2775,11 +2780,12 @@ async def upload_exercise_media( if not dest_path.is_file(): dest_path.write_bytes(raw) + clean_cr = (copyright_notice or "").strip() or None cur.execute( """INSERT INTO media_assets ( mime_type, byte_size, sha256, original_filename, visibility, club_id, uploaded_by_profile_id, copyright_notice, storage_backend, storage_key, lifecycle_state - ) VALUES (%s, %s, %s, %s, %s, %s, %s, NULL, 'local', %s, 'active') + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'local', %s, 'active') RETURNING id""", ( mime, @@ -2789,6 +2795,7 @@ async def upload_exercise_media( ex_vis, dedupe_club, profile_id, + clean_cr, storage_key, ), ) @@ -2799,12 +2806,16 @@ async def upload_exercise_media( "rights_holder_confirmed": rights_holder_confirmed, "contains_identifiable_persons": contains_identifiable_persons, "person_consent_confirmed": person_consent_confirmed, + "person_consent_context": person_consent_context, "contains_minors": contains_minors, "parental_consent_confirmed": parental_consent_confirmed, + "parental_consent_context": parental_consent_context, "contains_music": contains_music, "music_rights_confirmed": music_rights_confirmed, + "music_rights_context": music_rights_context, "contains_third_party_content": contains_third_party_content, "third_party_rights_confirmed": third_party_rights_confirmed, + "third_party_rights_context": third_party_rights_context, } validate_rights_declaration(p06_decl, ex_vis) write_rights_declaration(cur, aid, profile_id, "upload", ex_vis, p06_decl) diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index 2bb06e6..e135f67 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -82,12 +82,16 @@ class MediaAssetPatch(BaseModel): rights_holder_confirmed: Optional[bool] = None contains_identifiable_persons: Optional[bool] = None person_consent_confirmed: Optional[bool] = None + person_consent_context: Optional[str] = Field(None, max_length=2000) contains_minors: Optional[bool] = None parental_consent_confirmed: Optional[bool] = None + parental_consent_context: Optional[str] = Field(None, max_length=2000) contains_music: Optional[bool] = None music_rights_confirmed: Optional[bool] = None + music_rights_context: Optional[str] = Field(None, max_length=2000) contains_third_party_content: Optional[bool] = None third_party_rights_confirmed: Optional[bool] = None + third_party_rights_context: Optional[str] = Field(None, max_length=2000) class RightsDeclarationBody(BaseModel): @@ -96,12 +100,16 @@ class RightsDeclarationBody(BaseModel): rights_holder_confirmed: bool contains_identifiable_persons: bool person_consent_confirmed: Optional[bool] = None + person_consent_context: Optional[str] = Field(None, max_length=2000) contains_minors: bool parental_consent_confirmed: Optional[bool] = None + parental_consent_context: Optional[str] = Field(None, max_length=2000) contains_music: bool music_rights_confirmed: Optional[bool] = None + music_rights_context: Optional[str] = Field(None, max_length=2000) contains_third_party_content: bool third_party_rights_confirmed: Optional[bool] = None + third_party_rights_context: Optional[str] = Field(None, max_length=2000) class MediaBulkLifecycleBody(BaseModel): @@ -132,6 +140,11 @@ class MediaBulkPatchBody(BaseModel): original_filename: Optional[str] = Field(None, max_length=300) visibility: Optional[str] = Field(None, pattern="^(private|club|official)$") club_id: Optional[int] = None + # P-06 Kontext (bei Promotion) + person_consent_context: Optional[str] = Field(None, max_length=2000) + parental_consent_context: Optional[str] = Field(None, max_length=2000) + music_rights_context: Optional[str] = Field(None, max_length=2000) + third_party_rights_context: Optional[str] = Field(None, max_length=2000) tags: Optional[list[str]] = None # P-06: Rechterklaerung (gilt fuer alle Assets des Batches bei Promotion) rights_holder_confirmed: Optional[bool] = None @@ -671,6 +684,7 @@ def _ingest_library_media_file( visibility: str, club_id_form: Optional[int], decl: Optional[dict] = None, + copyright_notice: Optional[str] = None, ) -> dict: """Neues Archiv-Medium oder aktiver Dedupe-Treffer (sha256 + Sichtbarkeit + Verein). Kein exercise_media. @@ -809,11 +823,12 @@ def _ingest_library_media_file( if not dest_path.is_file(): dest_path.write_bytes(raw) + clean_cr = (copyright_notice or "").strip() or None cur.execute( """INSERT INTO media_assets ( mime_type, byte_size, sha256, original_filename, visibility, club_id, uploaded_by_profile_id, copyright_notice, storage_backend, storage_key, lifecycle_state - ) VALUES (%s, %s, %s, %s, %s, %s, %s, NULL, 'local', %s, 'active') + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'local', %s, 'active') RETURNING id""", ( mime, @@ -823,6 +838,7 @@ def _ingest_library_media_file( vis, next_cid, profile_id, + clean_cr, storage_key, ), ) @@ -841,16 +857,21 @@ async def bulk_upload_media_assets( files: list[UploadFile] = File(..., description="Mehrere Dateien (jpeg, png, gif, mp4, pdf)"), visibility: str = Form("private"), club_id: Optional[int] = Form(None), + copyright_notice: Optional[str] = Form(None), # P-06 Rechterklaerung (gilt fuer alle Dateien des Batches) rights_holder_confirmed: bool = Form(...), contains_identifiable_persons: bool = Form(...), person_consent_confirmed: Optional[bool] = Form(None), + person_consent_context: Optional[str] = Form(None), contains_minors: bool = Form(...), parental_consent_confirmed: Optional[bool] = Form(None), + parental_consent_context: Optional[str] = Form(None), contains_music: bool = Form(...), music_rights_confirmed: Optional[bool] = Form(None), + music_rights_context: Optional[str] = Form(None), contains_third_party_content: bool = Form(...), third_party_rights_confirmed: Optional[bool] = Form(None), + third_party_rights_context: Optional[str] = Form(None), ): """Mehrere Dateien ins Archiv; Dedupe wie Übungs-Upload. Pro Datei eigene DB-Transaktion. @@ -869,12 +890,16 @@ async def bulk_upload_media_assets( "rights_holder_confirmed": rights_holder_confirmed, "contains_identifiable_persons": contains_identifiable_persons, "person_consent_confirmed": person_consent_confirmed, + "person_consent_context": person_consent_context, "contains_minors": contains_minors, "parental_consent_confirmed": parental_consent_confirmed, + "parental_consent_context": parental_consent_context, "contains_music": contains_music, "music_rights_confirmed": music_rights_confirmed, + "music_rights_context": music_rights_context, "contains_third_party_content": contains_third_party_content, "third_party_rights_confirmed": third_party_rights_confirmed, + "third_party_rights_context": third_party_rights_context, } target_vis = (visibility or "private").strip().lower() validate_rights_declaration(decl, target_vis) @@ -901,6 +926,7 @@ async def bulk_upload_media_assets( visibility, club_id, decl=decl, + copyright_notice=copyright_notice, ) conn.commit() results.append({"filename": fn, "ok": True, **r}) diff --git a/backend/version.py b/backend/version.py index 8bdb2a6..35012ba 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.76" +APP_VERSION = "0.8.77" BUILD_DATE = "2026-05-11" DB_SCHEMA_VERSION = "20260511048" @@ -14,12 +14,12 @@ MODULE_VERSIONS = { "club_join_requests": "1.0.1", # Depends(get_tenant_context) "admin_users": "1.0.0", # GET /api/admin/users "platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT) - "media_rights": "1.0.0", # P-06: zentrales Policy-Modul (validate, coverage, write declaration) - "media_assets": "1.13.0", # P-06: Rechte-Erklaerung bei Upload/Promotion; Re-Deklarations-Endpoint; Admin-Legacy-Summary + "media_rights": "1.1.0", # P-06+: write_rights_declaration + 4 Kontext-Freitextfelder + "media_assets": "1.14.0", # P-06+: copyright_notice im Upload-Dialog; 4 Kontext-Felder in Bulk-Upload + PATCH + Re-Deklaration "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.20.0", # P-06: upload_exercise_media + from-asset Rechtspruefung + "exercises": "2.21.0", # P-06+: copyright_notice + 4 Kontext-Felder in upload_exercise_media "training_units": "0.2.0", "training_programs": "0.1.0", "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile @@ -31,6 +31,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.77", + "date": "2026-05-11", + "changes": [ + "P-06 Erweiterung: Migration 049 — 4 optionale Freitext-Kontextfelder in media_asset_rights_declarations (person_consent_context, parental_consent_context, music_rights_context, third_party_rights_context); copyright_notice direkt im Upload-Dialog erfassbar; alle drei Dialoge (RightsDeclarationDialog, ExerciseInlineFileMediaModal, ExerciseInlineEmbedModal) und alle Backend-Endpoints aktualisiert.", + ], + }, { "version": "0.8.76", "date": "2026-05-11", diff --git a/frontend/src/components/ExerciseInlineEmbedModal.jsx b/frontend/src/components/ExerciseInlineEmbedModal.jsx index a5a0ea5..ae7a5ad 100644 --- a/frontend/src/components/ExerciseInlineEmbedModal.jsx +++ b/frontend/src/components/ExerciseInlineEmbedModal.jsx @@ -11,15 +11,20 @@ import { import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption' const DECL_INIT = { + copyright_notice: '', rights_holder_confirmed: false, contains_identifiable_persons: null, person_consent_confirmed: false, + person_consent_context: '', contains_minors: null, parental_consent_confirmed: false, + parental_consent_context: '', contains_music: null, music_rights_confirmed: false, + music_rights_context: '', contains_third_party_content: null, third_party_rights_confirmed: false, + third_party_rights_context: '', } function validateDecl(decl) { @@ -178,15 +183,17 @@ export default function ExerciseInlineEmbedModal({ Rechte-Erklärung (VORLÄUFIG – p06-v1-conservative)

+
+ + setDeclField('copyright_notice', e.target.value)} + disabled={busy} style={{ fontSize: '0.85rem' }} /> +
+
- setDeclField('rights_holder_confirmed', e.target.checked)} - disabled={busy} - style={{ marginTop: '3px', flexShrink: 0 }} - /> + disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} /> @@ -194,96 +201,72 @@ export default function ExerciseInlineEmbedModal({
Erkennbare Personen abgebildet? * -
- - +
+ +
{decl.contains_identifiable_persons === true && ( -
- setDeclField('person_consent_confirmed', e.target.checked)} - disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} /> - +
+
+ setDeclField('person_consent_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} /> + +
+ +