feat: implement official exercise media management and copyright validation
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 22s

- Enhanced exercise update functionality to support the promotion of attached media assets to 'official' status, requiring active visibility and copyright validation.
- Updated backend API to handle new fields for promoting media and setting default copyright notices during exercise updates.
- Improved frontend error handling to prompt users for confirmation when promoting media assets, including checks for copyright compliance.
- Incremented version to 0.8.47, reflecting the latest changes in media management and governance.
This commit is contained in:
Lars 2026-05-07 13:34:22 +02:00
parent 88fb60e244
commit 95f5b0b2d7
7 changed files with 391 additions and 10 deletions

View File

@ -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`. |
---

View File

@ -12,7 +12,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| 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 |

View File

@ -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)

View File

@ -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

View File

@ -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",

View File

@ -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))

View File

@ -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`). */