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({