diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 5551519..ad075ee 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -265,6 +265,8 @@ class ExerciseUpdate(BaseModel): # §4.2: Übung → official — angehängte Datei-Assets anheben + Copyright (nur mit expliziter Bestätigung) promote_attached_media_for_official: Optional[bool] = None default_official_media_copyright: Optional[str] = Field(default=None, max_length=2000) + # Vereins-Übung: fehlende Copyrights an Datei-Assets nach Prompt-Text setzen (PUT-Retry) + default_club_media_copyright: Optional[str] = Field(default=None, max_length=2000) @model_validator(mode="after") def normalize_goal_execution(self): @@ -347,6 +349,7 @@ class ExerciseBulkMetadataPatch(BaseModel): target_group_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND) promote_attached_media_for_official: bool = False default_official_media_copyright: Optional[str] = Field(default=None, max_length=2000) + default_club_media_copyright: Optional[str] = Field(default=None, max_length=2000) @model_validator(mode="after") def at_least_one_patch_field(self): @@ -649,10 +652,19 @@ def apply_official_exercise_media_rules( ) -def apply_club_exercise_media_copyright_rules(cur, exercise_id: int, next_visibility: str) -> None: +def apply_club_exercise_media_copyright_rules( + cur, + exercise_id: int, + next_visibility: str, + *, + default_club_media_copyright: Optional[str] = None, +) -> None: """ Vereins-sichtbare Übung: angehängte Archiv-Dateien müssen aktiv sein und einen Copyright-Vermerk haben (wie bei offiziellen Übungen, ohne Sichtbarkeits-Promotion der Assets). + + Fehlt das Copyright, kann der Client nach Nutzerbestätigung `default_club_media_copyright` + mitsenden (mind. 3 Zeichen) — wird auf alle betroffenen Assets mit zu kurzem/leerem Vermerk gesetzt. """ nv = (next_visibility or "private").strip().lower() if nv != "club": @@ -701,7 +713,11 @@ def apply_club_exercise_media_copyright_rules(cur, exercise_id: int, next_visibi }, ) - if missing_cr: + if not missing_cr: + return + + default_cr = _normalize_media_copyright_notice(default_club_media_copyright) + if len(default_cr) < _MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN: raise HTTPException( status_code=422, detail={ @@ -714,6 +730,61 @@ def apply_club_exercise_media_copyright_rules(cur, exercise_id: int, next_visibi }, ) + ids = [int(x["media_asset_id"]) for x in missing_cr] + ph = ",".join(["%s"] * len(ids)) + cur.execute( + f""" + UPDATE media_assets + SET copyright_notice = %s, updated_at = NOW() + WHERE id IN ({ph}) + AND ( + copyright_notice IS NULL + OR LENGTH(TRIM(copyright_notice)) < %s + ) + """, + (default_cr, *ids, _MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN), + ) + + rows2 = _fetch_exercise_linked_file_assets(cur, exercise_id) + missing_after: List[Dict[str, Any]] = [] + blocking_after: List[Dict[str, Any]] = [] + for r in rows2: + aid = int(r["id"]) + lc = (r.get("lifecycle_state") or "").strip().lower() + cr = _normalize_media_copyright_notice(r.get("copyright_notice")) + if lc != "active": + blocking_after.append({"media_asset_id": aid}) + continue + if len(cr) < _MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN: + missing_after.append( + { + "media_asset_id": aid, + "original_filename": r.get("original_filename"), + } + ) + + if blocking_after: + raise HTTPException( + status_code=422, + detail={ + "code": "CLUB_MEDIA_LIFECYCLE", + "message": "Archiv-Medium nach Copyright-Update nicht mehr aktiv.", + "media_assets": blocking_after, + }, + ) + if missing_after: + raise HTTPException( + status_code=422, + detail={ + "code": "CLUB_MEDIA_COPYRIGHT_REQUIRED", + "message": ( + f"Copyright konnte nicht auf alle Dateien angewendet werden " + f"(mind. {_MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN} Zeichen pro Eintrag prüfen)." + ), + "media_assets": missing_after, + }, + ) + def _abs_media_path(file_path_db: str, media_root: Path) -> Optional[Path]: if not file_path_db or file_path_db.startswith("http"): @@ -1322,7 +1393,12 @@ def bulk_patch_exercises_metadata( if (next_vis or "").strip().lower() == "club": try: - apply_club_exercise_media_copyright_rules(cur, ex_id, next_vis) + apply_club_exercise_media_copyright_rules( + cur, + ex_id, + next_vis, + default_club_media_copyright=body.default_club_media_copyright, + ) except HTTPException as he: d = he.detail entry: Dict[str, Any] = {"id": ex_id} @@ -1913,6 +1989,7 @@ def update_exercise( raw_promo = data.pop("promote_attached_media_for_official", None) promote_media_flag = raw_promo is True default_official_copy = data.pop("default_official_media_copyright", None) + default_club_copy = data.pop("default_club_media_copyright", None) merged_rich = {fld: rich_row.get(fld) for fld in RICH_HTML_EXERCISE_FIELDS} for fld in RICH_HTML_EXERCISE_FIELDS: @@ -1993,7 +2070,12 @@ def update_exercise( assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False) try: - apply_club_exercise_media_copyright_rules(cur, exercise_id, next_vis) + apply_club_exercise_media_copyright_rules( + cur, + exercise_id, + next_vis, + default_club_media_copyright=default_club_copy, + ) except HTTPException: conn.rollback() raise diff --git a/backend/tests/test_club_exercise_media_copyright.py b/backend/tests/test_club_exercise_media_copyright.py index f322849..59fa8ba 100644 --- a/backend/tests/test_club_exercise_media_copyright.py +++ b/backend/tests/test_club_exercise_media_copyright.py @@ -60,3 +60,49 @@ def test_apply_club_exercise_media_copyright_ok() -> None: apply_club_exercise_media_copyright_rules(object(), 42, "club") finally: exercises_mod._fetch_exercise_linked_file_assets = orig + + +def test_apply_club_exercise_media_copyright_applies_default() -> None: + """Nach Bestätigung setzt der Client default_club_media_copyright — Backend schreibt auf Assets.""" + orig_fetch = exercises_mod._fetch_exercise_linked_file_assets + calls = {"n": 0} + + def mock_fetch(_cur, _eid: int): + calls["n"] += 1 + if calls["n"] == 1: + return [ + { + "id": 10, + "visibility": "private", + "club_id": 1, + "lifecycle_state": "active", + "copyright_notice": "", + "original_filename": "x.jpg", + } + ] + return [ + { + "id": 10, + "visibility": "private", + "club_id": 1, + "lifecycle_state": "active", + "copyright_notice": "© Testverein", + "original_filename": "x.jpg", + } + ] + + class MockCur: + def execute(self, *args, **kwargs): + pass + + exercises_mod._fetch_exercise_linked_file_assets = mock_fetch + try: + apply_club_exercise_media_copyright_rules( + MockCur(), + 42, + "club", + default_club_media_copyright="© Testverein", + ) + assert calls["n"] == 2 + finally: + exercises_mod._fetch_exercise_linked_file_assets = orig_fetch diff --git a/backend/version.py b/backend/version.py index 1272717..58e8700 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.64" +APP_VERSION = "0.8.65" BUILD_DATE = "2026-05-08" DB_SCHEMA_VERSION = "20260508049" @@ -17,7 +17,7 @@ MODULE_VERSIONS = { "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.19.0", # Inline-Medien §11: Fließtext-Platzhalter exercise_media.id, Normalisierung/Validierung; CREATE ohne Platzhalter + "exercises": "2.19.1", # Verein: PUT default_club_media_copyright + Prompt beim Speichern (fehlende File-Asset-Copyrights) "training_units": "0.2.0", "training_programs": "0.1.0", "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile @@ -29,6 +29,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.65", + "date": "2026-05-08", + "changes": [ + "Übung auf Verein: fehlende Copyrights an Datei-Assets wieder per Bestätigung + Eingabe beim Speichern setzbar (PUT default_club_media_copyright; Bulk-PATCH ebenfalls)", + ], + }, { "version": "0.8.64", "date": "2026-05-08", diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index a015b85..d00508b 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -654,10 +654,23 @@ function ExerciseFormPage() { firstErr.code === 'CLUB_MEDIA_COPYRIGHT_REQUIRED' && firstErr.payload?.media_assets ) { - alert( - 'Vereinsöffentliche Übungen brauchen bei jeder verknüpften Datei einen Copyright-Vermerk (mind. 3 Zeichen). Bitte in der Medienbibliothek oder den Mediendetails nachtragen.', + const miss = firstErr.payload.media_assets.length + const msg = + `Vereinsöffentliche Übungen brauchen bei jeder verknüpften Datei einen Copyright-Vermerk (mind. 3 Zeichen). ` + + `${miss} Datei(en) sind noch ohne ausreichenden Vermerk. ` + + `Beim Speichern einen gemeinsamen Vermerk für diese Dateien setzen?` + if (!window.confirm(msg)) throw firstErr + const defaultCopyright = window.prompt( + 'Copyright-Vermerk für die betroffenen Dateien (mind. 3 Zeichen):', + '© ', ) - throw firstErr + if (!defaultCopyright || String(defaultCopyright).trim().length < 3) { + alert('Mindestens 3 Zeichen für den Copyright-Vermerk.') + throw firstErr + } + await saveOnce({ + default_club_media_copyright: String(defaultCopyright).trim(), + }) } else if (firstErr.status === 422 && firstErr.code === 'CLUB_MEDIA_LIFECYCLE') { alert( 'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). Bitte reaktivieren oder entfernen.',