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 | §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 | |
|
||||
| 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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
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
|
||||
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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`). */
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user