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
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:
parent
88fb60e244
commit
95f5b0b2d7
|
|
@ -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 | 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 | §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`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 | |
|
| 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 | `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 | `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 |
|
| 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_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 |
|
| training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id |
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,9 @@ class ExerciseUpdate(BaseModel):
|
||||||
visibility: Optional[str] = None
|
visibility: Optional[str] = None
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
club_id: Optional[int] = 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")
|
@model_validator(mode="after")
|
||||||
def normalize_goal_execution(self):
|
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)
|
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)
|
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)
|
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")
|
@model_validator(mode="after")
|
||||||
def at_least_one_patch_field(self):
|
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])
|
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]:
|
def _abs_media_path(file_path_db: str, media_root: Path) -> Optional[Path]:
|
||||||
if not file_path_db or file_path_db.startswith("http"):
|
if not file_path_db or file_path_db.startswith("http"):
|
||||||
return None
|
return None
|
||||||
|
|
@ -1007,6 +1153,33 @@ def bulk_patch_exercises_metadata(
|
||||||
failed.append({"id": ex_id, "detail": _fail_msg(he)})
|
failed.append({"id": ex_id, "detail": _fail_msg(he)})
|
||||||
continue
|
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] = []
|
sets: List[str] = []
|
||||||
vals: List[Any] = []
|
vals: List[Any] = []
|
||||||
if patch_visibility:
|
if patch_visibility:
|
||||||
|
|
@ -1561,6 +1734,9 @@ def update_exercise(
|
||||||
ex_cid = int(ex_cid)
|
ex_cid = int(ex_cid)
|
||||||
|
|
||||||
data = body.dict(exclude_unset=True)
|
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
|
next_vis = ex_vis
|
||||||
if "visibility" in data and data["visibility"] is not None:
|
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
|
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 = []
|
fields = []
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
|
|
@ -1613,9 +1797,9 @@ def update_exercise(
|
||||||
params.append(exercise_id)
|
params.append(exercise_id)
|
||||||
query = f"UPDATE exercises SET {', '.join(fields)} WHERE id = %s"
|
query = f"UPDATE exercises SET {', '.join(fields)} WHERE id = %s"
|
||||||
cur.execute(query, params)
|
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)
|
exercise = enrich_exercise_detail(exercise_id, cur)
|
||||||
|
|
||||||
|
|
|
||||||
107
backend/tests/test_official_exercise_media_rules.py
Normal file
107
backend/tests/test_official_exercise_media_rules.py
Normal 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
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.46"
|
APP_VERSION = "0.8.47"
|
||||||
BUILD_DATE = "2026-05-07"
|
BUILD_DATE = "2026-05-07"
|
||||||
DB_SCHEMA_VERSION = "20260507045"
|
DB_SCHEMA_VERSION = "20260507045"
|
||||||
|
|
||||||
|
|
@ -17,7 +17,7 @@ MODULE_VERSIONS = {
|
||||||
"groups": "0.1.0",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.0",
|
"skills": "0.1.0",
|
||||||
"methods": "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_units": "0.2.0",
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
||||||
|
|
@ -29,6 +29,13 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.46",
|
||||||
"date": "2026-05-07",
|
"date": "2026-05-07",
|
||||||
|
|
|
||||||
|
|
@ -632,7 +632,54 @@ function ExerciseFormPage() {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
if (isEdit) {
|
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)
|
const ex = await api.getExercise(exerciseId)
|
||||||
setMediaList(ex.media || [])
|
setMediaList(ex.media || [])
|
||||||
setVariants((ex.variants || []).map(apiVariantToRow))
|
setVariants((ex.variants || []).map(apiVariantToRow))
|
||||||
|
|
|
||||||
|
|
@ -406,7 +406,7 @@ export async function listExercises(filters = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC + training_types) */
|
/** 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 num = (v) => (v === '' || v == null ? null : Number(v))
|
||||||
|
|
||||||
const goalHtml = formData.goal || ''
|
const goalHtml = formData.goal || ''
|
||||||
|
|
@ -458,6 +458,7 @@ export function buildExerciseApiPayload(formData) {
|
||||||
visibility: formData.visibility || 'private',
|
visibility: formData.visibility || 'private',
|
||||||
status: formData.status || 'draft',
|
status: formData.status || 'draft',
|
||||||
club_id: formData.club_id ?? null,
|
club_id: formData.club_id ?? null,
|
||||||
|
...extras,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -557,10 +558,45 @@ export async function createExercise(data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateExercise(id, 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',
|
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`). */
|
/** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user