feat(exercises): introduce combination exercises and enhance exercise management
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 1m1s
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 1m1s
- Updated app version to 0.8.99, reflecting the addition of combination exercises. - Implemented new data structures and validation for combination slots and archetypes in the backend. - Enhanced frontend components to support selection and display of combination exercises, including new UI elements for managing slots and archetypes. - Updated API payload handling to accommodate new exercise types and their associated data. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f4f5642c21
commit
8a9f9f960f
33
backend/migrations/056_combination_exercises.sql
Normal file
33
backend/migrations/056_combination_exercises.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
-- Migration 056: Kombinationsübungen (Phase 2 MVP) — Slots + Pool-Kandidaten
|
||||||
|
-- Fachgrundlage: functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md §6
|
||||||
|
|
||||||
|
ALTER TABLE exercises
|
||||||
|
ADD COLUMN IF NOT EXISTS exercise_kind VARCHAR(20) NOT NULL DEFAULT 'simple'
|
||||||
|
CHECK (exercise_kind IN ('simple', 'combination')),
|
||||||
|
ADD COLUMN IF NOT EXISTS method_archetype VARCHAR(80),
|
||||||
|
ADD COLUMN IF NOT EXISTS method_profile JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exercises_exercise_kind ON exercises(exercise_kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exercises_method_archetype ON exercises(method_archetype)
|
||||||
|
WHERE method_archetype IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS combination_exercise_slots (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||||
|
slot_index INT NOT NULL,
|
||||||
|
title VARCHAR(200),
|
||||||
|
UNIQUE (exercise_id, slot_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_combination_exercise_slots_exercise ON combination_exercise_slots(exercise_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS combination_slot_candidates (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
slot_id INT NOT NULL REFERENCES combination_exercise_slots(id) ON DELETE CASCADE,
|
||||||
|
candidate_exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE (slot_id, candidate_exercise_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_combination_slot_candidates_slot ON combination_slot_candidates(slot_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_combination_slot_candidates_exercise ON combination_slot_candidates(candidate_exercise_id);
|
||||||
|
|
@ -10,12 +10,13 @@ import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query, Request, UploadFile, File, Form
|
from fastapi import APIRouter, HTTPException, Depends, Query, Request, UploadFile, File, Form
|
||||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
from psycopg2.extras import Json
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from club_tenancy import (
|
from club_tenancy import (
|
||||||
|
|
@ -198,6 +199,26 @@ def _upload_limit_bytes(tenant: TenantContext) -> int:
|
||||||
# Pydantic Models
|
# Pydantic Models
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
# Archetyp-IDs (Maschinenlesbare Ablaufmuster) — konsistent zur technischen Entwurfsspezifikation
|
||||||
|
COMBINATION_ARCHETYPE_IDS = frozenset(
|
||||||
|
{
|
||||||
|
"circuit_rotate_time",
|
||||||
|
"circuit_all_parallel",
|
||||||
|
"sequence_linear",
|
||||||
|
"station_parcour",
|
||||||
|
"pair_superset",
|
||||||
|
"time_domain_interval",
|
||||||
|
"free_method_block",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CombinationSlotIn(BaseModel):
|
||||||
|
slot_index: int = Field(ge=0, le=99)
|
||||||
|
title: Optional[str] = Field(None, max_length=200)
|
||||||
|
candidate_exercise_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ExerciseCreate(BaseModel):
|
class ExerciseCreate(BaseModel):
|
||||||
# Basis-Felder (goal/execution: DB-Constraint mind. eines; Wiki oft nur eines)
|
# Basis-Felder (goal/execution: DB-Constraint mind. eines; Wiki oft nur eines)
|
||||||
title: str = Field(..., min_length=3, max_length=300)
|
title: str = Field(..., min_length=3, max_length=300)
|
||||||
|
|
@ -231,6 +252,12 @@ class ExerciseCreate(BaseModel):
|
||||||
status: str = "draft"
|
status: str = "draft"
|
||||||
club_id: Optional[int] = None
|
club_id: Optional[int] = None
|
||||||
|
|
||||||
|
# Kombinationsübung (Phase 2)
|
||||||
|
exercise_kind: Literal["simple", "combination"] = "simple"
|
||||||
|
method_archetype: Optional[str] = Field(None, max_length=80)
|
||||||
|
method_profile: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
combination_slots: list[CombinationSlotIn] = Field(default_factory=list)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def normalize_goal_execution(self):
|
def normalize_goal_execution(self):
|
||||||
g = (self.goal or "").strip() or None
|
g = (self.goal or "").strip() or None
|
||||||
|
|
@ -270,6 +297,11 @@ class ExerciseUpdate(BaseModel):
|
||||||
# Vereins-Übung: fehlende Copyrights an Datei-Assets nach Prompt-Text setzen (PUT-Retry)
|
# 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)
|
default_club_media_copyright: Optional[str] = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
exercise_kind: Optional[Literal["simple", "combination"]] = None
|
||||||
|
method_archetype: Optional[str] = Field(None, max_length=80)
|
||||||
|
method_profile: Optional[Dict[str, Any]] = None
|
||||||
|
combination_slots: Optional[list[CombinationSlotIn]] = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def normalize_goal_execution(self):
|
def normalize_goal_execution(self):
|
||||||
if self.goal is not None:
|
if self.goal is not None:
|
||||||
|
|
@ -811,6 +843,137 @@ def _resolve_local_media_file(
|
||||||
return path_under_media_root(media_root, asset_storage_key)
|
return path_under_media_root(media_root, asset_storage_key)
|
||||||
return _abs_media_path(file_path_db or "", media_root) if file_path_db else None
|
return _abs_media_path(file_path_db or "", media_root) if file_path_db else None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_method_profile_store(raw: Any) -> Dict[str, Any]:
|
||||||
|
if raw is None:
|
||||||
|
return {}
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
return raw
|
||||||
|
raise HTTPException(status_code=400, detail="method_profile muss ein JSON-Objekt sein")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_archetype_for_kind(kind: str, archetype: Optional[str]) -> None:
|
||||||
|
if kind != "combination":
|
||||||
|
return
|
||||||
|
if archetype is None or not str(archetype).strip():
|
||||||
|
return
|
||||||
|
a = str(archetype).strip()
|
||||||
|
if a not in COMBINATION_ARCHETYPE_IDS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
"Unbekannter method_archetype. Erlaubt: "
|
||||||
|
+ ", ".join(sorted(COMBINATION_ARCHETYPE_IDS))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_candidate_exercises_for_combination(cur, tenant: TenantContext, ids: List[int]) -> None:
|
||||||
|
if not ids:
|
||||||
|
return
|
||||||
|
seen: set[int] = set()
|
||||||
|
for cid_raw in ids:
|
||||||
|
cid = int(cid_raw)
|
||||||
|
if cid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(cid)
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT id, exercise_kind, visibility, club_id, created_by
|
||||||
|
FROM exercises WHERE id = %s""",
|
||||||
|
(cid,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Slot-Verweis: Übung #{cid} nicht gefunden")
|
||||||
|
rd = r2d(row)
|
||||||
|
k = str(rd.get("exercise_kind") or "simple").strip().lower()
|
||||||
|
if k != "simple":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Slot-Verweis: Übung #{cid} ist eine Kombinationsübung — nur Einzelübungen erlaubt",
|
||||||
|
)
|
||||||
|
if not library_content_visible_to_profile(
|
||||||
|
cur,
|
||||||
|
tenant.profile_id,
|
||||||
|
rd.get("visibility"),
|
||||||
|
rd.get("club_id"),
|
||||||
|
rd.get("created_by"),
|
||||||
|
tenant.global_role,
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Slot-Verweis: keine Leserechte für Übung #{cid}")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_and_normalize_combination_slots_payload(
|
||||||
|
cur,
|
||||||
|
tenant: TenantContext,
|
||||||
|
slots: Optional[List[CombinationSlotIn]],
|
||||||
|
) -> List[Tuple[int, Optional[str], List[int]]]:
|
||||||
|
if slots is None:
|
||||||
|
return []
|
||||||
|
normalized: Dict[int, Tuple[Optional[str], List[int]]] = {}
|
||||||
|
for s in sorted(slots, key=lambda x: x.slot_index):
|
||||||
|
cid_list_raw = list(s.candidate_exercise_ids or [])
|
||||||
|
cid_list: List[int] = []
|
||||||
|
for x in cid_list_raw:
|
||||||
|
cid_list.append(int(x))
|
||||||
|
title = ((s.title or "").strip()) or None
|
||||||
|
normalized[int(s.slot_index)] = (title, cid_list)
|
||||||
|
out: List[Tuple[int, Optional[str], List[int]]] = []
|
||||||
|
for idx in sorted(normalized.keys()):
|
||||||
|
title, cands = normalized[idx]
|
||||||
|
if not cands:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Station (Index {idx}): mindestens eine Einzelübung (Pool) ist erforderlich",
|
||||||
|
)
|
||||||
|
out.append((idx, title, cands))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def replace_combination_slots(
|
||||||
|
cur,
|
||||||
|
tenant: TenantContext,
|
||||||
|
exercise_id: int,
|
||||||
|
slots_norm: List[Tuple[int, Optional[str], List[int]]],
|
||||||
|
) -> None:
|
||||||
|
flat_ids = [cid for _, __, xs in slots_norm for cid in xs]
|
||||||
|
_assert_candidate_exercises_for_combination(cur, tenant, flat_ids)
|
||||||
|
cur.execute("DELETE FROM combination_exercise_slots WHERE exercise_id = %s", (exercise_id,))
|
||||||
|
for slot_index, title, cand_ids in slots_norm:
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO combination_exercise_slots (exercise_id, slot_index, title)
|
||||||
|
VALUES (%s, %s, %s) RETURNING id""",
|
||||||
|
(exercise_id, slot_index, title),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
sid = row["id"] if isinstance(row, dict) else row[0]
|
||||||
|
for so, cid in enumerate(cand_ids):
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO combination_slot_candidates (slot_id, candidate_exercise_id, sort_order)
|
||||||
|
VALUES (%s, %s, %s)""",
|
||||||
|
(sid, int(cid), int(so)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def wipe_combination_structure(cur, exercise_id: int) -> None:
|
||||||
|
cur.execute("DELETE FROM combination_exercise_slots WHERE exercise_id = %s", (exercise_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def assert_exercise_not_combination(cur, exercise_id: int) -> None:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COALESCE(exercise_kind, 'simple') AS exercise_kind FROM exercises WHERE id = %s",
|
||||||
|
(exercise_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
||||||
|
if str(r2d(row).get("exercise_kind") or "simple").strip().lower() == "combination":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Kombinationsübungen unterstützen keine Varianten.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def enrich_exercise_detail(exercise_id: int, cur) -> dict:
|
def enrich_exercise_detail(exercise_id: int, cur) -> dict:
|
||||||
"""
|
"""
|
||||||
Lädt alle M:N Relations für eine Übung und gibt ein vollständiges
|
Lädt alle M:N Relations für eine Übung und gibt ein vollständiges
|
||||||
|
|
@ -933,6 +1096,48 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
|
||||||
)
|
)
|
||||||
exercise["media"] = [r2d(r) for r in cur.fetchall()]
|
exercise["media"] = [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
mp_raw = exercise.get("method_profile")
|
||||||
|
exercise["method_profile"] = mp_raw if isinstance(mp_raw, dict) else {}
|
||||||
|
exercise["exercise_kind"] = str(exercise.get("exercise_kind") or "simple").strip().lower()
|
||||||
|
|
||||||
|
exercise["combination_slots"] = []
|
||||||
|
if exercise["exercise_kind"] == "combination":
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT id, slot_index, title FROM combination_exercise_slots
|
||||||
|
WHERE exercise_id = %s ORDER BY slot_index ASC, id ASC""",
|
||||||
|
(exercise_id,),
|
||||||
|
)
|
||||||
|
slot_rows = [r2d(r) for r in cur.fetchall()]
|
||||||
|
slots_out: List[dict] = []
|
||||||
|
for sr in slot_rows:
|
||||||
|
slot_pk = sr["id"]
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT candidate_exercise_id FROM combination_slot_candidates
|
||||||
|
WHERE slot_id = %s ORDER BY sort_order ASC, id ASC""",
|
||||||
|
(slot_pk,),
|
||||||
|
)
|
||||||
|
crows = cur.fetchall()
|
||||||
|
cids = [int(r2d(c)["candidate_exercise_id"]) for c in crows]
|
||||||
|
cand_meta: Dict[int, Optional[str]] = {}
|
||||||
|
if cids:
|
||||||
|
ph = ",".join(["%s"] * len(cids))
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT id, title FROM exercises WHERE id IN ({ph})",
|
||||||
|
tuple(cids),
|
||||||
|
)
|
||||||
|
cand_meta = {int(r2d(x)["id"]): r2d(x).get("title") for x in cur.fetchall()}
|
||||||
|
slots_out.append(
|
||||||
|
{
|
||||||
|
"slot_index": sr["slot_index"],
|
||||||
|
"title": sr.get("title"),
|
||||||
|
"candidate_exercise_ids": cids,
|
||||||
|
"candidates": [
|
||||||
|
{"exercise_id": cid, "title": cand_meta.get(cid)} for cid in cids
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
exercise["combination_slots"] = slots_out
|
||||||
|
|
||||||
return exercise
|
return exercise
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1528,6 +1733,10 @@ def list_exercises(
|
||||||
default=False,
|
default=False,
|
||||||
description="Nur Übungen, die vom aktuellen Profil angelegt wurden (created_by = Profil)",
|
description="Nur Übungen, die vom aktuellen Profil angelegt wurden (created_by = Profil)",
|
||||||
),
|
),
|
||||||
|
exercise_kind_any: list[str] = Query(
|
||||||
|
default=[],
|
||||||
|
description="ODER: mind. einer dieser Übungsarten — simple oder combination",
|
||||||
|
),
|
||||||
tenant: TenantContext = Depends(get_tenant_context),
|
tenant: TenantContext = Depends(get_tenant_context),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
@ -1558,6 +1767,21 @@ def list_exercises(
|
||||||
where.append("e.created_by = %s")
|
where.append("e.created_by = %s")
|
||||||
params.append(profile_id)
|
params.append(profile_id)
|
||||||
|
|
||||||
|
ek_filtered: List[str] = []
|
||||||
|
if exercise_kind_any:
|
||||||
|
for raw in exercise_kind_any:
|
||||||
|
s = str(raw or "").strip().lower()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
if s not in ("simple", "combination"):
|
||||||
|
raise HTTPException(status_code=400, detail="exercise_kind_any: nur simple oder combination")
|
||||||
|
if s not in ek_filtered:
|
||||||
|
ek_filtered.append(s)
|
||||||
|
if ek_filtered:
|
||||||
|
ph = ",".join(["%s"] * len(ek_filtered))
|
||||||
|
where.append(f"(LOWER(TRIM(COALESCE(e.exercise_kind::text,''))) IN ({ph}))")
|
||||||
|
params.extend(ek_filtered)
|
||||||
|
|
||||||
vis_list = _merge_str_any(visibility_any, visibility)
|
vis_list = _merge_str_any(visibility_any, visibility)
|
||||||
if vis_list:
|
if vis_list:
|
||||||
ph = ",".join(["%s"] * len(vis_list))
|
ph = ",".join(["%s"] * len(vis_list))
|
||||||
|
|
@ -1776,6 +2000,7 @@ def list_exercises(
|
||||||
# Query (primary_focus_name für Listen-Ansicht gemäß Spec-Beispiel „focus_area“-Label)
|
# Query (primary_focus_name für Listen-Ansicht gemäß Spec-Beispiel „focus_area“-Label)
|
||||||
query = f"""
|
query = f"""
|
||||||
SELECT e.id, e.title, e.summary, e.visibility, e.status,
|
SELECT e.id, e.title, e.summary, e.visibility, e.status,
|
||||||
|
e.exercise_kind, e.method_archetype,
|
||||||
e.created_by, p.name as creator_name,
|
e.created_by, p.name as creator_name,
|
||||||
e.club_id, c.name as club_name,
|
e.club_id, c.name as club_name,
|
||||||
e.created_at, e.updated_at,
|
e.created_at, e.updated_at,
|
||||||
|
|
@ -1829,6 +2054,7 @@ def list_exercises(
|
||||||
out = []
|
out = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
d = r2d(r)
|
d = r2d(r)
|
||||||
|
d["exercise_kind"] = str(d.get("exercise_kind") or "simple").strip().lower()
|
||||||
pfn = d.get("primary_focus_name")
|
pfn = d.get("primary_focus_name")
|
||||||
d["focus_area"] = pfn
|
d["focus_area"] = pfn
|
||||||
d["focus_area_names"] = _coerce_json_str_list(d.get("focus_area_names"))
|
d["focus_area_names"] = _coerce_json_str_list(d.get("focus_area_names"))
|
||||||
|
|
@ -1913,16 +2139,39 @@ def create_exercise(
|
||||||
cur, profile_id, tenant.global_role, body.visibility, club_id
|
cur, profile_id, tenant.global_role, body.visibility, club_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
kind_clean = str(body.exercise_kind or "simple").strip().lower()
|
||||||
|
if kind_clean not in ("simple", "combination"):
|
||||||
|
raise HTTPException(status_code=400, detail="exercise_kind: simple oder combination")
|
||||||
|
|
||||||
|
prof_dict = _normalize_method_profile_store(body.method_profile)
|
||||||
|
arch_raw = body.method_archetype
|
||||||
|
arch_val = (arch_raw.strip() if isinstance(arch_raw, str) and arch_raw.strip() else None)
|
||||||
|
_validate_archetype_for_kind(kind_clean, arch_val)
|
||||||
|
|
||||||
|
slots_norm: List[Tuple[int, Optional[str], List[int]]] = []
|
||||||
|
if kind_clean == "combination":
|
||||||
|
slots_norm = _validate_and_normalize_combination_slots_payload(
|
||||||
|
cur, tenant, body.combination_slots or []
|
||||||
|
)
|
||||||
|
if not slots_norm:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Kombinationsübung: mindestens eine Station mit Übungen nötig",
|
||||||
|
)
|
||||||
|
|
||||||
|
mp_json = Json(prof_dict if kind_clean == "combination" else {})
|
||||||
|
arch_db = arch_val if kind_clean == "combination" else None
|
||||||
|
|
||||||
# Equipment als JSONB
|
# Equipment als JSONB
|
||||||
equipment_json = json.dumps(body.equipment) if body.equipment else None
|
equipment_json = json.dumps(body.equipment) if body.equipment else None
|
||||||
|
|
||||||
# INSERT
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""INSERT INTO exercises
|
"""INSERT INTO exercises
|
||||||
(title, summary, goal, execution, preparation, trainer_notes,
|
(title, summary, goal, execution, preparation, trainer_notes,
|
||||||
duration_min, duration_max, group_size_min, group_size_max,
|
duration_min, duration_max, group_size_min, group_size_max,
|
||||||
equipment, visibility, status, created_by, club_id)
|
equipment, visibility, status, created_by, club_id,
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
exercise_kind, method_archetype, method_profile)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
RETURNING id""",
|
RETURNING id""",
|
||||||
(
|
(
|
||||||
body.title, body.summary, body.goal, body.execution,
|
body.title, body.summary, body.goal, body.execution,
|
||||||
|
|
@ -1931,11 +2180,15 @@ def create_exercise(
|
||||||
body.group_size_min, body.group_size_max,
|
body.group_size_min, body.group_size_max,
|
||||||
equipment_json,
|
equipment_json,
|
||||||
body.visibility, body.status, profile_id, club_id,
|
body.visibility, body.status, profile_id, club_id,
|
||||||
)
|
kind_clean, arch_db, mp_json,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
exercise_id = row['id'] if isinstance(row, dict) else row[0]
|
exercise_id = row['id'] if isinstance(row, dict) else row[0]
|
||||||
|
|
||||||
|
if kind_clean == "combination":
|
||||||
|
replace_combination_slots(cur, tenant, exercise_id, slots_norm)
|
||||||
|
|
||||||
data = body.dict()
|
data = body.dict()
|
||||||
assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False)
|
assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False)
|
||||||
if (body.visibility or "").strip().lower() == "club":
|
if (body.visibility or "").strip().lower() == "club":
|
||||||
|
|
@ -1963,7 +2216,7 @@ def update_exercise(
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""SELECT created_by, visibility, club_id,
|
f"""SELECT created_by, visibility, club_id, exercise_kind, method_archetype, method_profile,
|
||||||
{", ".join(sorted(RICH_HTML_EXERCISE_FIELDS))}
|
{", ".join(sorted(RICH_HTML_EXERCISE_FIELDS))}
|
||||||
FROM exercises WHERE id = %s""",
|
FROM exercises WHERE id = %s""",
|
||||||
(exercise_id,),
|
(exercise_id,),
|
||||||
|
|
@ -1993,6 +2246,84 @@ def update_exercise(
|
||||||
default_official_copy = data.pop("default_official_media_copyright", None)
|
default_official_copy = data.pop("default_official_media_copyright", None)
|
||||||
default_club_copy = data.pop("default_club_media_copyright", None)
|
default_club_copy = data.pop("default_club_media_copyright", None)
|
||||||
|
|
||||||
|
combo_slots_provided = "combination_slots" in data
|
||||||
|
combo_slots_payload = data.pop("combination_slots", None)
|
||||||
|
|
||||||
|
ec_kind_was = str(rd_full.get("exercise_kind") or "simple").strip().lower()
|
||||||
|
ek_provided = "exercise_kind" in data
|
||||||
|
next_kind = ec_kind_was
|
||||||
|
if ek_provided:
|
||||||
|
next_kind = str(data.pop("exercise_kind") or "simple").strip().lower()
|
||||||
|
|
||||||
|
arche_provided = "method_archetype" in data
|
||||||
|
meth_prof_provided = "method_profile" in data
|
||||||
|
|
||||||
|
next_ma_db = rd_full.get("method_archetype")
|
||||||
|
if isinstance(next_ma_db, str):
|
||||||
|
next_ma_db = next_ma_db.strip() or None
|
||||||
|
else:
|
||||||
|
next_ma_db = None
|
||||||
|
|
||||||
|
mp_row = rd_full.get("method_profile")
|
||||||
|
next_mp_db = mp_row if isinstance(mp_row, dict) else {}
|
||||||
|
|
||||||
|
if ek_provided and next_kind not in ("simple", "combination"):
|
||||||
|
raise HTTPException(status_code=400, detail="exercise_kind: simple oder combination")
|
||||||
|
|
||||||
|
if arche_provided:
|
||||||
|
va = data.pop("method_archetype")
|
||||||
|
if va is None or (isinstance(va, str) and not va.strip()):
|
||||||
|
next_ma_db = None
|
||||||
|
elif isinstance(va, str):
|
||||||
|
next_ma_db = va.strip() or None
|
||||||
|
else:
|
||||||
|
next_ma_db = None
|
||||||
|
|
||||||
|
if meth_prof_provided:
|
||||||
|
next_mp_db = _normalize_method_profile_store(data.pop("method_profile"))
|
||||||
|
|
||||||
|
if next_kind == "simple":
|
||||||
|
next_ma_db = None
|
||||||
|
next_mp_db = {}
|
||||||
|
|
||||||
|
_validate_archetype_for_kind(next_kind, next_ma_db)
|
||||||
|
|
||||||
|
if ec_kind_was == "simple" and next_kind == "combination":
|
||||||
|
if not combo_slots_provided:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail='Umschalten auf Kombinationsübung: Feld "combination_slots" ist erforderlich',
|
||||||
|
)
|
||||||
|
|
||||||
|
combo_slots_normalized: Optional[List[Tuple[int, Optional[str], List[int]]]] = None
|
||||||
|
if combo_slots_provided:
|
||||||
|
if next_kind != "combination":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="combination_slots nur bei exercise_kind=combination erlaubt",
|
||||||
|
)
|
||||||
|
slots_in_raw = combo_slots_payload if combo_slots_payload is not None else []
|
||||||
|
slots_in: List[CombinationSlotIn] = []
|
||||||
|
for s in slots_in_raw:
|
||||||
|
if isinstance(s, CombinationSlotIn):
|
||||||
|
slots_in.append(s)
|
||||||
|
elif isinstance(s, dict):
|
||||||
|
slots_in.append(CombinationSlotIn(**s))
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültige combination_slots Payload-Struktur")
|
||||||
|
combo_slots_normalized = _validate_and_normalize_combination_slots_payload(
|
||||||
|
cur, tenant, slots_in
|
||||||
|
)
|
||||||
|
if not combo_slots_normalized:
|
||||||
|
raise HTTPException(status_code=400, detail="Kombinationsübung: mindestens eine Station mit Übungen")
|
||||||
|
|
||||||
|
update_combo_cols = (
|
||||||
|
ek_provided
|
||||||
|
or arche_provided
|
||||||
|
or meth_prof_provided
|
||||||
|
or (next_kind == "simple" and ec_kind_was != "simple")
|
||||||
|
)
|
||||||
|
|
||||||
merged_rich = {fld: rich_row.get(fld) for fld in RICH_HTML_EXERCISE_FIELDS}
|
merged_rich = {fld: rich_row.get(fld) for fld in RICH_HTML_EXERCISE_FIELDS}
|
||||||
for fld in RICH_HTML_EXERCISE_FIELDS:
|
for fld in RICH_HTML_EXERCISE_FIELDS:
|
||||||
if fld not in data:
|
if fld not in data:
|
||||||
|
|
@ -2064,11 +2395,27 @@ def update_exercise(
|
||||||
fields.append("equipment = %s")
|
fields.append("equipment = %s")
|
||||||
params.append(json.dumps(data["equipment"]) if data["equipment"] else None)
|
params.append(json.dumps(data["equipment"]) if data["equipment"] else None)
|
||||||
|
|
||||||
|
if update_combo_cols:
|
||||||
|
if ek_provided:
|
||||||
|
fields.append("exercise_kind = %s")
|
||||||
|
params.append(next_kind)
|
||||||
|
fields.append("method_archetype = %s")
|
||||||
|
params.append(next_ma_db)
|
||||||
|
fields.append("method_profile = %s")
|
||||||
|
params.append(Json(next_mp_db))
|
||||||
|
|
||||||
if fields:
|
if fields:
|
||||||
fields.append("updated_at = NOW()")
|
fields.append("updated_at = NOW()")
|
||||||
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)
|
||||||
|
elif combo_slots_normalized is not None:
|
||||||
|
cur.execute("UPDATE exercises SET updated_at = NOW() WHERE id = %s", (exercise_id,))
|
||||||
|
|
||||||
|
if combo_slots_normalized is not None:
|
||||||
|
replace_combination_slots(cur, tenant, exercise_id, combo_slots_normalized)
|
||||||
|
elif ec_kind_was == "combination" and next_kind == "simple":
|
||||||
|
wipe_combination_structure(cur, exercise_id)
|
||||||
|
|
||||||
assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False)
|
assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False)
|
||||||
try:
|
try:
|
||||||
|
|
@ -2144,6 +2491,7 @@ def reorder_exercise_variants(
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
_assert_can_edit_exercise(cur, exercise_id, tenant)
|
_assert_can_edit_exercise(cur, exercise_id, tenant)
|
||||||
|
assert_exercise_not_combination(cur, exercise_id)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT id FROM exercise_variants WHERE exercise_id = %s",
|
"SELECT id FROM exercise_variants WHERE exercise_id = %s",
|
||||||
|
|
@ -2178,6 +2526,7 @@ def create_exercise_variant(
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
_assert_can_edit_exercise(cur, exercise_id, tenant)
|
_assert_can_edit_exercise(cur, exercise_id, tenant)
|
||||||
|
assert_exercise_not_combination(cur, exercise_id)
|
||||||
_validate_variant_prerequisite(cur, exercise_id, body.prerequisite_variant_id)
|
_validate_variant_prerequisite(cur, exercise_id, body.prerequisite_variant_id)
|
||||||
|
|
||||||
eq_json = _variant_equipment_json(body.equipment_changes)
|
eq_json = _variant_equipment_json(body.equipment_changes)
|
||||||
|
|
@ -2242,6 +2591,7 @@ def update_exercise_variant(
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
_assert_can_edit_exercise(cur, exercise_id, tenant)
|
_assert_can_edit_exercise(cur, exercise_id, tenant)
|
||||||
|
assert_exercise_not_combination(cur, exercise_id)
|
||||||
old = _fetch_variant_row(cur, exercise_id, variant_id)
|
old = _fetch_variant_row(cur, exercise_id, variant_id)
|
||||||
|
|
||||||
if "variant_name" in data and data["variant_name"] is not None:
|
if "variant_name" in data and data["variant_name"] is not None:
|
||||||
|
|
@ -2323,6 +2673,7 @@ def delete_exercise_variant(
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
_assert_can_edit_exercise(cur, exercise_id, tenant)
|
_assert_can_edit_exercise(cur, exercise_id, tenant)
|
||||||
|
assert_exercise_not_combination(cur, exercise_id)
|
||||||
_fetch_variant_row(cur, exercise_id, variant_id)
|
_fetch_variant_row(cur, exercise_id, variant_id)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.98"
|
APP_VERSION = "0.8.99"
|
||||||
BUILD_DATE = "2026-05-12"
|
BUILD_DATE = "2026-05-12"
|
||||||
DB_SCHEMA_VERSION = "20260512055"
|
DB_SCHEMA_VERSION = "20260512056"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||||
|
|
@ -21,7 +21,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.23.0", # P-11: enrich_exercise_detail + download_file blocken Legal-Hold-Assets (451)
|
"exercises": "2.24.0", # Phase 2: Kombinationsübungen exercise_kind/combination_slots + Archetyp/Profil (Migration 056)
|
||||||
"training_units": "0.2.0",
|
"training_units": "0.2.0",
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.9.0", # apply-training-module; Trainingsmodule-Bibliothek (Phase 1)
|
"planning": "0.9.0", # apply-training-module; Trainingsmodule-Bibliothek (Phase 1)
|
||||||
|
|
@ -35,6 +35,13 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.99",
|
||||||
|
"date": "2026-05-12",
|
||||||
|
"changes": [
|
||||||
|
"exercises Phase 2 (Kombinationsübungen): Migration 056 (`exercise_kind`, `method_archetype`, `method_profile`; Tabellen Slots/Kandidaten); CRUD über POST/PUT/GET Übung mit `combination_slots`; Liste-Filter `exercise_kind_any`; Varianten-Endpoints verbieten `exercise_kind=combination`.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.98",
|
"version": "0.8.98",
|
||||||
"date": "2026-05-12",
|
"date": "2026-05-12",
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,7 @@ export default function ExercisePickerModal({
|
||||||
...queryBase,
|
...queryBase,
|
||||||
include_archived: true,
|
include_archived: true,
|
||||||
include_variants: true,
|
include_variants: true,
|
||||||
|
exercise_kind_any: ['simple'],
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
})
|
})
|
||||||
|
|
@ -253,6 +254,7 @@ export default function ExercisePickerModal({
|
||||||
...queryBase,
|
...queryBase,
|
||||||
include_archived: true,
|
include_archived: true,
|
||||||
include_variants: true,
|
include_variants: true,
|
||||||
|
exercise_kind_any: ['simple'],
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
offset,
|
offset,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,42 @@ function ExerciseDetailPage() {
|
||||||
{meta.length > 0 && <p className="exercise-meta-line">{meta.join(' · ')}</p>}
|
{meta.length > 0 && <p className="exercise-meta-line">{meta.join(' · ')}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
|
||||||
|
Array.isArray(exercise.combination_slots) &&
|
||||||
|
exercise.combination_slots.length > 0 && (
|
||||||
|
<section className="card exercise-detail-section">
|
||||||
|
<h2>Stationen und Übungspools</h2>
|
||||||
|
{exercise.method_archetype ? (
|
||||||
|
<p style={{ fontSize: '14px', color: 'var(--text2)', marginTop: 0 }}>
|
||||||
|
Archetyp: <code>{String(exercise.method_archetype)}</code>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<ol style={{ paddingLeft: '1.25rem', marginBottom: 0 }}>
|
||||||
|
{exercise.combination_slots.map((s) => (
|
||||||
|
<li key={`${s.slot_index}-${(s.title || '').slice(0, 8)}`} style={{ marginBottom: '10px' }}>
|
||||||
|
<strong>
|
||||||
|
Station {s.slot_index != null ? s.slot_index : '?'}{s.title ? ` — ${s.title}` : ''}
|
||||||
|
</strong>
|
||||||
|
<ul style={{ margin: '4px 0 0', paddingLeft: '1.2rem' }}>
|
||||||
|
{(s.candidates && s.candidates.length
|
||||||
|
? s.candidates
|
||||||
|
: (s.candidate_exercise_ids || []).map((id) => ({
|
||||||
|
exercise_id: id,
|
||||||
|
title: null,
|
||||||
|
}))
|
||||||
|
).map((c) => (
|
||||||
|
<li key={c.exercise_id}>
|
||||||
|
<Link to={`/exercises/${c.exercise_id}`}>Übung #{c.exercise_id}</Link>
|
||||||
|
{c.title ? ` — ${c.title}` : ''}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{exercise.goal && (
|
{exercise.goal && (
|
||||||
<section className="card exercise-detail-section">
|
<section className="card exercise-detail-section">
|
||||||
<h2>Ziel</h2>
|
<h2>Ziel</h2>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,29 @@ const VARIANT_DIFFICULTY = [
|
||||||
{ value: 'adapted', label: 'Angepasst' },
|
{ value: 'adapted', label: 'Angepasst' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** An API `method_archetype` (Backend `COMBINATION_ARCHETYPE_IDS`) */
|
||||||
|
const COMBINATION_ARCHETYPE_OPTIONS = [
|
||||||
|
{ id: 'sequence_linear', label: 'Lineare Sequenz' },
|
||||||
|
{ id: 'circuit_rotate_time', label: 'Rotierender Zirkel (Zeit)' },
|
||||||
|
{ id: 'circuit_all_parallel', label: 'Parallele Stationen' },
|
||||||
|
{ id: 'station_parcour', label: 'Parcours' },
|
||||||
|
{ id: 'pair_superset', label: 'Partner- / Paarwechsel' },
|
||||||
|
{ id: 'time_domain_interval', label: 'Intervallblock (Zeitdomäne)' },
|
||||||
|
{ id: 'free_method_block', label: 'Freier Methodenblock' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function comboSlotsFromDetail(exercise) {
|
||||||
|
const raw = exercise?.combination_slots
|
||||||
|
if (!Array.isArray(raw) || raw.length === 0) {
|
||||||
|
return [{ slot_index: 0, title: '', idsText: '' }]
|
||||||
|
}
|
||||||
|
return raw.map((s, i) => ({
|
||||||
|
slot_index: s.slot_index != null ? Number(s.slot_index) : i,
|
||||||
|
title: s.title != null ? String(s.title) : '',
|
||||||
|
idsText: Array.isArray(s.candidate_exercise_ids) ? s.candidate_exercise_ids.join(', ') : '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
function emptyVariantDraft() {
|
function emptyVariantDraft() {
|
||||||
return {
|
return {
|
||||||
variant_name: '',
|
variant_name: '',
|
||||||
|
|
@ -249,6 +272,10 @@ function emptyForm() {
|
||||||
visibility: 'private',
|
visibility: 'private',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
skills: [],
|
skills: [],
|
||||||
|
exercise_kind: 'simple',
|
||||||
|
method_archetype: '',
|
||||||
|
method_profile_json: '{}',
|
||||||
|
combination_slots: [{ slot_index: 0, title: '', idsText: '' }],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -291,6 +318,18 @@ function detailToForm(exercise) {
|
||||||
required_level: normalizeSkillLevelSlug(s.required_level),
|
required_level: normalizeSkillLevelSlug(s.required_level),
|
||||||
target_level: normalizeSkillLevelSlug(s.target_level),
|
target_level: normalizeSkillLevelSlug(s.target_level),
|
||||||
})) || [],
|
})) || [],
|
||||||
|
exercise_kind:
|
||||||
|
String(exercise.exercise_kind || 'simple').toLowerCase() === 'combination'
|
||||||
|
? 'combination'
|
||||||
|
: 'simple',
|
||||||
|
method_archetype: exercise.method_archetype != null ? String(exercise.method_archetype) : '',
|
||||||
|
method_profile_json:
|
||||||
|
typeof exercise.method_profile === 'object' &&
|
||||||
|
exercise.method_profile != null &&
|
||||||
|
!Array.isArray(exercise.method_profile)
|
||||||
|
? JSON.stringify(exercise.method_profile, null, 2)
|
||||||
|
: '{}',
|
||||||
|
combination_slots: comboSlotsFromDetail(exercise),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -949,6 +988,180 @@ function ExerciseFormPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginTop: 0, marginBottom: '10px', fontSize: '1rem' }}>Übungstyp</h3>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Art</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={formData.exercise_kind === 'combination' ? 'combination' : 'simple'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nk = e.target.value
|
||||||
|
setFormDirty(true)
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
exercise_kind: nk,
|
||||||
|
...(nk === 'simple'
|
||||||
|
? {
|
||||||
|
method_archetype: '',
|
||||||
|
method_profile_json: '{}',
|
||||||
|
combination_slots: [{ slot_index: 0, title: '', idsText: '' }],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="simple">Einzelübung</option>
|
||||||
|
<option value="combination">Kombinationsübung (Stationen / Pool)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{formData.exercise_kind === 'combination' ? (
|
||||||
|
<>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Methoden-Archetyp (optional)</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={formData.method_archetype || ''}
|
||||||
|
onChange={(e) => updateFormField('method_archetype', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— später wählen —</option>
|
||||||
|
{COMBINATION_ARCHETYPE_OPTIONS.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Ablaufprofil (JSON, optional)</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={4}
|
||||||
|
value={formData.method_profile_json || '{}'}
|
||||||
|
onChange={(e) => updateFormField('method_profile_json', e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder='{"work_seconds":45,"rest_seconds":15,"rounds":3}'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong style={{ fontSize: '14px' }}>Stationen</strong>
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '6px 0 10px' }}>
|
||||||
|
Index (Reihenfolge), optional Stationstitel und kommaseparierte IDs von{' '}
|
||||||
|
<strong>Einzelübungen</strong> im Pool (nur Übungen mit Art „Einzelübung“).
|
||||||
|
</p>
|
||||||
|
{(formData.combination_slots || []).map((row, idx) => (
|
||||||
|
<div
|
||||||
|
key={`cs-${idx}`}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(0, 72px) minmax(0, 1fr) minmax(0, 1.2fr) auto',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
alignItems: 'end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label" style={{ fontSize: '12px' }}>
|
||||||
|
Idx.
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={99}
|
||||||
|
className="form-input"
|
||||||
|
value={row.slot_index}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...(formData.combination_slots || [])]
|
||||||
|
const v = e.target.value
|
||||||
|
next[idx] = {
|
||||||
|
...row,
|
||||||
|
slot_index: v === '' ? '' : parseInt(v, 10),
|
||||||
|
}
|
||||||
|
updateFormField('combination_slots', next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label" style={{ fontSize: '12px' }}>
|
||||||
|
Titel
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={row.title || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...(formData.combination_slots || [])]
|
||||||
|
next[idx] = { ...row, title: e.target.value }
|
||||||
|
updateFormField('combination_slots', next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label" style={{ fontSize: '12px' }}>
|
||||||
|
Übungs-IDs
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={row.idsText || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...(formData.combination_slots || [])]
|
||||||
|
next[idx] = { ...row, idsText: e.target.value }
|
||||||
|
updateFormField('combination_slots', next)
|
||||||
|
}}
|
||||||
|
placeholder="z. B. 12, 34, 56"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
style={{ fontSize: '12px', padding: '6px 8px' }}
|
||||||
|
onClick={() => {
|
||||||
|
const prev = formData.combination_slots || []
|
||||||
|
const next = prev.filter((_, j) => j !== idx)
|
||||||
|
updateFormField(
|
||||||
|
'combination_slots',
|
||||||
|
next.length ? next : [{ slot_index: 0, title: '', idsText: '' }],
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Entf.
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: '12px', marginTop: '4px' }}
|
||||||
|
onClick={() => {
|
||||||
|
const cur = formData.combination_slots || []
|
||||||
|
const ixList = cur
|
||||||
|
.map((r) =>
|
||||||
|
typeof r.slot_index === 'number' && !Number.isNaN(r.slot_index) ? r.slot_index : null,
|
||||||
|
)
|
||||||
|
.filter((n) => n != null)
|
||||||
|
const nextIx = ixList.length ? Math.max(...ixList) + 1 : 0
|
||||||
|
updateFormField('combination_slots', [
|
||||||
|
...cur,
|
||||||
|
{ slot_index: nextIx, title: '', idsText: '' },
|
||||||
|
])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Station
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Ziel *</label>
|
<label className="form-label">Ziel *</label>
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
|
|
@ -1219,7 +1432,7 @@ function ExerciseFormPage() {
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isEdit && (
|
{isEdit && formData.exercise_kind !== 'combination' && (
|
||||||
<details ref={variantsDetailsRef} className="card exercise-variants-details" style={{ marginTop: '16px' }}>
|
<details ref={variantsDetailsRef} className="card exercise-variants-details" style={{ marginTop: '16px' }}>
|
||||||
<summary className="exercise-variants-summary">
|
<summary className="exercise-variants-summary">
|
||||||
<span className="exercise-variants-summary__title">Übungsvarianten</span>
|
<span className="exercise-variants-summary__title">Übungsvarianten</span>
|
||||||
|
|
@ -1380,7 +1593,7 @@ function ExerciseFormPage() {
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isEdit && (
|
{isEdit && formData.exercise_kind !== 'combination' && (
|
||||||
<details className="card exercise-variants-details" style={{ marginTop: '16px' }}>
|
<details className="card exercise-variants-details" style={{ marginTop: '16px' }}>
|
||||||
<summary className="exercise-variants-summary">
|
<summary className="exercise-variants-summary">
|
||||||
<span className="exercise-variants-summary__title">Progressionsgraph</span>
|
<span className="exercise-variants-summary__title">Progressionsgraph</span>
|
||||||
|
|
|
||||||
|
|
@ -1408,6 +1408,11 @@ function ExercisesListPage() {
|
||||||
{typeNames.map((name) => (
|
{typeNames.map((name) => (
|
||||||
<span key={`tt:${name}`} className="exercise-tag exercise-tag--training">{name}</span>
|
<span key={`tt:${name}`} className="exercise-tag exercise-tag--training">{name}</span>
|
||||||
))}
|
))}
|
||||||
|
{(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' ? (
|
||||||
|
<span className="exercise-tag" style={{ background: 'var(--accent-soft)', color: 'var(--accent-dark)' }}>
|
||||||
|
Kombination
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{exercise.summary && String(exercise.summary).trim() ? (
|
{exercise.summary && String(exercise.summary).trim() ? (
|
||||||
<div className="exercise-card-summary exercise-card-summary--rich">
|
<div className="exercise-card-summary exercise-card-summary--rich">
|
||||||
|
|
|
||||||
|
|
@ -451,7 +451,7 @@ export function buildExerciseApiPayload(formData, extras = {}) {
|
||||||
.filter((x) => x && x.target_group_id)
|
.filter((x) => x && x.target_group_id)
|
||||||
.map((x) => ({ target_group_id: Number(x.target_group_id), is_primary: !!x.is_primary }))
|
.map((x) => ({ target_group_id: Number(x.target_group_id), is_primary: !!x.is_primary }))
|
||||||
|
|
||||||
return {
|
const payload = {
|
||||||
title: (formData.title || '').trim(),
|
title: (formData.title || '').trim(),
|
||||||
summary: formData.summary || null,
|
summary: formData.summary || null,
|
||||||
goal: goalHtml.trim() ? goalHtml : null,
|
goal: goalHtml.trim() ? goalHtml : null,
|
||||||
|
|
@ -478,8 +478,65 @@ export function buildExerciseApiPayload(formData, extras = {}) {
|
||||||
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,
|
||||||
|
exercise_kind:
|
||||||
|
String(formData.exercise_kind || 'simple').toLowerCase() === 'combination'
|
||||||
|
? 'combination'
|
||||||
|
: 'simple',
|
||||||
...extras,
|
...extras,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isCombo = payload.exercise_kind === 'combination'
|
||||||
|
|
||||||
|
if (isCombo) {
|
||||||
|
let mpObj = {}
|
||||||
|
const mpRaw = typeof formData.method_profile_json === 'string' ? formData.method_profile_json.trim() : ''
|
||||||
|
if (mpRaw) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(mpRaw)
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
throw new Error('Ablaufprofil muss ein JSON-Objekt sein.')
|
||||||
|
}
|
||||||
|
mpObj = parsed
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SyntaxError) {
|
||||||
|
throw new Error('Ablaufprofil (JSON): Syntax ungültig.')
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotRows = Array.isArray(formData.combination_slots) ? formData.combination_slots : []
|
||||||
|
const combination_slots = []
|
||||||
|
for (let i = 0; i < slotRows.length; i += 1) {
|
||||||
|
const row = slotRows[i] || {}
|
||||||
|
const ix =
|
||||||
|
row.slot_index === '' || row.slot_index == null ? i : parseInt(row.slot_index, 10)
|
||||||
|
if (Number.isNaN(ix) || ix < 0 || ix > 99) {
|
||||||
|
throw new Error(`Station Index ungültig (Zeile ${i + 1}).`)
|
||||||
|
}
|
||||||
|
const idsText = typeof row.idsText === 'string' ? row.idsText : ''
|
||||||
|
const candidate_exercise_ids = idsText
|
||||||
|
.split(/[\s,;]+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((s) => parseInt(s, 10))
|
||||||
|
.filter((n) => Number.isFinite(n))
|
||||||
|
combination_slots.push({
|
||||||
|
slot_index: ix,
|
||||||
|
title: (typeof row.title === 'string' && row.title.trim()) || null,
|
||||||
|
candidate_exercise_ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.method_archetype = (formData.method_archetype || '').trim() || null
|
||||||
|
payload.method_profile = mpObj
|
||||||
|
payload.combination_slots = combination_slots
|
||||||
|
} else {
|
||||||
|
payload.method_archetype = null
|
||||||
|
payload.method_profile = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadExerciseMedia(exerciseId, formData) {
|
export async function uploadExerciseMedia(exerciseId, formData) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user