diff --git a/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md b/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md index 0c4b667..45854c9 100644 --- a/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md +++ b/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md @@ -188,7 +188,7 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa |-------|----------| | 2026-05-07 | Erstfassung als Single Source of Truth (verbindlich); Abstimmung mit Stakeholder: Promotion Übung↔Medien, Copyright, Papierkorb 3-stufig, externe Speicher, Embeds getrennt vom Asset-Lifecycle. | | 2026-05-07 | §11 **Inline-Medien im Fließtext**: Leitplanken (Anker `exercise_media.id`, einheitlicher Render-Pfad, keine zweite Governance); Zeitpunkt der Umsetzung; Drift-Vermeidung ohne jetzigen Vollbau. | -| 2026-05-07 | Archiv-API: `GET /api/media-assets`, `GET /api/media-assets/{id}/file`; `POST /api/exercises/{id}/media/from-asset`; UI Picker/Vorschau. Papierkorb/Retention wie vorhergehende Releases. | +| 2026-05-07 | **Promotion Übung → `official` (Umsetzung):** PUT `/api/exercises/{id}` und `PATCH /api/exercises/bulk-metadata` prüfen angehängte `media_assets` (aktiv, Sichtbarkeit, Copyright ≥3 Zeichen); optional `promote_attached_media_for_official` + `default_official_media_copyright`; Antwort-Codes `OFFICIAL_MEDIA_LIFECYCLE`, `OFFICIAL_MEDIA_CONFIRM_REQUIRED`. | --- diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 2221719..f72a770 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -12,7 +12,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | | | exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin | | exercises | `GET .../media/{mid}/file` | ja | `get_tenant_context_flexible` | ja (wie Übung lesen) | Datei oder `?ssetoken`; kein anonymes `/media/` ohne ALLOW_PUBLIC_MEDIA_STATIC | -| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | | +| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) | | exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar | | training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id | | training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id | diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 53f8d3d..ba7f919 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -188,6 +188,9 @@ class ExerciseUpdate(BaseModel): visibility: Optional[str] = None status: Optional[str] = None club_id: Optional[int] = None + # §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) @model_validator(mode="after") def normalize_goal_execution(self): @@ -268,6 +271,8 @@ class ExerciseBulkMetadataPatch(BaseModel): style_direction_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND) training_type_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND) 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) @model_validator(mode="after") def at_least_one_patch_field(self): @@ -429,6 +434,147 @@ def _count_exercise_media(cur, exercise_id: int) -> int: return int(r["c"] if isinstance(r, dict) else r[0]) +_MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN = 3 + + +def _fetch_exercise_linked_file_assets(cur, exercise_id: int) -> List[Dict[str, Any]]: + cur.execute( + """ + SELECT ma.id, ma.visibility, ma.club_id, ma.lifecycle_state, ma.copyright_notice, + ma.original_filename + FROM exercise_media em + INNER JOIN media_assets ma ON ma.id = em.media_asset_id + WHERE em.exercise_id = %s + """, + (exercise_id,), + ) + return [r2d(r) for r in cur.fetchall()] + + +def _normalize_media_copyright_notice(val: Any) -> str: + return (val or "").strip() + + +def apply_official_exercise_media_rules( + cur, + exercise_id: int, + next_visibility: str, + *, + promote_attached_media: bool, + default_official_media_copyright: Optional[str], +) -> None: + """ + §4.2 MEDIA_ASSETS_AND_ARCHIVE_SPEC: Offizielle Übung mit Datei-Assets — + nur aktive Assets, Sichtbarkeit official, Copyright mindestens konfiguriert. + Bei Zustimmung werden Sichtbarkeit und fehlende Copyrights gesetzt. + """ + nv = (next_visibility or "private").strip().lower() + if nv != "official": + return + + rows = _fetch_exercise_linked_file_assets(cur, exercise_id) + if not rows: + return + + blocking_lc: List[Dict[str, Any]] = [] + need_promo: List[Dict[str, Any]] = [] + need_cr_ids: List[int] = [] + + for r in rows: + aid = int(r["id"]) + lc = (r.get("lifecycle_state") or "").strip().lower() + vis = (r.get("visibility") or "").strip().lower() + cr = _normalize_media_copyright_notice(r.get("copyright_notice")) + + if lc != "active": + blocking_lc.append( + { + "media_asset_id": aid, + "lifecycle_state": lc, + "visibility": vis, + "original_filename": r.get("original_filename"), + } + ) + continue + if vis != "official": + need_promo.append( + { + "media_asset_id": aid, + "visibility": vis, + "original_filename": r.get("original_filename"), + } + ) + if len(cr) < _MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN: + need_cr_ids.append(aid) + + if blocking_lc: + raise HTTPException( + status_code=422, + detail={ + "code": "OFFICIAL_MEDIA_LIFECYCLE", + "message": ( + "Nicht aktive Archiv-Medien dürfen nicht an einer offiziellen Übung hängen " + "(Papierkorb/Recovery zuerst)." + ), + "media_assets": blocking_lc, + }, + ) + + default_cr = _normalize_media_copyright_notice(default_official_media_copyright) + + if need_promo and not promote_attached_media: + raise HTTPException( + status_code=422, + detail={ + "code": "OFFICIAL_MEDIA_CONFIRM_REQUIRED", + "message": "Zugeordnete Dateien sind noch nicht offiziell — Bestätigung erforderlich.", + "assets_need_visibility_promotion": need_promo, + "assets_missing_copyright": sorted(set(need_cr_ids)), + }, + ) + + if need_cr_ids and len(default_cr) < _MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN: + raise HTTPException( + status_code=422, + detail={ + "code": "OFFICIAL_MEDIA_CONFIRM_REQUIRED", + "message": ( + "Für offizielle Übungen ist ein Copyright-Vermerk pro Datei erforderlich " + f"(mind. {_MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN} Zeichen)." + ), + "assets_need_visibility_promotion": [], + "assets_missing_copyright": sorted(set(need_cr_ids)), + }, + ) + + promo_ids = [int(x["media_asset_id"]) for x in need_promo] if promote_attached_media else [] + if promo_ids: + ph = ",".join(["%s"] * len(promo_ids)) + cur.execute( + f""" + UPDATE media_assets + SET visibility = 'official', club_id = NULL, updated_at = NOW() + WHERE id IN ({ph}) AND lower(trim(lifecycle_state)) = 'active' + """, + tuple(promo_ids), + ) + + if need_cr_ids and len(default_cr) >= _MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN: + ph = ",".join(["%s"] * len(need_cr_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, *need_cr_ids, _MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN), + ) + + def _abs_media_path(file_path_db: str, media_root: Path) -> Optional[Path]: if not file_path_db or file_path_db.startswith("http"): return None @@ -1007,6 +1153,33 @@ def bulk_patch_exercises_metadata( failed.append({"id": ex_id, "detail": _fail_msg(he)}) continue + if (next_vis or "").strip().lower() == "official": + try: + apply_official_exercise_media_rules( + cur, + ex_id, + next_vis, + promote_attached_media=body.promote_attached_media_for_official is True, + default_official_media_copyright=body.default_official_media_copyright, + ) + except HTTPException as he: + d = he.detail + entry: Dict[str, Any] = {"id": ex_id} + if isinstance(d, dict): + entry["detail"] = str(d.get("message") or d.get("code") or "Medien-Validierung fehlgeschlagen") + if "code" in d: + entry["code"] = d["code"] + if "media_assets" in d: + entry["media_assets"] = d["media_assets"] + if "assets_need_visibility_promotion" in d: + entry["assets_need_visibility_promotion"] = d["assets_need_visibility_promotion"] + if "assets_missing_copyright" in d: + entry["assets_missing_copyright"] = d["assets_missing_copyright"] + else: + entry["detail"] = _fail_msg(he) + failed.append(entry) + continue + sets: List[str] = [] vals: List[Any] = [] if patch_visibility: @@ -1561,6 +1734,9 @@ def update_exercise( ex_cid = int(ex_cid) data = body.dict(exclude_unset=True) + 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) next_vis = ex_vis if "visibility" in data and data["visibility"] is not None: @@ -1594,6 +1770,14 @@ def update_exercise( cur, profile_id, tenant.global_role, next_vis, gov_club ) + apply_official_exercise_media_rules( + cur, + exercise_id, + next_vis, + promote_attached_media=promote_media_flag, + default_official_media_copyright=default_official_copy, + ) + fields = [] params = [] @@ -1613,9 +1797,9 @@ def update_exercise( params.append(exercise_id) query = f"UPDATE exercises SET {', '.join(fields)} WHERE id = %s" cur.execute(query, params) - conn.commit() - assign_exercise_relations(cur, conn, exercise_id, data) + assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False) + conn.commit() exercise = enrich_exercise_detail(exercise_id, cur) diff --git a/backend/tests/test_official_exercise_media_rules.py b/backend/tests/test_official_exercise_media_rules.py new file mode 100644 index 0000000..d7de2ba --- /dev/null +++ b/backend/tests/test_official_exercise_media_rules.py @@ -0,0 +1,107 @@ +"""§4.2: apply_official_exercise_media_rules — Lifecycle, Sichtbarkeit, Copyright.""" +from __future__ import annotations + +import os +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException + +os.environ.setdefault("SKIP_DB_MIGRATE", "1") + +from routers.exercises import apply_official_exercise_media_rules + + +def _row( + aid: int, + *, + vis: str = "private", + lifecycle: str = "active", + copyright_notice: str | None = "", + name: str = "f.bin", +) -> dict: + return { + "id": aid, + "visibility": vis, + "club_id": 1, + "lifecycle_state": lifecycle, + "copyright_notice": copyright_notice, + "original_filename": name, + } + + +def test_non_official_visibility_noop() -> None: + cur = MagicMock() + apply_official_exercise_media_rules( + cur, + 1, + "private", + promote_attached_media=False, + default_official_media_copyright=None, + ) + cur.execute.assert_not_called() + + +def test_lifecycle_not_active_422() -> None: + cur = MagicMock() + cur.fetchall.return_value = [_row(1, lifecycle="trash_soft", copyright_notice="abc")] + with pytest.raises(HTTPException) as ei: + apply_official_exercise_media_rules( + cur, + 1, + "official", + promote_attached_media=False, + default_official_media_copyright=None, + ) + assert ei.value.status_code == 422 + assert ei.value.detail["code"] == "OFFICIAL_MEDIA_LIFECYCLE" + + +def test_visibility_promotion_confirm_422() -> None: + cur = MagicMock() + cur.fetchall.return_value = [_row(1, vis="club", copyright_notice="halten")] + with pytest.raises(HTTPException) as ei: + apply_official_exercise_media_rules( + cur, + 1, + "official", + promote_attached_media=False, + default_official_media_copyright=None, + ) + assert ei.value.status_code == 422 + assert ei.value.detail["code"] == "OFFICIAL_MEDIA_CONFIRM_REQUIRED" + assert ei.value.detail["assets_need_visibility_promotion"] + + +def test_copyright_required_422() -> None: + cur = MagicMock() + cur.fetchall.return_value = [_row(1, vis="official", copyright_notice="")] + with pytest.raises(HTTPException) as ei: + apply_official_exercise_media_rules( + cur, + 1, + "official", + promote_attached_media=True, + default_official_media_copyright=None, + ) + assert ei.value.status_code == 422 + assert ei.value.detail["code"] == "OFFICIAL_MEDIA_CONFIRM_REQUIRED" + assert ei.value.detail["assets_missing_copyright"] + + +def test_promote_and_fill_copyright_updates() -> None: + cur = MagicMock() + cur.fetchall.return_value = [_row(1, vis="private", copyright_notice=" ")] + apply_official_exercise_media_rules( + cur, + 42, + "official", + promote_attached_media=True, + default_official_media_copyright="© Test Holding", + ) + assert cur.execute.call_count == 3 + sql1 = cur.execute.call_args_list[1][0][0] + sql2 = cur.execute.call_args_list[2][0][0] + assert "visibility = 'official'" in sql1 + assert "copyright_notice" in sql2 + diff --git a/backend/version.py b/backend/version.py index b48919c..e120465 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.46" +APP_VERSION = "0.8.47" BUILD_DATE = "2026-05-07" DB_SCHEMA_VERSION = "20260507045" @@ -17,7 +17,7 @@ MODULE_VERSIONS = { "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.15.1", # Upload 409: strukturiertes Logging + "exercises": "2.16.0", # §4.2 official: angehängte media_assets + Copyright (PUT + bulk-metadata) "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.47", + "date": "2026-05-07", + "changes": [ + "Übung „offiziell“ (§4.2): angehängte Datei-Assets müssen aktiv sein; Sichtbarkeit/Copyright per Bestätigung anheben; PUT /api/exercises/{id} + PATCH bulk-metadata: Felder promote_attached_media_for_official, default_official_media_copyright; Frontend Bestätigungsdialog", + ], + }, { "version": "0.8.46", "date": "2026-05-07", diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index b6c1d0e..b9b7e38 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -632,7 +632,54 @@ function ExerciseFormPage() { setSaving(true) try { if (isEdit) { - await api.updateExercise(exerciseId, payload) + const saveOnce = (extras = {}) => + api.updateExercise(exerciseId, buildExerciseApiPayload(payloadBase, extras)) + try { + await saveOnce() + } catch (firstErr) { + if ( + firstErr.status === 422 && + firstErr.code === 'OFFICIAL_MEDIA_LIFECYCLE' && + firstErr.payload?.media_assets + ) { + alert( + 'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). ' + + 'Bitte Medium wiederherstellen oder aus der Übung entfernen.', + ) + throw firstErr + } + if (firstErr.status === 422 && firstErr.code === 'OFFICIAL_MEDIA_CONFIRM_REQUIRED') { + const promo = (firstErr.payload.assets_need_visibility_promotion || []).length + const miss = (firstErr.payload.assets_missing_copyright || []).length + let msg = + 'Die Übung ist oder wird offiziell. ' + if (promo > 0) { + msg += `${promo} verknüpfte Datei(en) werden dabei plattformweit („offiziell“) freigegeben. ` + } + if (miss > 0) { + msg += `${miss} Datei(en) haben noch keinen ausreichenden Copyright-Vermerk (mind. 3 Zeichen). ` + } + msg += 'Fortfahren?' + if (!window.confirm(msg)) throw firstErr + let defaultCopyright = '' + if (miss > 0) { + defaultCopyright = window.prompt( + 'Copyright-Vermerk für betroffene Dateien ohne Eintrag (mind. 3 Zeichen):', + '© ', + ) + if (!defaultCopyright || String(defaultCopyright).trim().length < 3) { + alert('Mindestens 3 Zeichen für den Copyright-Vermerk.') + throw firstErr + } + } + await saveOnce({ + promote_attached_media_for_official: true, + ...(miss > 0 ? { default_official_media_copyright: String(defaultCopyright).trim() } : {}), + }) + } else { + throw firstErr + } + } const ex = await api.getExercise(exerciseId) setMediaList(ex.media || []) setVariants((ex.variants || []).map(apiVariantToRow)) diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 15bf102..c31c308 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -406,7 +406,7 @@ export async function listExercises(filters = {}) { } /** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC + training_types) */ -export function buildExerciseApiPayload(formData) { +export function buildExerciseApiPayload(formData, extras = {}) { const num = (v) => (v === '' || v == null ? null : Number(v)) const goalHtml = formData.goal || '' @@ -458,6 +458,7 @@ export function buildExerciseApiPayload(formData) { visibility: formData.visibility || 'private', status: formData.status || 'draft', club_id: formData.club_id ?? null, + ...extras, } } @@ -557,10 +558,45 @@ export async function createExercise(data) { } export async function updateExercise(id, data) { - return request(`/api/exercises/${id}`, { + const token = localStorage.getItem('authToken') + const headers = mergeActiveClubHeader({ 'Content-Type': 'application/json' }) + if (token) headers['X-Auth-Token'] = token + const url = `${API_URL}/api/exercises/${id}` + const response = await fetch(url, { method: 'PUT', - body: JSON.stringify(data) + headers, + body: JSON.stringify(data), }) + if (!response.ok) { + const text = await response.text() + let parsed = null + try { + parsed = JSON.parse(text) + } catch { + parsed = null + } + const d = parsed?.detail + if ( + response.status === 422 && + d && + typeof d === 'object' && + !Array.isArray(d) && + typeof d.code === 'string' + ) { + const e = new Error(typeof d.message === 'string' ? d.message : 'Validierung fehlgeschlagen') + e.status = 422 + e.code = d.code + e.payload = d + throw e + } + if (parsed?.detail != null) { + const msg = typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail) + throw new Error(msg) + } + const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180) + throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`) + } + return response.json() } /** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */