All checks were successful
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 45s
- Added functionality to promote private exercises used in training units to club visibility, allowing better access for trainers and members. - Introduced helper functions to retrieve distinct exercise IDs and group club IDs for scheduled units. - Updated the create, update, and quick create training unit methods to include exercise promotion logic, enhancing exercise management within clubs.
2030 lines
75 KiB
Python
2030 lines
75 KiB
Python
"""
|
||
Exercises Router - v2.0 (Clean-Room Rebuild)
|
||
|
||
Komplett neu gebaut nach EXERCISES_API_SPEC.md v1.2
|
||
KEIN Legacy-Code aus v1 - nur M:N Relations, keine JSONB-Felder für Kataloge
|
||
"""
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
import os
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form
|
||
from pydantic import BaseModel, Field, model_validator
|
||
|
||
from db import get_db, get_cursor, r2d
|
||
from club_tenancy import (
|
||
assert_valid_governance_visibility,
|
||
can_manage_club_org,
|
||
club_admin_shares_club_with_creator,
|
||
has_club_role,
|
||
is_platform_admin,
|
||
library_content_visible_to_profile,
|
||
)
|
||
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/api", tags=["exercises"])
|
||
|
||
|
||
def _coerce_json_str_list(val: Any) -> List[str]:
|
||
"""JSON-Aggregat oder JSON-String aus PG in eine saubere str-Liste für die Listen-API."""
|
||
if val is None:
|
||
return []
|
||
if isinstance(val, list):
|
||
return [str(x) for x in val if x is not None and str(x).strip()]
|
||
if isinstance(val, str):
|
||
try:
|
||
parsed = json.loads(val)
|
||
if isinstance(parsed, list):
|
||
return [str(x) for x in parsed if x is not None and str(x).strip()]
|
||
except Exception:
|
||
return []
|
||
return []
|
||
return []
|
||
|
||
|
||
# Kanonische Fähigkeitsstufen 1–5 (Übung ↔ Skill-Zeile), siehe Migration 029
|
||
_CANONICAL_SKILL_LEVELS = frozenset(
|
||
{"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"}
|
||
)
|
||
_LEGACY_SKILL_LEVEL_SLUG = {
|
||
"einsteiger": "basis",
|
||
"experte": "optimierung",
|
||
"1": "basis",
|
||
"2": "grundlagen",
|
||
"3": "aufbau",
|
||
"4": "fortgeschritten",
|
||
"5": "optimierung",
|
||
}
|
||
|
||
# SQL: numerischer Rang aus target_level (fallback required_level) für Filter
|
||
_EXERCISE_SKILL_LEVEL_RANK_SQL = """
|
||
CASE COALESCE(
|
||
NULLIF(TRIM(LOWER(es.target_level::text)), ''),
|
||
NULLIF(TRIM(LOWER(es.required_level::text)), '')
|
||
)
|
||
WHEN 'basis' THEN 1
|
||
WHEN 'grundlagen' THEN 2
|
||
WHEN 'aufbau' THEN 3
|
||
WHEN 'fortgeschritten' THEN 4
|
||
WHEN 'optimierung' THEN 5
|
||
WHEN 'einsteiger' THEN 1
|
||
WHEN 'experte' THEN 5
|
||
WHEN '1' THEN 1
|
||
WHEN '2' THEN 2
|
||
WHEN '3' THEN 3
|
||
WHEN '4' THEN 4
|
||
WHEN '5' THEN 5
|
||
ELSE NULL END
|
||
""".strip()
|
||
|
||
|
||
def normalize_exercise_skill_level(value) -> Optional[str]:
|
||
"""Wandelt Legacy-/Zahlencodes in kanonische Slugs; ungültig → None."""
|
||
if value is None:
|
||
return None
|
||
s = str(value).strip().lower()
|
||
if not s:
|
||
return None
|
||
if s in _CANONICAL_SKILL_LEVELS:
|
||
return s
|
||
return _LEGACY_SKILL_LEVEL_SLUG.get(s)
|
||
|
||
MEDIA_ROOT = Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent.parent / "media")))
|
||
MAX_EXERCISE_MEDIA = 10
|
||
# Upload-Limits (Übungs-Medien): Trainer wie bisher kleiner; Admin/Superadmin höheres Limit für große Videos
|
||
_MAX_UPLOAD_MB_USER = max(1, int(os.getenv("EXERCISE_MEDIA_MAX_UPLOAD_MB", "50")))
|
||
_MAX_UPLOAD_MB_ADMIN = max(_MAX_UPLOAD_MB_USER, int(os.getenv("EXERCISE_MEDIA_ADMIN_MAX_UPLOAD_MB", "1024")))
|
||
MAX_UPLOAD_BYTES_USER = _MAX_UPLOAD_MB_USER * 1024 * 1024
|
||
MAX_UPLOAD_BYTES_ADMIN = _MAX_UPLOAD_MB_ADMIN * 1024 * 1024
|
||
ALLOWED_UPLOAD_MIMES = frozenset(
|
||
{"image/jpeg", "image/png", "image/gif", "video/mp4", "application/pdf"}
|
||
)
|
||
|
||
|
||
def _upload_limit_bytes(tenant: TenantContext) -> int:
|
||
role = tenant.global_role or ""
|
||
if role in ("admin", "superadmin"):
|
||
return MAX_UPLOAD_BYTES_ADMIN
|
||
return MAX_UPLOAD_BYTES_USER
|
||
|
||
|
||
# ============================================================================
|
||
# Pydantic Models
|
||
# ============================================================================
|
||
|
||
class ExerciseCreate(BaseModel):
|
||
# Basis-Felder (goal/execution: DB-Constraint mind. eines; Wiki oft nur eines)
|
||
title: str = Field(..., min_length=3, max_length=300)
|
||
summary: Optional[str] = None
|
||
goal: Optional[str] = Field(None, max_length=5000)
|
||
execution: Optional[str] = Field(None, max_length=10000)
|
||
preparation: Optional[str] = None
|
||
trainer_notes: Optional[str] = None
|
||
|
||
# Dauer & Gruppengröße
|
||
duration_min: Optional[int] = None
|
||
duration_max: Optional[int] = None
|
||
group_size_min: Optional[int] = None
|
||
group_size_max: Optional[int] = None
|
||
|
||
# Equipment (Liste von Strings)
|
||
equipment: list[str] = []
|
||
|
||
# M:N Relations (Liste von {id: int, is_primary: bool})
|
||
focus_areas_multi: list[dict] = []
|
||
training_styles_multi: list[dict] = []
|
||
training_types_multi: list[dict] = []
|
||
target_groups_multi: list[dict] = []
|
||
age_groups: list[str] = [] # ["Kinder", "Teenager"] aus Katalog
|
||
|
||
# Skills (Liste von {skill_id: int, is_primary: bool, intensity: str, required_level: str, target_level: str})
|
||
skills: list[dict] = []
|
||
|
||
# Sichtbarkeit & Status
|
||
visibility: str = "private"
|
||
status: str = "draft"
|
||
club_id: Optional[int] = None
|
||
|
||
@model_validator(mode="after")
|
||
def normalize_goal_execution(self):
|
||
g = (self.goal or "").strip() or None
|
||
e = (self.execution or "").strip() or None
|
||
if not g and not e:
|
||
raise ValueError("Mindestens eines der Felder Ziel oder Durchführung ist erforderlich")
|
||
self.goal = g
|
||
self.execution = e
|
||
return self
|
||
|
||
|
||
class ExerciseUpdate(BaseModel):
|
||
# Alle Felder optional für Partial Update
|
||
title: Optional[str] = Field(None, min_length=3, max_length=300)
|
||
summary: Optional[str] = None
|
||
goal: Optional[str] = Field(None, max_length=5000)
|
||
execution: Optional[str] = Field(None, max_length=10000)
|
||
preparation: Optional[str] = None
|
||
trainer_notes: Optional[str] = None
|
||
duration_min: Optional[int] = None
|
||
duration_max: Optional[int] = None
|
||
group_size_min: Optional[int] = None
|
||
group_size_max: Optional[int] = None
|
||
equipment: Optional[list[str]] = None
|
||
focus_areas_multi: Optional[list[dict]] = None
|
||
training_styles_multi: Optional[list[dict]] = None
|
||
training_types_multi: Optional[list[dict]] = None
|
||
target_groups_multi: Optional[list[dict]] = None
|
||
age_groups: Optional[list[str]] = None
|
||
skills: Optional[list[dict]] = None
|
||
visibility: Optional[str] = None
|
||
status: Optional[str] = None
|
||
club_id: Optional[int] = None
|
||
|
||
@model_validator(mode="after")
|
||
def normalize_goal_execution(self):
|
||
if self.goal is not None:
|
||
self.goal = self.goal.strip() or None
|
||
if self.execution is not None:
|
||
self.execution = self.execution.strip() or None
|
||
return self
|
||
|
||
|
||
class ExerciseMediaUpdate(BaseModel):
|
||
title: Optional[str] = None
|
||
description: Optional[str] = None
|
||
is_primary: Optional[bool] = None
|
||
context: Optional[str] = None
|
||
|
||
|
||
class ExerciseMediaReorder(BaseModel):
|
||
media_ids: list[int]
|
||
|
||
|
||
class ExerciseVariantCreate(BaseModel):
|
||
variant_name: str = Field(..., min_length=3, max_length=200)
|
||
description: Optional[str] = None
|
||
execution_changes: Optional[str] = None
|
||
duration_min: Optional[int] = None
|
||
duration_max: Optional[int] = None
|
||
equipment_changes: Optional[list[str]] = None
|
||
difficulty_adjustment: Optional[str] = Field(None, max_length=50)
|
||
progression_level: int = Field(default=1, ge=1, le=10)
|
||
sequence_order: Optional[int] = None
|
||
prerequisite_variant_id: Optional[int] = None
|
||
|
||
|
||
class ExerciseVariantUpdate(BaseModel):
|
||
variant_name: Optional[str] = Field(None, min_length=3, max_length=200)
|
||
description: Optional[str] = None
|
||
execution_changes: Optional[str] = None
|
||
duration_min: Optional[int] = None
|
||
duration_max: Optional[int] = None
|
||
equipment_changes: Optional[list[str]] = None
|
||
difficulty_adjustment: Optional[str] = Field(None, max_length=50)
|
||
progression_level: Optional[int] = Field(None, ge=1, le=10)
|
||
sequence_order: Optional[int] = None
|
||
prerequisite_variant_id: Optional[int] = None
|
||
|
||
|
||
class ExerciseVariantsReorder(BaseModel):
|
||
variant_ids: list[int]
|
||
|
||
|
||
_VALID_EXERCISE_STATUS_BULK = frozenset({"draft", "in_review", "approved", "archived"})
|
||
_LIST_FILTER_VISIBILITY = frozenset({"private", "club", "official"})
|
||
_LIST_FILTER_STATUS = frozenset({"draft", "in_review", "approved", "archived"})
|
||
_MAX_BULK_METADATA_IDS = 500
|
||
_MAX_BULK_RELATION_IDS_PER_KIND = 80
|
||
|
||
|
||
class ExerciseBulkMetadataPatch(BaseModel):
|
||
"""Massenänderung: Sichtbarkeit/Status und/oder Zuordnungen (Kataloge)."""
|
||
|
||
exercise_ids: list[int] = Field(..., min_length=1, max_length=_MAX_BULK_METADATA_IDS)
|
||
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
|
||
status: Optional[str] = None
|
||
club_id: Optional[int] = Field(default=None, ge=1)
|
||
focus_area_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)
|
||
target_group_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
|
||
|
||
@model_validator(mode="after")
|
||
def at_least_one_patch_field(self):
|
||
if (
|
||
self.visibility is None
|
||
and self.status is None
|
||
and self.focus_area_ids is None
|
||
and self.style_direction_ids is None
|
||
and self.training_type_ids is None
|
||
and self.target_group_ids is None
|
||
):
|
||
raise ValueError(
|
||
"Mindestens eines der Felder visibility, status, focus_area_ids, style_direction_ids, "
|
||
"training_type_ids oder target_group_ids angeben"
|
||
)
|
||
return self
|
||
|
||
|
||
# ============================================================================
|
||
# Helper Functions
|
||
# ============================================================================
|
||
|
||
def _row_created_by(row) -> int:
|
||
if row is None:
|
||
return None
|
||
if isinstance(row, dict):
|
||
return row.get("created_by")
|
||
return row[0]
|
||
|
||
|
||
def _ensure_media_dirs():
|
||
sub = MEDIA_ROOT / "exercises"
|
||
sub.mkdir(parents=True, exist_ok=True)
|
||
return sub
|
||
|
||
|
||
def _detect_embed_platform(url: str) -> Optional[str]:
|
||
if not url:
|
||
return None
|
||
u = url.lower()
|
||
if "youtube.com" in u or "youtu.be" in u:
|
||
return "youtube"
|
||
if "vimeo.com" in u:
|
||
return "vimeo"
|
||
if "instagram.com" in u:
|
||
return "instagram"
|
||
if "tiktok.com" in u:
|
||
return "tiktok"
|
||
return None
|
||
|
||
|
||
def _assert_can_edit_exercise(cur, exercise_id: int, profile_id: int):
|
||
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
|
||
row = cur.fetchone()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
||
if _row_created_by(row) != profile_id:
|
||
raise HTTPException(status_code=403, detail="Nur der Ersteller darf diese Übung bearbeiten")
|
||
|
||
|
||
def _variant_equipment_json(changes: Optional[list]) -> str:
|
||
return json.dumps(changes if changes else [])
|
||
|
||
|
||
def _normalize_variant_equipment_list(val) -> list:
|
||
if val is None:
|
||
return []
|
||
if isinstance(val, list):
|
||
return val
|
||
if isinstance(val, str):
|
||
try:
|
||
return json.loads(val)
|
||
except Exception:
|
||
return []
|
||
return []
|
||
|
||
|
||
def _validate_variant_prerequisite(cur, exercise_id: int, prereq_id: Optional[int]) -> None:
|
||
if prereq_id is None:
|
||
return
|
||
cur.execute(
|
||
"SELECT 1 FROM exercise_variants WHERE id = %s AND exercise_id = %s",
|
||
(prereq_id, exercise_id),
|
||
)
|
||
if not cur.fetchone():
|
||
raise HTTPException(status_code=400, detail="Voraussetzungs-Variante gehört nicht zu dieser Übung")
|
||
|
||
|
||
def _fetch_variant_row(cur, exercise_id: int, variant_id: int) -> dict:
|
||
cur.execute(
|
||
"""SELECT id, variant_name, description, execution_changes,
|
||
duration_min, duration_max, equipment_changes, difficulty_adjustment,
|
||
progression_level, sequence_order, prerequisite_variant_id, created_at
|
||
FROM exercise_variants WHERE id = %s AND exercise_id = %s""",
|
||
(variant_id, exercise_id),
|
||
)
|
||
row = cur.fetchone()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Variante nicht gefunden")
|
||
return r2d(row)
|
||
|
||
|
||
def _count_exercise_media(cur, exercise_id: int) -> int:
|
||
cur.execute("SELECT COUNT(*) AS c FROM exercise_media WHERE exercise_id = %s", (exercise_id,))
|
||
r = cur.fetchone()
|
||
return int(r["c"] if isinstance(r, dict) else r[0])
|
||
|
||
|
||
def _abs_media_path(file_path_db: str) -> Optional[Path]:
|
||
if not file_path_db or file_path_db.startswith("http"):
|
||
return None
|
||
rel = file_path_db.lstrip("/")
|
||
if rel.startswith("media/"):
|
||
rel = rel[len("media/") :]
|
||
p = MEDIA_ROOT / rel
|
||
try:
|
||
p.resolve().relative_to(MEDIA_ROOT.resolve())
|
||
except ValueError:
|
||
return None
|
||
return p
|
||
|
||
def enrich_exercise_detail(exercise_id: int, cur) -> dict:
|
||
"""
|
||
Lädt alle M:N Relations für eine Übung und gibt ein vollständiges
|
||
Exercise-Objekt zurück (wie in API-Spec GET /exercises/{id}).
|
||
"""
|
||
# Basis-Exercise
|
||
cur.execute(
|
||
"""SELECT e.*, p.name as creator_name, c.name as club_name
|
||
FROM exercises e
|
||
LEFT JOIN profiles p ON e.created_by = p.id
|
||
LEFT JOIN clubs c ON e.club_id = c.id
|
||
WHERE e.id = %s""",
|
||
(exercise_id,)
|
||
)
|
||
row = cur.fetchone()
|
||
if not row:
|
||
return None
|
||
|
||
exercise = r2d(row)
|
||
|
||
# Equipment JSONB → List
|
||
if exercise.get("equipment"):
|
||
exercise["equipment"] = exercise["equipment"] if isinstance(exercise["equipment"], list) else []
|
||
else:
|
||
exercise["equipment"] = []
|
||
|
||
# Focus Areas (M:N)
|
||
cur.execute(
|
||
"""SELECT efa.id, efa.focus_area_id, fa.name, fa.abbreviation, fa.color, fa.icon, efa.is_primary
|
||
FROM exercise_focus_areas efa
|
||
JOIN focus_areas fa ON efa.focus_area_id = fa.id
|
||
WHERE efa.exercise_id = %s
|
||
ORDER BY efa.is_primary DESC, fa.name""",
|
||
(exercise_id,)
|
||
)
|
||
exercise["focus_areas"] = [r2d(r) for r in cur.fetchall()]
|
||
|
||
# Training Styles (M:N)
|
||
cur.execute(
|
||
"""SELECT ets.id, ets.style_direction_id as training_style_id, sd.name, sd.abbreviation, ets.is_primary
|
||
FROM exercise_style_directions ets
|
||
JOIN style_directions sd ON ets.style_direction_id = sd.id
|
||
WHERE ets.exercise_id = %s
|
||
ORDER BY ets.is_primary DESC, sd.name""",
|
||
(exercise_id,)
|
||
)
|
||
exercise["training_styles"] = [r2d(r) for r in cur.fetchall()]
|
||
|
||
# Trainingsstil (Breitensport / Leistungssport …) — exercise_training_types
|
||
cur.execute(
|
||
"""SELECT ett.id, ett.training_type_id, tt.name, tt.abbreviation, ett.is_primary
|
||
FROM exercise_training_types ett
|
||
JOIN training_types tt ON ett.training_type_id = tt.id
|
||
WHERE ett.exercise_id = %s
|
||
ORDER BY ett.is_primary DESC, tt.sort_order NULLS LAST, tt.name""",
|
||
(exercise_id,),
|
||
)
|
||
exercise["training_types"] = [r2d(r) for r in cur.fetchall()]
|
||
|
||
# Target Groups (M:N)
|
||
cur.execute(
|
||
"""SELECT etg.id, etg.target_group_id, tg.name, tg.description, etg.is_primary
|
||
FROM exercise_target_groups etg
|
||
JOIN target_groups tg ON etg.target_group_id = tg.id
|
||
WHERE etg.exercise_id = %s
|
||
ORDER BY etg.is_primary DESC, tg.name""",
|
||
(exercise_id,)
|
||
)
|
||
exercise["target_groups"] = [r2d(r) for r in cur.fetchall()]
|
||
|
||
# Age Groups (M:N) - direkt als VARCHAR gespeichert
|
||
cur.execute(
|
||
"""SELECT age_group
|
||
FROM exercise_age_groups
|
||
WHERE exercise_id = %s
|
||
ORDER BY age_group""",
|
||
(exercise_id,)
|
||
)
|
||
exercise["age_groups"] = [r["age_group"] for r in cur.fetchall()]
|
||
|
||
# Skills (M:N) mit Levels und Intensity
|
||
cur.execute(
|
||
"""SELECT es.id, es.skill_id, s.name as skill_name, s.category as skill_category,
|
||
es.is_primary, es.intensity, es.required_level, es.target_level, es.ai_suggested
|
||
FROM exercise_skills es
|
||
JOIN skills s ON es.skill_id = s.id
|
||
WHERE es.exercise_id = %s
|
||
ORDER BY es.is_primary DESC, s.name""",
|
||
(exercise_id,)
|
||
)
|
||
exercise["skills"] = [r2d(r) for r in cur.fetchall()]
|
||
for sk in exercise["skills"]:
|
||
sk["required_level"] = normalize_exercise_skill_level(sk.get("required_level"))
|
||
sk["target_level"] = normalize_exercise_skill_level(sk.get("target_level"))
|
||
|
||
# Variants (1:N) - mit Progression (Reihenfolge: sequence_order, dann progression_level)
|
||
cur.execute(
|
||
"""SELECT id, variant_name, description, execution_changes,
|
||
duration_min, duration_max, equipment_changes, difficulty_adjustment,
|
||
progression_level, sequence_order, prerequisite_variant_id, created_at
|
||
FROM exercise_variants
|
||
WHERE exercise_id = %s
|
||
ORDER BY sequence_order NULLS LAST, progression_level, id""",
|
||
(exercise_id,)
|
||
)
|
||
exercise["variants"] = [r2d(r) for r in cur.fetchall()]
|
||
|
||
# Media (1:N)
|
||
cur.execute(
|
||
"""SELECT id, media_type, file_path, file_size, mime_type, original_filename,
|
||
embed_url, embed_platform, title, description, sort_order, is_primary, context
|
||
FROM exercise_media
|
||
WHERE exercise_id = %s
|
||
ORDER BY sort_order, id""",
|
||
(exercise_id,)
|
||
)
|
||
exercise["media"] = [r2d(r) for r in cur.fetchall()]
|
||
|
||
return exercise
|
||
|
||
|
||
def assign_exercise_relations(
|
||
cur,
|
||
conn,
|
||
exercise_id: int,
|
||
data: dict,
|
||
*,
|
||
do_commit: bool = True,
|
||
):
|
||
"""
|
||
Weist M:N Relations für eine Übung zu.
|
||
Löscht alte Zuordnungen und legt neue an (REPLACE-Logik).
|
||
"""
|
||
# Focus Areas
|
||
if "focus_areas_multi" in data:
|
||
cur.execute("DELETE FROM exercise_focus_areas WHERE exercise_id = %s", (exercise_id,))
|
||
for fa in data["focus_areas_multi"]:
|
||
cur.execute(
|
||
"""INSERT INTO exercise_focus_areas (exercise_id, focus_area_id, is_primary)
|
||
VALUES (%s, %s, %s)""",
|
||
(exercise_id, fa["focus_area_id"], fa.get("is_primary", False))
|
||
)
|
||
|
||
# Training Styles (Stilrichtungen, z. B. Shotokan)
|
||
if "training_styles_multi" in data:
|
||
cur.execute("DELETE FROM exercise_style_directions WHERE exercise_id = %s", (exercise_id,))
|
||
for ts in data["training_styles_multi"]:
|
||
cur.execute(
|
||
"""INSERT INTO exercise_style_directions (exercise_id, style_direction_id, is_primary)
|
||
VALUES (%s, %s, %s)""",
|
||
(exercise_id, ts["training_style_id"], ts.get("is_primary", False))
|
||
)
|
||
|
||
# Trainingsstil (Breitensport, Leistungssport, …)
|
||
if "training_types_multi" in data:
|
||
cur.execute("DELETE FROM exercise_training_types WHERE exercise_id = %s", (exercise_id,))
|
||
for tt in data["training_types_multi"]:
|
||
cur.execute(
|
||
"""INSERT INTO exercise_training_types (exercise_id, training_type_id, is_primary)
|
||
VALUES (%s, %s, %s)""",
|
||
(exercise_id, tt["training_type_id"], tt.get("is_primary", False)),
|
||
)
|
||
|
||
# Target Groups
|
||
if "target_groups_multi" in data:
|
||
cur.execute("DELETE FROM exercise_target_groups WHERE exercise_id = %s", (exercise_id,))
|
||
for tg in data["target_groups_multi"]:
|
||
cur.execute(
|
||
"""INSERT INTO exercise_target_groups (exercise_id, target_group_id, is_primary)
|
||
VALUES (%s, %s, %s)""",
|
||
(exercise_id, tg["target_group_id"], tg.get("is_primary", False))
|
||
)
|
||
|
||
# Age Groups (direkt als VARCHAR, CHECK constraint validiert)
|
||
if "age_groups" in data:
|
||
cur.execute("DELETE FROM exercise_age_groups WHERE exercise_id = %s", (exercise_id,))
|
||
for age_group_name in data["age_groups"]:
|
||
try:
|
||
cur.execute(
|
||
"INSERT INTO exercise_age_groups (exercise_id, age_group) VALUES (%s, %s)",
|
||
(exercise_id, age_group_name)
|
||
)
|
||
except Exception as e:
|
||
logger.warning("Age Group '%s' ungültig: %s", age_group_name, e)
|
||
|
||
# Skills
|
||
if "skills" in data:
|
||
cur.execute("DELETE FROM exercise_skills WHERE exercise_id = %s", (exercise_id,))
|
||
for skill in data["skills"]:
|
||
cur.execute(
|
||
"""INSERT INTO exercise_skills
|
||
(exercise_id, skill_id, is_primary, intensity, required_level, target_level, ai_suggested)
|
||
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
|
||
(
|
||
exercise_id,
|
||
skill["skill_id"],
|
||
skill.get("is_primary", False),
|
||
skill.get("intensity"),
|
||
normalize_exercise_skill_level(skill.get("required_level")),
|
||
normalize_exercise_skill_level(skill.get("target_level")),
|
||
skill.get("ai_suggested", False),
|
||
)
|
||
)
|
||
|
||
if do_commit:
|
||
conn.commit()
|
||
|
||
|
||
# ============================================================================
|
||
# Endpoints
|
||
# ============================================================================
|
||
|
||
|
||
def _normalize_bulk_id_list(raw: Optional[list]) -> list[int]:
|
||
"""Positive IDs, Reihenfolge beibehalten, Duplikate entfernen."""
|
||
if not raw:
|
||
return []
|
||
seen: set[int] = set()
|
||
out: list[int] = []
|
||
for x in raw:
|
||
try:
|
||
xi = int(x)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if xi < 1 or xi in seen:
|
||
continue
|
||
seen.add(xi)
|
||
out.append(xi)
|
||
return out
|
||
|
||
|
||
def _assert_catalog_ids_exist(cur, kind: str, ids: list[int]) -> None:
|
||
if not ids:
|
||
return
|
||
table_by_kind = {
|
||
"focus_areas": "focus_areas",
|
||
"style_directions": "style_directions",
|
||
"training_types": "training_types",
|
||
"target_groups": "target_groups",
|
||
}
|
||
table = table_by_kind.get(kind)
|
||
if not table:
|
||
raise HTTPException(status_code=500, detail="Interner Fehler: unbekannter Katalog")
|
||
ph = ",".join(["%s"] * len(ids))
|
||
cur.execute(f"SELECT id FROM {table} WHERE id IN ({ph})", tuple(ids))
|
||
found = {
|
||
int(r["id"]) if isinstance(r, dict) else int(r[0])
|
||
for r in cur.fetchall()
|
||
}
|
||
missing = [i for i in ids if i not in found]
|
||
if missing:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Unbekannte {kind}-IDs (Beispiele): {missing[:12]}",
|
||
)
|
||
|
||
|
||
def _merge_ids(multi: list[int], single: Optional[int]) -> list[int]:
|
||
"""Liste aus wiederholten Query-Parametern plus optional einem Legacy-Einzelfilter (ohne Duplikate)."""
|
||
seen: set[int] = set()
|
||
out: list[int] = []
|
||
for x in list(multi or []):
|
||
xi = int(x)
|
||
if xi not in seen:
|
||
seen.add(xi)
|
||
out.append(xi)
|
||
if single is not None:
|
||
xi = int(single)
|
||
if xi not in seen:
|
||
out.append(xi)
|
||
return out
|
||
|
||
|
||
def _dedupe_positive_ids(ids: list[int]) -> list[int]:
|
||
seen: set[int] = set()
|
||
out: list[int] = []
|
||
for raw in ids or []:
|
||
try:
|
||
xi = int(raw)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if xi < 1 or xi in seen:
|
||
continue
|
||
seen.add(xi)
|
||
out.append(xi)
|
||
return out
|
||
|
||
|
||
def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]:
|
||
seen = set()
|
||
out = []
|
||
for x in list(multi or []):
|
||
s = str(x).strip()
|
||
if not s or s in seen:
|
||
continue
|
||
seen.add(s)
|
||
out.append(s)
|
||
if single is not None and str(single).strip():
|
||
s = str(single).strip()
|
||
if s not in seen:
|
||
out.append(s)
|
||
return out
|
||
|
||
|
||
def _normalize_choice_list(raw: list[str], allowed: frozenset, label: str) -> list[str]:
|
||
out = []
|
||
seen = set()
|
||
for x in raw or []:
|
||
s = str(x).strip().lower()
|
||
if not s or s in seen:
|
||
continue
|
||
if s not in allowed:
|
||
raise HTTPException(status_code=400, detail=f"Ungültiger Wert in {label}")
|
||
seen.add(s)
|
||
out.append(s)
|
||
return out
|
||
|
||
|
||
def _exercise_delete_usage_counts(cur, exercise_id: int) -> dict:
|
||
cur.execute(
|
||
"""
|
||
SELECT
|
||
(SELECT COUNT(*)::int FROM exercise_block_items WHERE exercise_id = %s) AS block_items,
|
||
(SELECT COUNT(*)::int FROM training_unit_section_items WHERE exercise_id = %s) AS section_items,
|
||
(SELECT COUNT(*)::int FROM exercise_progression_edges
|
||
WHERE from_exercise_id = %s OR to_exercise_id = %s) AS prog_edges
|
||
""",
|
||
(exercise_id, exercise_id, exercise_id, exercise_id),
|
||
)
|
||
row = cur.fetchone()
|
||
return dict(row) if row else {"block_items": 0, "section_items": 0, "prog_edges": 0}
|
||
|
||
|
||
def _exercise_delete_usage_message(counts: dict) -> str:
|
||
bi = int(counts.get("block_items") or 0)
|
||
si = int(counts.get("section_items") or 0)
|
||
pe = int(counts.get("prog_edges") or 0)
|
||
parts = []
|
||
if bi:
|
||
parts.append(f"{bi}× in Übungsblöcken")
|
||
if si:
|
||
parts.append(f"{si}× in Trainingsplänen oder Rahmenabläufen")
|
||
if pe:
|
||
parts.append(f"{pe}× in Progressionsgraphen (Kanten)")
|
||
if not parts:
|
||
return ""
|
||
return (
|
||
"Die Übung wird noch verwendet und kann nicht gelöscht werden. Bitte auf „archiviert“ setzen. "
|
||
"Verwendung: " + ", ".join(parts) + "."
|
||
)
|
||
|
||
|
||
def _assert_can_delete_exercise(cur, tenant: TenantContext, row: dict) -> None:
|
||
pid = tenant.profile_id
|
||
role = tenant.global_role
|
||
if is_platform_admin(role):
|
||
return
|
||
vis = str(row.get("visibility") or "private").strip().lower()
|
||
cid = row.get("club_id")
|
||
creator = row.get("created_by")
|
||
try:
|
||
creator_int = int(creator) if creator is not None else None
|
||
except (TypeError, ValueError):
|
||
creator_int = None
|
||
|
||
if vis == "official":
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Globale Übungen dürfen nur von Plattform-Admins gelöscht werden.",
|
||
)
|
||
if vis == "club":
|
||
try:
|
||
ex_club = int(cid) if cid is not None else None
|
||
except (TypeError, ValueError):
|
||
ex_club = None
|
||
if ex_club is None:
|
||
raise HTTPException(status_code=400, detail="Vereins-Übung ohne gültige Vereinszuordnung")
|
||
if not has_club_role(cur, pid, ex_club, "club_admin"):
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Nur Vereins-Admins dürfen Vereins-Übungen löschen.",
|
||
)
|
||
return
|
||
|
||
if creator_int is not None and creator_int == pid:
|
||
return
|
||
if creator_int is not None and club_admin_shares_club_with_creator(cur, pid, creator_int):
|
||
return
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Keine Berechtigung zum Löschen dieser Übung.",
|
||
)
|
||
|
||
|
||
@router.patch("/exercises/bulk-metadata")
|
||
def bulk_patch_exercises_metadata(
|
||
body: ExerciseBulkMetadataPatch,
|
||
tenant: TenantContext = Depends(get_tenant_context),
|
||
):
|
||
"""
|
||
Ändert Sichtbarkeit, Status und/oder Katalog-Zuordnungen für viele Übungen auf einmal (REPLACE je Kategorie).
|
||
|
||
Zuordnung: Sind z. B. focus_area_ids im Body gesetzt, werden die Fokusbereiche bei den bearbeiteten
|
||
Übungen vollständig durch diese Liste ersetzt (leeres Array entfernt alle).
|
||
|
||
Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin).
|
||
Zusätzlich: Vereinsorga (club_admin) darf **nur** bei reiner Sichtbarkeitsänderung auf ``club``
|
||
für den eigenen Verein (`club_id` / aktiver Verein) fremde Übungen freigeben — analog
|
||
Trainingseinheit-Speichern.
|
||
|
||
Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin).
|
||
"""
|
||
profile_id = tenant.profile_id
|
||
role = tenant.global_role
|
||
|
||
unique_ids = sorted({int(x) for x in body.exercise_ids if x is not None and int(x) > 0})
|
||
if not unique_ids:
|
||
raise HTTPException(status_code=400, detail="Keine gültigen Übungs-IDs")
|
||
if len(unique_ids) > _MAX_BULK_METADATA_IDS:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Maximal {_MAX_BULK_METADATA_IDS} Übungen pro Anfrage",
|
||
)
|
||
|
||
status_val: Optional[str] = None
|
||
if body.status is not None:
|
||
st = str(body.status).strip().lower()
|
||
if st not in _VALID_EXERCISE_STATUS_BULK:
|
||
raise HTTPException(status_code=400, detail="Ungültiger Status")
|
||
status_val = st
|
||
|
||
patch_visibility = body.visibility is not None
|
||
patch_status = status_val is not None
|
||
|
||
patch_focus_areas = body.focus_area_ids is not None
|
||
fa_ids = _normalize_bulk_id_list(body.focus_area_ids or []) if patch_focus_areas else []
|
||
patch_style_dirs = body.style_direction_ids is not None
|
||
sd_ids = _normalize_bulk_id_list(body.style_direction_ids or []) if patch_style_dirs else []
|
||
patch_training_types = body.training_type_ids is not None
|
||
tt_ids = _normalize_bulk_id_list(body.training_type_ids or []) if patch_training_types else []
|
||
patch_target_groups = body.target_group_ids is not None
|
||
tg_ids = _normalize_bulk_id_list(body.target_group_ids or []) if patch_target_groups else []
|
||
|
||
relation_data: Dict[str, Any] = {}
|
||
if patch_focus_areas:
|
||
relation_data["focus_areas_multi"] = [
|
||
{"focus_area_id": i, "is_primary": idx == 0} for idx, i in enumerate(fa_ids)
|
||
]
|
||
if patch_style_dirs:
|
||
relation_data["training_styles_multi"] = [
|
||
{"training_style_id": i, "is_primary": idx == 0} for idx, i in enumerate(sd_ids)
|
||
]
|
||
if patch_training_types:
|
||
relation_data["training_types_multi"] = [
|
||
{"training_type_id": i, "is_primary": idx == 0} for idx, i in enumerate(tt_ids)
|
||
]
|
||
if patch_target_groups:
|
||
relation_data["target_groups_multi"] = [
|
||
{"target_group_id": i, "is_primary": idx == 0} for idx, i in enumerate(tg_ids)
|
||
]
|
||
|
||
updated: List[int] = []
|
||
failed: List[Dict[str, Any]] = []
|
||
|
||
def _fail_msg(he: HTTPException) -> str:
|
||
d = he.detail
|
||
return d if isinstance(d, str) else str(d)
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
|
||
if patch_focus_areas:
|
||
_assert_catalog_ids_exist(cur, "focus_areas", fa_ids)
|
||
if patch_style_dirs:
|
||
_assert_catalog_ids_exist(cur, "style_directions", sd_ids)
|
||
if patch_training_types:
|
||
_assert_catalog_ids_exist(cur, "training_types", tt_ids)
|
||
if patch_target_groups:
|
||
_assert_catalog_ids_exist(cur, "target_groups", tg_ids)
|
||
|
||
for ex_id in unique_ids:
|
||
cur.execute(
|
||
"SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s",
|
||
(ex_id,),
|
||
)
|
||
row = cur.fetchone()
|
||
if not row:
|
||
failed.append({"id": ex_id, "detail": "Übung nicht gefunden"})
|
||
continue
|
||
rowd = r2d(row)
|
||
owner = rowd.get("created_by")
|
||
if owner is not None:
|
||
owner = int(owner)
|
||
|
||
ex_vis = (rowd.get("visibility") or "private").strip().lower()
|
||
ex_cid_raw = rowd.get("club_id")
|
||
ex_cid = int(ex_cid_raw) if ex_cid_raw is not None else None
|
||
|
||
next_vis = ex_vis
|
||
if patch_visibility:
|
||
next_vis = str(body.visibility).strip().lower()
|
||
|
||
next_club = ex_cid
|
||
if patch_visibility and body.club_id is not None:
|
||
next_club = int(body.club_id)
|
||
|
||
if patch_visibility and next_vis == "club" and next_club is None:
|
||
eff = tenant.effective_club_id
|
||
next_club = int(eff) if eff is not None else None
|
||
|
||
if patch_visibility and next_vis == "club" and next_club is None:
|
||
failed.append(
|
||
{
|
||
"id": ex_id,
|
||
"detail": "Vereins-Übung: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).",
|
||
}
|
||
)
|
||
continue
|
||
|
||
other_meta_patches = (
|
||
patch_status
|
||
or patch_focus_areas
|
||
or patch_style_dirs
|
||
or patch_training_types
|
||
or patch_target_groups
|
||
)
|
||
is_owner_or_platform = owner == profile_id or is_platform_admin(role)
|
||
if not is_owner_or_platform:
|
||
org_club_promo_only = (
|
||
patch_visibility
|
||
and not other_meta_patches
|
||
and next_vis == "club"
|
||
and next_club is not None
|
||
and can_manage_club_org(cur, profile_id, int(next_club), role)
|
||
)
|
||
if not org_club_promo_only:
|
||
failed.append(
|
||
{
|
||
"id": ex_id,
|
||
"detail": "Keine Berechtigung (Ersteller, Plattform-Admin oder Vereinsorga bei reiner Vereinsfreigabe).",
|
||
}
|
||
)
|
||
continue
|
||
|
||
if patch_visibility:
|
||
gov_club = next_club if next_vis == "club" else None
|
||
try:
|
||
assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club)
|
||
except HTTPException as he:
|
||
failed.append({"id": ex_id, "detail": _fail_msg(he)})
|
||
continue
|
||
|
||
sets: List[str] = []
|
||
vals: List[Any] = []
|
||
if patch_visibility:
|
||
sets.extend(["visibility = %s", "club_id = %s"])
|
||
cid_out = next_club if next_vis == "club" else None
|
||
vals.extend([next_vis, cid_out])
|
||
if patch_status:
|
||
sets.append("status = %s")
|
||
vals.append(status_val)
|
||
|
||
sets.append("updated_at = NOW()")
|
||
vals.append(ex_id)
|
||
cur.execute(
|
||
f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s",
|
||
tuple(vals),
|
||
)
|
||
if relation_data:
|
||
assign_exercise_relations(cur, conn, ex_id, relation_data, do_commit=False)
|
||
updated.append(ex_id)
|
||
conn.commit()
|
||
|
||
return {
|
||
"updated": updated,
|
||
"failed": failed,
|
||
"updated_count": len(updated),
|
||
"failed_count": len(failed),
|
||
}
|
||
|
||
|
||
@router.get("/exercises")
|
||
def list_exercises(
|
||
focus_area_ids: list[int] = Query(default=[], description="ODER: mind. einer dieser Fokusbereiche"),
|
||
focus_area: Optional[int] = Query(default=None, description="Einzel-ID (Legacy), wird mit focus_area_ids kombiniert"),
|
||
visibility_any: list[str] = Query(default=[], description="ODER: eine dieser Sichtbarkeiten"),
|
||
visibility: Optional[str] = Query(default=None, description="Einzel (Legacy)"),
|
||
status_any: list[str] = Query(default=[], description="ODER: einer dieser Statuswerte"),
|
||
status: Optional[str] = Query(default=None, description="Einzel (Legacy)"),
|
||
skill_ids: list[int] = Query(default=[], description="ODER: mind. eine dieser Fähigkeiten"),
|
||
skill_id: Optional[int] = Query(default=None, description="Einzel (Legacy)"),
|
||
style_direction_ids: list[int] = Query(default=[], description="ODER: mind. eine Stilrichtung"),
|
||
style_direction_id: Optional[int] = Query(default=None, description="Einzel (Legacy)"),
|
||
training_type_ids: list[int] = Query(default=[], description="ODER: mind. ein Trainingsstil"),
|
||
training_type_id: Optional[int] = Query(default=None, description="Einzel (Legacy)"),
|
||
target_group_ids: list[int] = Query(default=[], description="ODER: mind. eine Zielgruppe"),
|
||
target_group_id: Optional[int] = Query(default=None, description="Einzel (Legacy)"),
|
||
skill_min_level: Optional[int] = Query(default=None, ge=1, le=5),
|
||
skill_max_level: Optional[int] = Query(default=None, ge=1, le=5),
|
||
search: Optional[str] = Query(default=None),
|
||
ai_search: Optional[str] = Query(
|
||
default=None,
|
||
description="Platzhalter KI-Suche: derzeit gleiche Volltextlogik wie search (später Embeddings/Reranking)",
|
||
),
|
||
limit: int = Query(default=50, ge=1, le=100),
|
||
offset: int = Query(default=0, ge=0),
|
||
include_variants: bool = Query(
|
||
default=False,
|
||
description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI",
|
||
),
|
||
visibility_exclude_any: list[str] = Query(
|
||
default=[], description="Keine dieser Sichtbarkeiten (Negativliste)"
|
||
),
|
||
status_exclude_any: list[str] = Query(
|
||
default=[], description="Keiner dieser Statuswerte (Negativliste)"
|
||
),
|
||
exclude_without_focus: bool = Query(
|
||
default=False,
|
||
description="Wenn true: nur Übungen mit mindestens einem Fokusbereich",
|
||
),
|
||
focus_only_without_focus_areas: bool = Query(
|
||
default=False,
|
||
description="Nur Übungen ohne einen einzigen Fokusbereich (M:N exercise_focus_areas leer)",
|
||
),
|
||
focus_area_must_include_ids: list[int] = Query(
|
||
default=[],
|
||
description="Alle genannten Fokusbereiche müssen gesetzt sein (UND / „+“)",
|
||
),
|
||
focus_area_must_exclude_ids: list[int] = Query(
|
||
default=[],
|
||
description="Keiner dieser Fokusbereiche darf gesetzt sein („−“)",
|
||
),
|
||
style_direction_must_include_ids: list[int] = Query(
|
||
default=[],
|
||
description="Alle genannten Stilrichtungen müssen der Übung zugeordnet sein (UND)",
|
||
),
|
||
style_direction_must_exclude_ids: list[int] = Query(
|
||
default=[],
|
||
description="Keine dieser Stilrichtungen darf zugeordnet sein",
|
||
),
|
||
training_type_must_include_ids: list[int] = Query(
|
||
default=[],
|
||
description="Alle genannten Trainingsstile müssen zugeordnet sein (UND)",
|
||
),
|
||
training_type_must_exclude_ids: list[int] = Query(
|
||
default=[],
|
||
description="Keiner dieser Trainingsstile darf zugeordnet sein",
|
||
),
|
||
target_group_must_include_ids: list[int] = Query(
|
||
default=[],
|
||
description="Alle genannten Zielgruppen müssen zugeordnet sein (UND)",
|
||
),
|
||
target_group_must_exclude_ids: list[int] = Query(
|
||
default=[],
|
||
description="Keine dieser Zielgruppen darf zugeordnet sein",
|
||
),
|
||
include_archived: bool = Query(
|
||
default=False,
|
||
description="Archivierte einbeziehen; Standard false (außer Statusfilter enthält archived)",
|
||
),
|
||
created_by_me: bool = Query(
|
||
default=False,
|
||
description="Nur Übungen, die vom aktuellen Profil angelegt wurden (created_by = Profil)",
|
||
),
|
||
tenant: TenantContext = Depends(get_tenant_context),
|
||
):
|
||
"""
|
||
Liste aller Übungen mit Filtern.
|
||
Lightweight Response (ohne M:N Details, nur IDs und Namen).
|
||
Optional include_variants für Variantenauswahl in der Trainingsplanung.
|
||
"""
|
||
profile_id = tenant.profile_id
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
|
||
# WHERE-Bedingungen
|
||
where = ["1=1"]
|
||
params = []
|
||
|
||
role = tenant.global_role
|
||
if not is_platform_admin(role):
|
||
vis_sql, vis_params = library_content_visibility_sql(
|
||
alias="e",
|
||
profile_id=profile_id,
|
||
role=role,
|
||
effective_club_id=tenant.effective_club_id,
|
||
)
|
||
where.append(vis_sql)
|
||
params.extend(vis_params)
|
||
|
||
if created_by_me:
|
||
where.append("e.created_by = %s")
|
||
params.append(profile_id)
|
||
|
||
vis_list = _merge_str_any(visibility_any, visibility)
|
||
if vis_list:
|
||
ph = ",".join(["%s"] * len(vis_list))
|
||
where.append(f"e.visibility IN ({ph})")
|
||
params.extend(vis_list)
|
||
|
||
st_list = _merge_str_any(status_any, status)
|
||
if st_list:
|
||
ph = ",".join(["%s"] * len(st_list))
|
||
where.append(f"e.status IN ({ph})")
|
||
params.extend(st_list)
|
||
|
||
includes_archived = any(str(x).strip().lower() == "archived" for x in st_list)
|
||
if not include_archived and not includes_archived:
|
||
where.append("COALESCE(e.status, '') <> %s")
|
||
params.append("archived")
|
||
|
||
vis_excl = _normalize_choice_list(
|
||
list(visibility_exclude_any),
|
||
_LIST_FILTER_VISIBILITY,
|
||
"visibility_exclude_any",
|
||
)
|
||
if vis_excl:
|
||
ph = ",".join(["%s"] * len(vis_excl))
|
||
where.append(f"(e.visibility IS NULL OR LOWER(TRIM(e.visibility::text)) NOT IN ({ph}))")
|
||
params.extend(vis_excl)
|
||
|
||
st_excl = _normalize_choice_list(
|
||
list(status_exclude_any),
|
||
_LIST_FILTER_STATUS,
|
||
"status_exclude_any",
|
||
)
|
||
if st_excl:
|
||
ph = ",".join(["%s"] * len(st_excl))
|
||
where.append(f"(e.status IS NULL OR LOWER(TRIM(e.status::text)) NOT IN ({ph}))")
|
||
params.extend(st_excl)
|
||
|
||
focus_only = focus_only_without_focus_areas
|
||
must_inc = _dedupe_positive_ids(list(focus_area_must_include_ids))
|
||
must_exc = _dedupe_positive_ids(list(focus_area_must_exclude_ids))
|
||
fa_or = _merge_ids(focus_area_ids, focus_area)
|
||
|
||
if focus_only:
|
||
if exclude_without_focus:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="focus_only_without_focus_areas schließt exclude_without_focus aus.",
|
||
)
|
||
if fa_or:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_ids (ODER-Liste) verwendet werden.",
|
||
)
|
||
if must_inc:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_must_include_ids verwendet werden.",
|
||
)
|
||
if must_exc:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_must_exclude_ids verwendet werden.",
|
||
)
|
||
where.append(
|
||
"NOT EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
|
||
)
|
||
else:
|
||
if exclude_without_focus:
|
||
where.append(
|
||
"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
|
||
)
|
||
if fa_or:
|
||
ph = ",".join(["%s"] * len(fa_or))
|
||
where.append(
|
||
f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
|
||
)
|
||
params.extend(fa_or)
|
||
for fid in must_inc:
|
||
where.append(
|
||
"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id = %s)"
|
||
)
|
||
params.append(fid)
|
||
if must_exc:
|
||
ph = ",".join(["%s"] * len(must_exc))
|
||
where.append(
|
||
f"NOT EXISTS (SELECT 1 FROM exercise_focus_areas efa "
|
||
f"WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
|
||
)
|
||
params.extend(must_exc)
|
||
|
||
sk_ids = _merge_ids(skill_ids, skill_id)
|
||
if sk_ids:
|
||
ph = ",".join(["%s"] * len(sk_ids))
|
||
where.append(
|
||
f"EXISTS (SELECT 1 FROM exercise_skills es WHERE es.exercise_id = e.id AND es.skill_id IN ({ph}))"
|
||
)
|
||
params.extend(sk_ids)
|
||
|
||
sd_or = _merge_ids(style_direction_ids, style_direction_id)
|
||
sd_inc = _dedupe_positive_ids(list(style_direction_must_include_ids))
|
||
sd_exc = _dedupe_positive_ids(list(style_direction_must_exclude_ids))
|
||
if sd_or:
|
||
ph = ",".join(["%s"] * len(sd_or))
|
||
where.append(
|
||
"EXISTS (SELECT 1 FROM exercise_style_directions esd "
|
||
f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))"
|
||
)
|
||
params.extend(sd_or)
|
||
for sid in sd_inc:
|
||
where.append(
|
||
"EXISTS (SELECT 1 FROM exercise_style_directions esd "
|
||
"WHERE esd.exercise_id = e.id AND esd.style_direction_id = %s)"
|
||
)
|
||
params.append(sid)
|
||
if sd_exc:
|
||
ph = ",".join(["%s"] * len(sd_exc))
|
||
where.append(
|
||
"NOT EXISTS (SELECT 1 FROM exercise_style_directions esd "
|
||
f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))"
|
||
)
|
||
params.extend(sd_exc)
|
||
|
||
tt_or = _merge_ids(training_type_ids, training_type_id)
|
||
tt_inc = _dedupe_positive_ids(list(training_type_must_include_ids))
|
||
tt_exc = _dedupe_positive_ids(list(training_type_must_exclude_ids))
|
||
if tt_or:
|
||
ph = ",".join(["%s"] * len(tt_or))
|
||
where.append(
|
||
"EXISTS (SELECT 1 FROM exercise_training_types ett "
|
||
f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))"
|
||
)
|
||
params.extend(tt_or)
|
||
for tid in tt_inc:
|
||
where.append(
|
||
"EXISTS (SELECT 1 FROM exercise_training_types ett "
|
||
"WHERE ett.exercise_id = e.id AND ett.training_type_id = %s)"
|
||
)
|
||
params.append(tid)
|
||
if tt_exc:
|
||
ph = ",".join(["%s"] * len(tt_exc))
|
||
where.append(
|
||
"NOT EXISTS (SELECT 1 FROM exercise_training_types ett "
|
||
f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))"
|
||
)
|
||
params.extend(tt_exc)
|
||
|
||
tg_or = _merge_ids(target_group_ids, target_group_id)
|
||
tg_inc = _dedupe_positive_ids(list(target_group_must_include_ids))
|
||
tg_exc = _dedupe_positive_ids(list(target_group_must_exclude_ids))
|
||
if tg_or:
|
||
ph = ",".join(["%s"] * len(tg_or))
|
||
where.append(
|
||
"EXISTS (SELECT 1 FROM exercise_target_groups etg "
|
||
f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))"
|
||
)
|
||
params.extend(tg_or)
|
||
for gid in tg_inc:
|
||
where.append(
|
||
"EXISTS (SELECT 1 FROM exercise_target_groups etg "
|
||
"WHERE etg.exercise_id = e.id AND etg.target_group_id = %s)"
|
||
)
|
||
params.append(gid)
|
||
if tg_exc:
|
||
ph = ",".join(["%s"] * len(tg_exc))
|
||
where.append(
|
||
"NOT EXISTS (SELECT 1 FROM exercise_target_groups etg "
|
||
f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))"
|
||
)
|
||
params.extend(tg_exc)
|
||
|
||
if skill_min_level is not None or skill_max_level is not None:
|
||
lo = skill_min_level if skill_min_level is not None else 1
|
||
hi = skill_max_level if skill_max_level is not None else 5
|
||
if lo > hi:
|
||
lo, hi = hi, lo
|
||
where.append(
|
||
"EXISTS (SELECT 1 FROM exercise_skills es WHERE es.exercise_id = e.id AND ("
|
||
+ _EXERCISE_SKILL_LEVEL_RANK_SQL
|
||
+ ") BETWEEN %s AND %s)"
|
||
)
|
||
params.extend([lo, hi])
|
||
|
||
# Volltext (tsvector); ai_search gleiche Engine, bei zwei Begriffen ODER-Verknüpfung
|
||
s1 = (search or "").strip()
|
||
s2 = (ai_search or "").strip()
|
||
if s1 and s2 and s1 != s2:
|
||
where.append(
|
||
"(e.search_vector @@ plainto_tsquery('german', %s) "
|
||
"OR e.search_vector @@ plainto_tsquery('german', %s))"
|
||
)
|
||
params.extend([s1, s2])
|
||
elif s1 or s2:
|
||
qtext = s1 or s2
|
||
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
|
||
params.append(qtext)
|
||
|
||
variants_sql = ""
|
||
if include_variants:
|
||
variants_sql = """,
|
||
(
|
||
SELECT COALESCE(
|
||
json_agg(
|
||
json_build_object(
|
||
'id', ev.id,
|
||
'variant_name', ev.variant_name,
|
||
'sequence_order', ev.sequence_order
|
||
)
|
||
ORDER BY ev.sequence_order NULLS LAST, ev.progression_level, ev.id
|
||
),
|
||
'[]'::json
|
||
)
|
||
FROM exercise_variants ev
|
||
WHERE ev.exercise_id = e.id
|
||
) AS variants"""
|
||
|
||
# Query (primary_focus_name für Listen-Ansicht gemäß Spec-Beispiel „focus_area“-Label)
|
||
query = f"""
|
||
SELECT e.id, e.title, e.summary, e.visibility, e.status,
|
||
e.created_by, p.name as creator_name,
|
||
e.club_id, c.name as club_name,
|
||
e.created_at, e.updated_at,
|
||
(
|
||
SELECT fa.name FROM exercise_focus_areas efa
|
||
JOIN focus_areas fa ON fa.id = efa.focus_area_id
|
||
WHERE efa.exercise_id = e.id
|
||
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
|
||
LIMIT 1
|
||
) AS primary_focus_name,
|
||
(
|
||
SELECT COALESCE(
|
||
json_agg(fa.name ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC),
|
||
'[]'::json
|
||
)
|
||
FROM exercise_focus_areas efa
|
||
JOIN focus_areas fa ON fa.id = efa.focus_area_id
|
||
WHERE efa.exercise_id = e.id
|
||
) AS focus_area_names,
|
||
(
|
||
SELECT COALESCE(
|
||
json_agg(sd.name ORDER BY esd.is_primary DESC NULLS LAST, sd.name ASC),
|
||
'[]'::json
|
||
)
|
||
FROM exercise_style_directions esd
|
||
JOIN style_directions sd ON sd.id = esd.style_direction_id
|
||
WHERE esd.exercise_id = e.id
|
||
) AS style_direction_names,
|
||
(
|
||
SELECT COALESCE(
|
||
json_agg(tt.name ORDER BY ett.is_primary DESC NULLS LAST, tt.sort_order NULLS LAST, tt.name ASC),
|
||
'[]'::json
|
||
)
|
||
FROM exercise_training_types ett
|
||
JOIN training_types tt ON tt.id = ett.training_type_id
|
||
WHERE ett.exercise_id = e.id
|
||
) AS training_type_names
|
||
{variants_sql}
|
||
FROM exercises e
|
||
LEFT JOIN profiles p ON e.created_by = p.id
|
||
LEFT JOIN clubs c ON e.club_id = c.id
|
||
WHERE {' AND '.join(where)}
|
||
ORDER BY e.updated_at DESC
|
||
LIMIT %s OFFSET %s
|
||
"""
|
||
params.extend([limit, offset])
|
||
|
||
cur.execute(query, params)
|
||
rows = cur.fetchall()
|
||
|
||
out = []
|
||
for r in rows:
|
||
d = r2d(r)
|
||
pfn = d.get("primary_focus_name")
|
||
d["focus_area"] = pfn
|
||
d["focus_area_names"] = _coerce_json_str_list(d.get("focus_area_names"))
|
||
d["style_direction_names"] = _coerce_json_str_list(d.get("style_direction_names"))
|
||
d["training_type_names"] = _coerce_json_str_list(d.get("training_type_names"))
|
||
if include_variants:
|
||
v = d.get("variants")
|
||
if isinstance(v, str):
|
||
try:
|
||
d["variants"] = json.loads(v)
|
||
except Exception:
|
||
d["variants"] = []
|
||
elif v is None:
|
||
d["variants"] = []
|
||
out.append(d)
|
||
return out
|
||
|
||
|
||
@router.get("/exercises/{exercise_id}")
|
||
def get_exercise(
|
||
exercise_id: int,
|
||
tenant: TenantContext = Depends(get_tenant_context),
|
||
):
|
||
"""
|
||
Exercise Detail mit allen M:N Relations (vollständig enriched).
|
||
"""
|
||
profile_id = tenant.profile_id
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
exercise = enrich_exercise_detail(exercise_id, cur)
|
||
|
||
if not exercise:
|
||
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
||
|
||
if not library_content_visible_to_profile(
|
||
cur,
|
||
profile_id,
|
||
exercise["visibility"],
|
||
exercise.get("club_id"),
|
||
exercise.get("created_by"),
|
||
tenant.global_role,
|
||
):
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Übung")
|
||
|
||
return exercise
|
||
|
||
|
||
@router.post("/exercises", status_code=201)
|
||
def create_exercise(
|
||
body: ExerciseCreate,
|
||
tenant: TenantContext = Depends(get_tenant_context),
|
||
):
|
||
"""
|
||
Erstellt eine neue Übung mit allen M:N Relations.
|
||
"""
|
||
profile_id = tenant.profile_id
|
||
|
||
# Validierung
|
||
if body.status not in ("draft", "in_review", "approved", "archived"):
|
||
raise HTTPException(status_code=400, detail="Ungültiger Status")
|
||
if body.visibility not in ("private", "club", "official"):
|
||
raise HTTPException(status_code=400, detail="Ungültige Visibility")
|
||
|
||
club_id = body.club_id
|
||
if body.visibility == "club" and club_id is None:
|
||
club_id = tenant.effective_club_id
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
assert_valid_governance_visibility(
|
||
cur, profile_id, tenant.global_role, body.visibility, club_id
|
||
)
|
||
|
||
# Equipment als JSONB
|
||
equipment_json = json.dumps(body.equipment) if body.equipment else None
|
||
|
||
# INSERT
|
||
cur.execute(
|
||
"""INSERT INTO exercises
|
||
(title, summary, goal, execution, preparation, trainer_notes,
|
||
duration_min, duration_max, group_size_min, group_size_max,
|
||
equipment, visibility, status, created_by, club_id)
|
||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||
RETURNING id""",
|
||
(
|
||
body.title, body.summary, body.goal, body.execution,
|
||
body.preparation, body.trainer_notes,
|
||
body.duration_min, body.duration_max,
|
||
body.group_size_min, body.group_size_max,
|
||
equipment_json,
|
||
body.visibility, body.status, profile_id, club_id,
|
||
)
|
||
)
|
||
row = cur.fetchone()
|
||
exercise_id = row['id'] if isinstance(row, dict) else row[0]
|
||
conn.commit()
|
||
|
||
# M:N Relations zuweisen
|
||
data = body.dict()
|
||
assign_exercise_relations(cur, conn, exercise_id, data)
|
||
|
||
# Vollständiges Objekt zurückgeben
|
||
exercise = enrich_exercise_detail(exercise_id, cur)
|
||
|
||
return exercise
|
||
|
||
|
||
@router.put("/exercises/{exercise_id}")
|
||
def update_exercise(
|
||
exercise_id: int,
|
||
body: ExerciseUpdate,
|
||
tenant: TenantContext = Depends(get_tenant_context),
|
||
):
|
||
"""
|
||
Aktualisiert eine Übung (Partial Update).
|
||
Nur Owner darf editieren.
|
||
"""
|
||
profile_id = tenant.profile_id
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
|
||
cur.execute(
|
||
"SELECT created_by, visibility, club_id FROM exercises WHERE id = %s",
|
||
(exercise_id,),
|
||
)
|
||
row = cur.fetchone()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
||
|
||
if _row_created_by(row) != profile_id:
|
||
raise HTTPException(status_code=403, detail="Nur der Ersteller darf editieren")
|
||
|
||
ex_vis = (row.get("visibility") or "private").strip().lower()
|
||
ex_cid = row.get("club_id")
|
||
if ex_cid is not None:
|
||
ex_cid = int(ex_cid)
|
||
|
||
data = body.dict(exclude_unset=True)
|
||
|
||
next_vis = ex_vis
|
||
if "visibility" in data and data["visibility"] is not None:
|
||
v_raw = str(data["visibility"]).strip().lower()
|
||
if v_raw:
|
||
next_vis = v_raw
|
||
|
||
next_club = ex_cid
|
||
if "club_id" in data:
|
||
raw_c = data["club_id"]
|
||
if raw_c in (None, "", []):
|
||
next_club = None
|
||
else:
|
||
next_club = int(raw_c)
|
||
|
||
if next_vis == "club":
|
||
if next_club is None:
|
||
next_club = tenant.effective_club_id
|
||
if next_club is None:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Vereins-Übung: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).",
|
||
)
|
||
data["club_id"] = next_club
|
||
|
||
if next_vis != ex_vis:
|
||
data["visibility"] = next_vis
|
||
|
||
gov_club = next_club if next_vis == "club" else None
|
||
assert_valid_governance_visibility(
|
||
cur, profile_id, tenant.global_role, next_vis, gov_club
|
||
)
|
||
|
||
fields = []
|
||
params = []
|
||
|
||
for field in ["title", "summary", "goal", "execution", "preparation", "trainer_notes",
|
||
"duration_min", "duration_max", "group_size_min", "group_size_max",
|
||
"visibility", "status", "club_id"]:
|
||
if field in data and data[field] is not None:
|
||
fields.append(f"{field} = %s")
|
||
params.append(data[field])
|
||
|
||
if "equipment" in data:
|
||
fields.append("equipment = %s")
|
||
params.append(json.dumps(data["equipment"]) if data["equipment"] else None)
|
||
|
||
if fields:
|
||
fields.append("updated_at = NOW()")
|
||
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)
|
||
|
||
exercise = enrich_exercise_detail(exercise_id, cur)
|
||
|
||
return exercise
|
||
|
||
|
||
@router.delete("/exercises/{exercise_id}")
|
||
def delete_exercise(
|
||
exercise_id: int,
|
||
tenant: TenantContext = Depends(get_tenant_context),
|
||
):
|
||
"""
|
||
Löscht eine Übung.
|
||
|
||
Berechtigung: Plattform-Admin (alle); Vereins-Admin Vereins-Übungen seines Vereins;
|
||
Ersteller nur eigene private Übungen; Vereins-Admin zusätzlich private Übungen von Mitgliedern,
|
||
mit denen er einen Verein teilt.
|
||
|
||
Bei Verwendung in Blöcken, Trainingsplänen oder Progressionsgraphen: 409 — bitte archivieren.
|
||
"""
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
|
||
cur.execute(
|
||
"SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s",
|
||
(exercise_id,),
|
||
)
|
||
row = cur.fetchone()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
||
ex = r2d(row)
|
||
|
||
_assert_can_delete_exercise(cur, tenant, ex)
|
||
|
||
counts = _exercise_delete_usage_counts(cur, exercise_id)
|
||
usage_msg = _exercise_delete_usage_message(counts)
|
||
if usage_msg:
|
||
raise HTTPException(status_code=409, detail=usage_msg)
|
||
|
||
cur.execute("DELETE FROM exercises WHERE id = %s", (exercise_id,))
|
||
conn.commit()
|
||
|
||
return {"ok": True}
|
||
|
||
|
||
# --- Übungsvarianten (EXERCISES_API_SPEC.md) ---
|
||
|
||
|
||
@router.put("/exercises/{exercise_id}/variants/reorder")
|
||
def reorder_exercise_variants(
|
||
exercise_id: int,
|
||
body: ExerciseVariantsReorder,
|
||
tenant: TenantContext = Depends(get_tenant_context),
|
||
):
|
||
profile_id = tenant.profile_id
|
||
|
||
if len(body.variant_ids) != len(set(body.variant_ids)):
|
||
raise HTTPException(status_code=400, detail="variant_ids dürfen keine Duplikate enthalten")
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
_assert_can_edit_exercise(cur, exercise_id, profile_id)
|
||
|
||
cur.execute(
|
||
"SELECT id FROM exercise_variants WHERE exercise_id = %s",
|
||
(exercise_id,),
|
||
)
|
||
existing = [r["id"] for r in cur.fetchall()]
|
||
if set(existing) != set(body.variant_ids):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="variant_ids müssen alle Varianten dieser Übung genau einmal enthalten",
|
||
)
|
||
|
||
for pos, vid in enumerate(body.variant_ids):
|
||
cur.execute(
|
||
"""UPDATE exercise_variants SET sequence_order = %s
|
||
WHERE id = %s AND exercise_id = %s""",
|
||
(pos + 1, vid, exercise_id),
|
||
)
|
||
conn.commit()
|
||
|
||
return {"ok": True, "reordered": len(body.variant_ids)}
|
||
|
||
|
||
@router.post("/exercises/{exercise_id}/variants", status_code=201)
|
||
def create_exercise_variant(
|
||
exercise_id: int,
|
||
body: ExerciseVariantCreate,
|
||
tenant: TenantContext = Depends(get_tenant_context),
|
||
):
|
||
profile_id = tenant.profile_id
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
_assert_can_edit_exercise(cur, exercise_id, profile_id)
|
||
_validate_variant_prerequisite(cur, exercise_id, body.prerequisite_variant_id)
|
||
|
||
eq_json = _variant_equipment_json(body.equipment_changes)
|
||
seq = body.sequence_order
|
||
if seq is None:
|
||
cur.execute(
|
||
"""SELECT COALESCE(MAX(sequence_order), 0) + 1 AS n
|
||
FROM exercise_variants WHERE exercise_id = %s""",
|
||
(exercise_id,),
|
||
)
|
||
seq = cur.fetchone()["n"]
|
||
|
||
desc = (body.description or "").strip() or None
|
||
exec_ch = (body.execution_changes or "").strip() or None
|
||
diff = (body.difficulty_adjustment or "").strip() or None
|
||
|
||
cur.execute(
|
||
"""INSERT INTO exercise_variants (
|
||
exercise_id, variant_name, description, execution_changes,
|
||
duration_min, duration_max, equipment_changes, difficulty_adjustment,
|
||
progression_level, sequence_order, prerequisite_variant_id
|
||
) VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb,%s,%s,%s,%s)
|
||
RETURNING id""",
|
||
(
|
||
exercise_id,
|
||
body.variant_name.strip(),
|
||
desc,
|
||
exec_ch,
|
||
body.duration_min,
|
||
body.duration_max,
|
||
eq_json,
|
||
diff,
|
||
body.progression_level,
|
||
seq,
|
||
body.prerequisite_variant_id,
|
||
),
|
||
)
|
||
new_id = cur.fetchone()["id"]
|
||
row = _fetch_variant_row(cur, exercise_id, new_id)
|
||
conn.commit()
|
||
|
||
return row
|
||
|
||
|
||
@router.put("/exercises/{exercise_id}/variants/{variant_id}")
|
||
def update_exercise_variant(
|
||
exercise_id: int,
|
||
variant_id: int,
|
||
body: ExerciseVariantUpdate,
|
||
tenant: TenantContext = Depends(get_tenant_context),
|
||
):
|
||
profile_id = tenant.profile_id
|
||
|
||
data = body.dict(exclude_unset=True)
|
||
if not data:
|
||
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
_assert_can_edit_exercise(cur, exercise_id, profile_id)
|
||
old = _fetch_variant_row(cur, exercise_id, variant_id)
|
||
|
||
if "variant_name" in data and data["variant_name"] is not None:
|
||
old["variant_name"] = data["variant_name"].strip()
|
||
if "description" in data:
|
||
old["description"] = (data["description"] or "").strip() or None
|
||
if "execution_changes" in data:
|
||
old["execution_changes"] = (data["execution_changes"] or "").strip() or None
|
||
if "duration_min" in data:
|
||
old["duration_min"] = data["duration_min"]
|
||
if "duration_max" in data:
|
||
old["duration_max"] = data["duration_max"]
|
||
if "equipment_changes" in data:
|
||
old["equipment_changes"] = _normalize_variant_equipment_list(data["equipment_changes"])
|
||
if "difficulty_adjustment" in data:
|
||
old["difficulty_adjustment"] = (data["difficulty_adjustment"] or "").strip() or None
|
||
if "progression_level" in data and data["progression_level"] is not None:
|
||
old["progression_level"] = data["progression_level"]
|
||
if "sequence_order" in data:
|
||
old["sequence_order"] = data["sequence_order"]
|
||
if "prerequisite_variant_id" in data:
|
||
old["prerequisite_variant_id"] = data["prerequisite_variant_id"]
|
||
|
||
prereq = old.get("prerequisite_variant_id")
|
||
if prereq == variant_id:
|
||
raise HTTPException(status_code=400, detail="Variante kann nicht ihre eigene Voraussetzung sein")
|
||
_validate_variant_prerequisite(cur, exercise_id, prereq)
|
||
|
||
eq_db = _variant_equipment_json(_normalize_variant_equipment_list(old.get("equipment_changes")))
|
||
|
||
cur.execute(
|
||
"""UPDATE exercise_variants SET
|
||
variant_name = %s,
|
||
description = %s,
|
||
execution_changes = %s,
|
||
duration_min = %s,
|
||
duration_max = %s,
|
||
equipment_changes = %s::jsonb,
|
||
difficulty_adjustment = %s,
|
||
progression_level = %s,
|
||
sequence_order = %s,
|
||
prerequisite_variant_id = %s
|
||
WHERE id = %s AND exercise_id = %s""",
|
||
(
|
||
old["variant_name"],
|
||
old.get("description"),
|
||
old.get("execution_changes"),
|
||
old.get("duration_min"),
|
||
old.get("duration_max"),
|
||
eq_db,
|
||
old.get("difficulty_adjustment"),
|
||
old.get("progression_level"),
|
||
old.get("sequence_order"),
|
||
old.get("prerequisite_variant_id"),
|
||
variant_id,
|
||
exercise_id,
|
||
),
|
||
)
|
||
row = _fetch_variant_row(cur, exercise_id, variant_id)
|
||
conn.commit()
|
||
|
||
return row
|
||
|
||
|
||
@router.delete("/exercises/{exercise_id}/variants/{variant_id}")
|
||
def delete_exercise_variant(
|
||
exercise_id: int,
|
||
variant_id: int,
|
||
tenant: TenantContext = Depends(get_tenant_context),
|
||
):
|
||
profile_id = tenant.profile_id
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
_assert_can_edit_exercise(cur, exercise_id, profile_id)
|
||
_fetch_variant_row(cur, exercise_id, variant_id)
|
||
|
||
cur.execute(
|
||
"SELECT COUNT(*) AS c FROM exercise_variants WHERE prerequisite_variant_id = %s",
|
||
(variant_id,),
|
||
)
|
||
cnt = int(cur.fetchone()["c"])
|
||
if cnt > 0:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="Variante ist Voraussetzung anderer Varianten — zuerst dort ändern oder entfernen",
|
||
)
|
||
|
||
cur.execute(
|
||
"DELETE FROM exercise_variants WHERE id = %s AND exercise_id = %s",
|
||
(variant_id, exercise_id),
|
||
)
|
||
conn.commit()
|
||
|
||
return {"ok": True}
|
||
|
||
|
||
# --- Medien (MEDIA_UPLOAD_SPEC.md / EXERCISES_API_SPEC.md) ---
|
||
|
||
|
||
def _fetch_media_row(cur, exercise_id: int, media_id: int) -> Optional[dict]:
|
||
cur.execute(
|
||
"""SELECT id, exercise_id, media_type, file_path, file_size, mime_type, original_filename,
|
||
embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at
|
||
FROM exercise_media WHERE id = %s AND exercise_id = %s""",
|
||
(media_id, exercise_id),
|
||
)
|
||
row = cur.fetchone()
|
||
return r2d(row) if row else None
|
||
|
||
|
||
@router.post("/exercises/{exercise_id}/media", status_code=201)
|
||
async def upload_exercise_media(
|
||
exercise_id: int,
|
||
tenant: TenantContext = Depends(get_tenant_context),
|
||
file: Optional[UploadFile] = File(None),
|
||
embed_url: Optional[str] = Form(None),
|
||
media_type: str = Form(...),
|
||
title: str = Form(""),
|
||
description: str = Form(""),
|
||
context: str = Form("ablauf"),
|
||
is_primary: bool = Form(False),
|
||
):
|
||
profile_id = tenant.profile_id
|
||
if media_type not in ("image", "video", "document", "sketch"):
|
||
raise HTTPException(status_code=400, detail="Ungültiger media_type")
|
||
if context not in ("ablauf", "detail", "trainer_hint"):
|
||
raise HTTPException(status_code=400, detail="Ungültiger context")
|
||
|
||
emb = (embed_url or "").strip() or None
|
||
has_file = file is not None and bool(file.filename)
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
_assert_can_edit_exercise(cur, exercise_id, profile_id)
|
||
|
||
if _count_exercise_media(cur, exercise_id) >= MAX_EXERCISE_MEDIA:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Maximal {MAX_EXERCISE_MEDIA} Medien pro Übung",
|
||
)
|
||
|
||
if has_file and emb:
|
||
raise HTTPException(status_code=400, detail="Entweder Datei oder embed_url, nicht beides")
|
||
if not has_file and not emb:
|
||
raise HTTPException(status_code=400, detail="Datei oder embed_url erforderlich")
|
||
|
||
sort_sql = (
|
||
"COALESCE((SELECT MAX(sort_order) + 1 FROM exercise_media WHERE exercise_id = %s), 1)"
|
||
)
|
||
|
||
if emb:
|
||
platform = _detect_embed_platform(emb)
|
||
if not platform:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Ungültige Embed-URL (erlaubt: YouTube, Vimeo, Instagram, TikTok)",
|
||
)
|
||
cur.execute(
|
||
f"""INSERT INTO exercise_media (
|
||
exercise_id, media_type, file_path, file_size, mime_type, original_filename,
|
||
embed_url, embed_platform, title, description, context, is_primary, sort_order
|
||
) VALUES (
|
||
%s, %s, NULL, NULL, NULL, NULL, %s, %s, %s, %s, %s, %s, {sort_sql}
|
||
)
|
||
RETURNING id, exercise_id, media_type, file_path, file_size, mime_type, original_filename,
|
||
embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at""",
|
||
(
|
||
exercise_id,
|
||
media_type,
|
||
emb,
|
||
platform,
|
||
title or None,
|
||
description or None,
|
||
context,
|
||
is_primary,
|
||
exercise_id,
|
||
),
|
||
)
|
||
else:
|
||
raw = await file.read()
|
||
max_upload = _upload_limit_bytes(tenant)
|
||
if len(raw) > max_upload:
|
||
raise HTTPException(
|
||
status_code=413,
|
||
detail=f"Datei zu groß (max. {max_upload // (1024 * 1024)} MB)",
|
||
)
|
||
mime = file.content_type or ""
|
||
if mime not in ALLOWED_UPLOAD_MIMES:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Dateityp nicht erlaubt: {mime or 'unbekannt'}",
|
||
)
|
||
ext = Path(file.filename or "").suffix[:12] if file.filename else ""
|
||
if not ext and mime == "image/jpeg":
|
||
ext = ".jpg"
|
||
elif not ext and mime == "image/png":
|
||
ext = ".png"
|
||
digest = hashlib.sha256(raw).hexdigest()[:12]
|
||
fname = f"{digest}_{exercise_id}{ext}"
|
||
dest_dir = _ensure_media_dirs()
|
||
dest_path = dest_dir / fname
|
||
dest_path.write_bytes(raw)
|
||
db_path = f"/media/exercises/{fname}"
|
||
cur.execute(
|
||
f"""INSERT INTO exercise_media (
|
||
exercise_id, media_type, file_path, file_size, mime_type, original_filename,
|
||
embed_url, embed_platform, title, description, context, is_primary, sort_order
|
||
) VALUES (
|
||
%s, %s, %s, %s, %s, %s, NULL, NULL, %s, %s, %s, %s, {sort_sql}
|
||
)
|
||
RETURNING id, exercise_id, media_type, file_path, file_size, mime_type, original_filename,
|
||
embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at""",
|
||
(
|
||
exercise_id,
|
||
media_type,
|
||
db_path,
|
||
len(raw),
|
||
mime,
|
||
file.filename,
|
||
title or None,
|
||
description or None,
|
||
context,
|
||
is_primary,
|
||
exercise_id,
|
||
),
|
||
)
|
||
row = cur.fetchone()
|
||
conn.commit()
|
||
return r2d(row)
|
||
|
||
|
||
@router.put("/exercises/{exercise_id}/media/reorder")
|
||
def reorder_exercise_media(
|
||
exercise_id: int,
|
||
body: ExerciseMediaReorder,
|
||
tenant: TenantContext = Depends(get_tenant_context),
|
||
):
|
||
profile_id = tenant.profile_id
|
||
ids = body.media_ids
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
_assert_can_edit_exercise(cur, exercise_id, profile_id)
|
||
cur.execute(
|
||
"SELECT id FROM exercise_media WHERE exercise_id = %s ORDER BY sort_order, id",
|
||
(exercise_id,),
|
||
)
|
||
existing = [r["id"] if isinstance(r, dict) else r[0] for r in cur.fetchall()]
|
||
if set(ids) != set(existing) or len(ids) != len(existing):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="media_ids unvollständig oder gehören nicht zu dieser Übung",
|
||
)
|
||
for i, mid in enumerate(ids, start=1):
|
||
cur.execute(
|
||
"UPDATE exercise_media SET sort_order = %s, updated_at = NOW() WHERE id = %s AND exercise_id = %s",
|
||
(i, mid, exercise_id),
|
||
)
|
||
conn.commit()
|
||
return {"ok": True, "reordered": len(ids)}
|
||
|
||
|
||
@router.put("/exercises/{exercise_id}/media/{media_id}")
|
||
def update_exercise_media(
|
||
exercise_id: int,
|
||
media_id: int,
|
||
body: ExerciseMediaUpdate,
|
||
tenant: TenantContext = Depends(get_tenant_context),
|
||
):
|
||
profile_id = tenant.profile_id
|
||
data = body.dict(exclude_unset=True)
|
||
if not data:
|
||
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
_assert_can_edit_exercise(cur, exercise_id, profile_id)
|
||
if not _fetch_media_row(cur, exercise_id, media_id):
|
||
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
||
if "context" in data and data["context"] not in ("ablauf", "detail", "trainer_hint", None):
|
||
raise HTTPException(status_code=400, detail="Ungültiger context")
|
||
|
||
fields = []
|
||
params = []
|
||
for k in ("title", "description", "is_primary", "context"):
|
||
if k in data:
|
||
fields.append(f"{k} = %s")
|
||
params.append(data[k])
|
||
if fields:
|
||
fields.append("updated_at = NOW()")
|
||
params.extend([media_id, exercise_id])
|
||
cur.execute(
|
||
f"UPDATE exercise_media SET {', '.join(fields)} WHERE id = %s AND exercise_id = %s",
|
||
params,
|
||
)
|
||
conn.commit()
|
||
cur.execute(
|
||
"""SELECT id, media_type, file_path, file_size, mime_type, original_filename,
|
||
embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at
|
||
FROM exercise_media WHERE id = %s""",
|
||
(media_id,),
|
||
)
|
||
return r2d(cur.fetchone())
|
||
|
||
|
||
@router.delete("/exercises/{exercise_id}/media/{media_id}")
|
||
def delete_exercise_media(
|
||
exercise_id: int,
|
||
media_id: int,
|
||
tenant: TenantContext = Depends(get_tenant_context),
|
||
):
|
||
profile_id = tenant.profile_id
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
_assert_can_edit_exercise(cur, exercise_id, profile_id)
|
||
cur.execute(
|
||
"""SELECT file_path FROM exercise_media WHERE id = %s AND exercise_id = %s""",
|
||
(media_id, exercise_id),
|
||
)
|
||
row = cur.fetchone()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
||
fp = row["file_path"] if isinstance(row, dict) else row[0]
|
||
cur.execute(
|
||
"DELETE FROM exercise_media WHERE id = %s AND exercise_id = %s",
|
||
(media_id, exercise_id),
|
||
)
|
||
conn.commit()
|
||
|
||
abs_p = _abs_media_path(fp) if fp else None
|
||
if abs_p and abs_p.is_file():
|
||
try:
|
||
abs_p.unlink()
|
||
except OSError as e:
|
||
logger.warning("Medien-Datei konnte nicht gelöscht werden: %s", e)
|
||
|
||
return {"ok": True}
|