2026-04-22 16:44:32 +02:00
|
|
|
|
"""
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
Exercises Router - v2.0 (Clean-Room Rebuild)
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
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
|
2026-04-22 16:44:32 +02:00
|
|
|
|
"""
|
2026-04-27 14:27:25 +02:00
|
|
|
|
import hashlib
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
import json
|
|
|
|
|
|
import logging
|
2026-04-27 14:27:25 +02:00
|
|
|
|
import os
|
|
|
|
|
|
from pathlib import Path
|
2026-05-05 22:18:04 +02:00
|
|
|
|
from typing import Any, Dict, List, Optional
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
|
2026-04-27 14:27:25 +02:00
|
|
|
|
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form
|
|
|
|
|
|
from pydantic import BaseModel, Field, model_validator
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
|
|
|
|
|
from db import get_db, get_cursor, r2d
|
2026-05-05 22:11:05 +02:00
|
|
|
|
from club_tenancy import (
|
|
|
|
|
|
assert_valid_governance_visibility,
|
2026-05-06 13:52:24 +02:00
|
|
|
|
club_admin_shares_club_with_creator,
|
|
|
|
|
|
has_club_role,
|
2026-05-05 22:11:05 +02:00
|
|
|
|
is_platform_admin,
|
|
|
|
|
|
library_content_visible_to_profile,
|
|
|
|
|
|
)
|
2026-05-05 21:46:41 +02:00
|
|
|
|
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
2026-04-22 16:44:32 +02:00
|
|
|
|
router = APIRouter(prefix="/api", tags=["exercises"])
|
|
|
|
|
|
|
2026-05-06 12:20:22 +02:00
|
|
|
|
|
|
|
|
|
|
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 []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-27 18:25:23 +02:00
|
|
|
|
# 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)
|
|
|
|
|
|
|
2026-04-27 14:27:25 +02:00
|
|
|
|
MEDIA_ROOT = Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent.parent / "media")))
|
|
|
|
|
|
MAX_EXERCISE_MEDIA = 10
|
2026-04-28 13:56:26 +02:00
|
|
|
|
# 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
|
2026-04-27 14:27:25 +02:00
|
|
|
|
ALLOWED_UPLOAD_MIMES = frozenset(
|
|
|
|
|
|
{"image/jpeg", "image/png", "image/gif", "video/mp4", "application/pdf"}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
2026-05-05 21:51:53 +02:00
|
|
|
|
def _upload_limit_bytes(tenant: TenantContext) -> int:
|
|
|
|
|
|
role = tenant.global_role or ""
|
2026-04-28 13:56:26 +02:00
|
|
|
|
if role in ("admin", "superadmin"):
|
|
|
|
|
|
return MAX_UPLOAD_BYTES_ADMIN
|
|
|
|
|
|
return MAX_UPLOAD_BYTES_USER
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# Pydantic Models
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
class ExerciseCreate(BaseModel):
|
2026-04-27 14:27:25 +02:00
|
|
|
|
# Basis-Felder (goal/execution: DB-Constraint mind. eines; Wiki oft nur eines)
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
title: str = Field(..., min_length=3, max_length=300)
|
|
|
|
|
|
summary: Optional[str] = None
|
2026-04-27 14:27:25 +02:00
|
|
|
|
goal: Optional[str] = Field(None, max_length=5000)
|
|
|
|
|
|
execution: Optional[str] = Field(None, max_length=10000)
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
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] = []
|
2026-04-27 14:48:46 +02:00
|
|
|
|
training_types_multi: list[dict] = []
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-04-27 14:27:25 +02:00
|
|
|
|
@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
|
|
|
|
|
|
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-27 14:27:25 +02:00
|
|
|
|
goal: Optional[str] = Field(None, max_length=5000)
|
|
|
|
|
|
execution: Optional[str] = Field(None, max_length=10000)
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
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
|
2026-04-27 14:48:46 +02:00
|
|
|
|
training_types_multi: Optional[list[dict]] = None
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-04-27 14:27:25 +02:00
|
|
|
|
@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]
|
|
|
|
|
|
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
|
2026-04-28 10:59:09 +02:00
|
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-05 22:18:04 +02:00
|
|
|
|
_VALID_EXERCISE_STATUS_BULK = frozenset({"draft", "in_review", "approved", "archived"})
|
2026-05-06 13:52:24 +02:00
|
|
|
|
_LIST_FILTER_VISIBILITY = frozenset({"private", "club", "official"})
|
|
|
|
|
|
_LIST_FILTER_STATUS = frozenset({"draft", "in_review", "approved", "archived"})
|
2026-05-05 22:18:04 +02:00
|
|
|
|
_MAX_BULK_METADATA_IDS = 500
|
2026-05-06 11:02:46 +02:00
|
|
|
|
_MAX_BULK_RELATION_IDS_PER_KIND = 80
|
2026-05-05 22:18:04 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExerciseBulkMetadataPatch(BaseModel):
|
2026-05-06 11:02:46 +02:00
|
|
|
|
"""Massenänderung: Sichtbarkeit/Status und/oder Zuordnungen (Kataloge)."""
|
2026-05-05 22:18:04 +02:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-05-06 11:02:46 +02:00
|
|
|
|
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)
|
2026-05-05 22:18:04 +02:00
|
|
|
|
|
|
|
|
|
|
@model_validator(mode="after")
|
2026-05-06 11:02:46 +02:00
|
|
|
|
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"
|
|
|
|
|
|
)
|
2026-05-05 22:18:04 +02:00
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# Helper Functions
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
2026-04-27 14:27:25 +02:00
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-28 10:59:09 +02:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-27 14:27:25 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
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
|
2026-04-24 15:25:17 +02:00
|
|
|
|
FROM exercise_style_directions ets
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
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()]
|
|
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
|
# 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()]
|
|
|
|
|
|
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
# 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()]
|
|
|
|
|
|
|
2026-04-24 15:27:11 +02:00
|
|
|
|
# Age Groups (M:N) - direkt als VARCHAR gespeichert
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
cur.execute(
|
2026-04-24 15:27:11 +02:00
|
|
|
|
"""SELECT age_group
|
|
|
|
|
|
FROM exercise_age_groups
|
|
|
|
|
|
WHERE exercise_id = %s
|
|
|
|
|
|
ORDER BY age_group""",
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
(exercise_id,)
|
|
|
|
|
|
)
|
2026-04-24 15:27:11 +02:00
|
|
|
|
exercise["age_groups"] = [r["age_group"] for r in cur.fetchall()]
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
|
|
|
|
|
|
# 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()]
|
2026-04-27 18:25:23 +02:00
|
|
|
|
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"))
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
|
2026-04-28 10:59:09 +02:00
|
|
|
|
# Variants (1:N) - mit Progression (Reihenfolge: sequence_order, dann progression_level)
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""SELECT id, variant_name, description, execution_changes,
|
|
|
|
|
|
duration_min, duration_max, equipment_changes, difficulty_adjustment,
|
2026-04-28 10:59:09 +02:00
|
|
|
|
progression_level, sequence_order, prerequisite_variant_id, created_at
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
FROM exercise_variants
|
|
|
|
|
|
WHERE exercise_id = %s
|
2026-04-28 10:59:09 +02:00
|
|
|
|
ORDER BY sequence_order NULLS LAST, progression_level, id""",
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
(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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-06 11:02:46 +02:00
|
|
|
|
def assign_exercise_relations(
|
|
|
|
|
|
cur,
|
|
|
|
|
|
conn,
|
|
|
|
|
|
exercise_id: int,
|
|
|
|
|
|
data: dict,
|
|
|
|
|
|
*,
|
|
|
|
|
|
do_commit: bool = True,
|
|
|
|
|
|
):
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
"""
|
|
|
|
|
|
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))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
|
# Training Styles (Stilrichtungen, z. B. Shotokan)
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
if "training_styles_multi" in data:
|
2026-04-24 15:25:17 +02:00
|
|
|
|
cur.execute("DELETE FROM exercise_style_directions WHERE exercise_id = %s", (exercise_id,))
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
for ts in data["training_styles_multi"]:
|
|
|
|
|
|
cur.execute(
|
2026-04-24 15:25:17 +02:00
|
|
|
|
"""INSERT INTO exercise_style_directions (exercise_id, style_direction_id, is_primary)
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
VALUES (%s, %s, %s)""",
|
|
|
|
|
|
(exercise_id, ts["training_style_id"], ts.get("is_primary", False))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
|
# 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)),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
# 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))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-24 15:27:11 +02:00
|
|
|
|
# Age Groups (direkt als VARCHAR, CHECK constraint validiert)
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
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"]:
|
2026-04-24 15:27:11 +02:00
|
|
|
|
try:
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
cur.execute(
|
2026-04-24 15:27:11 +02:00
|
|
|
|
"INSERT INTO exercise_age_groups (exercise_id, age_group) VALUES (%s, %s)",
|
|
|
|
|
|
(exercise_id, age_group_name)
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
)
|
2026-04-24 15:27:11 +02:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning("Age Group '%s' ungültig: %s", age_group_name, e)
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
|
|
|
|
|
|
# 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"),
|
2026-04-27 18:25:23 +02:00
|
|
|
|
normalize_exercise_skill_level(skill.get("required_level")),
|
|
|
|
|
|
normalize_exercise_skill_level(skill.get("target_level")),
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
skill.get("ai_suggested", False),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-06 11:02:46 +02:00
|
|
|
|
if do_commit:
|
|
|
|
|
|
conn.commit()
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# Endpoints
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
2026-05-06 11:02:46 +02:00
|
|
|
|
|
|
|
|
|
|
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]}",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-28 08:02:08 +02:00
|
|
|
|
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 _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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-06 13:52:24 +02:00
|
|
|
|
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.",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-05 22:18:04 +02:00
|
|
|
|
@router.patch("/exercises/bulk-metadata")
|
|
|
|
|
|
def bulk_patch_exercises_metadata(
|
|
|
|
|
|
body: ExerciseBulkMetadataPatch,
|
|
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
2026-05-06 11:02:46 +02:00
|
|
|
|
Ä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).
|
|
|
|
|
|
|
2026-05-05 22:18:04 +02:00
|
|
|
|
Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin).
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-06 11:02:46 +02:00
|
|
|
|
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)
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-05 22:18:04 +02:00
|
|
|
|
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)
|
2026-05-06 11:02:46 +02:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-05-05 22:18:04 +02:00
|
|
|
|
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)
|
|
|
|
|
|
if owner != profile_id and not is_platform_admin(role):
|
|
|
|
|
|
failed.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": ex_id,
|
|
|
|
|
|
"detail": "Keine Berechtigung (nur Ersteller oder Plattform-Admin)",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
|
if next_vis == "club":
|
|
|
|
|
|
if next_club is None:
|
|
|
|
|
|
next_club = tenant.effective_club_id
|
|
|
|
|
|
if 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
|
|
|
|
|
|
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),
|
|
|
|
|
|
)
|
2026-05-06 11:02:46 +02:00
|
|
|
|
if relation_data:
|
|
|
|
|
|
assign_exercise_relations(cur, conn, ex_id, relation_data, do_commit=False)
|
2026-05-05 22:18:04 +02:00
|
|
|
|
updated.append(ex_id)
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"updated": updated,
|
|
|
|
|
|
"failed": failed,
|
|
|
|
|
|
"updated_count": len(updated),
|
|
|
|
|
|
"failed_count": len(failed),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-22 16:44:32 +02:00
|
|
|
|
@router.get("/exercises")
|
|
|
|
|
|
def list_exercises(
|
2026-04-28 08:02:08 +02:00
|
|
|
|
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)"),
|
2026-04-27 18:25:23 +02:00
|
|
|
|
skill_min_level: Optional[int] = Query(default=None, ge=1, le=5),
|
|
|
|
|
|
skill_max_level: Optional[int] = Query(default=None, ge=1, le=5),
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
search: Optional[str] = Query(default=None),
|
2026-04-27 18:25:23 +02:00
|
|
|
|
ai_search: Optional[str] = Query(
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
description="Platzhalter KI-Suche: derzeit gleiche Volltextlogik wie search (später Embeddings/Reranking)",
|
|
|
|
|
|
),
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
limit: int = Query(default=50, ge=1, le=100),
|
|
|
|
|
|
offset: int = Query(default=0, ge=0),
|
2026-04-28 09:30:33 +02:00
|
|
|
|
include_variants: bool = Query(
|
|
|
|
|
|
default=False,
|
|
|
|
|
|
description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI",
|
|
|
|
|
|
),
|
2026-05-06 13:52:24 +02:00
|
|
|
|
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",
|
|
|
|
|
|
),
|
|
|
|
|
|
include_archived: bool = Query(
|
|
|
|
|
|
default=False,
|
|
|
|
|
|
description="Archivierte einbeziehen; Standard false (außer Statusfilter enthält archived)",
|
|
|
|
|
|
),
|
2026-05-05 21:46:41 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-04-22 16:44:32 +02:00
|
|
|
|
):
|
|
|
|
|
|
"""
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
Liste aller Übungen mit Filtern.
|
|
|
|
|
|
Lightweight Response (ohne M:N Details, nur IDs und Namen).
|
2026-04-28 09:30:33 +02:00
|
|
|
|
Optional include_variants für Variantenauswahl in der Trainingsplanung.
|
2026-04-22 16:44:32 +02:00
|
|
|
|
"""
|
2026-05-05 21:46:41 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
# WHERE-Bedingungen
|
|
|
|
|
|
where = ["1=1"]
|
2026-04-22 16:44:32 +02:00
|
|
|
|
params = []
|
|
|
|
|
|
|
2026-05-05 21:46:41 +02:00
|
|
|
|
role = tenant.global_role
|
2026-05-05 16:12:17 +02:00
|
|
|
|
if not is_platform_admin(role):
|
2026-05-05 21:46:41 +02:00
|
|
|
|
vis_sql, vis_params = library_content_visibility_sql(
|
|
|
|
|
|
alias="e",
|
|
|
|
|
|
profile_id=profile_id,
|
|
|
|
|
|
role=role,
|
|
|
|
|
|
effective_club_id=tenant.effective_club_id,
|
2026-05-05 16:12:17 +02:00
|
|
|
|
)
|
2026-05-05 21:46:41 +02:00
|
|
|
|
where.append(vis_sql)
|
|
|
|
|
|
params.extend(vis_params)
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
2026-04-28 08:02:08 +02:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-05-06 13:52:24 +02:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
if exclude_without_focus:
|
|
|
|
|
|
where.append(
|
|
|
|
|
|
"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-28 08:02:08 +02:00
|
|
|
|
fa_ids = _merge_ids(focus_area_ids, focus_area)
|
|
|
|
|
|
if fa_ids:
|
|
|
|
|
|
ph = ",".join(["%s"] * len(fa_ids))
|
|
|
|
|
|
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_ids)
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
|
2026-04-28 08:02:08 +02:00
|
|
|
|
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)
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
2026-04-28 08:02:08 +02:00
|
|
|
|
sd_ids = _merge_ids(style_direction_ids, style_direction_id)
|
|
|
|
|
|
if sd_ids:
|
|
|
|
|
|
ph = ",".join(["%s"] * len(sd_ids))
|
2026-04-27 18:25:23 +02:00
|
|
|
|
where.append(
|
|
|
|
|
|
"EXISTS (SELECT 1 FROM exercise_style_directions esd "
|
2026-04-28 08:02:08 +02:00
|
|
|
|
f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))"
|
2026-04-27 18:25:23 +02:00
|
|
|
|
)
|
2026-04-28 08:02:08 +02:00
|
|
|
|
params.extend(sd_ids)
|
2026-04-27 18:25:23 +02:00
|
|
|
|
|
2026-04-28 08:02:08 +02:00
|
|
|
|
tt_ids = _merge_ids(training_type_ids, training_type_id)
|
|
|
|
|
|
if tt_ids:
|
|
|
|
|
|
ph = ",".join(["%s"] * len(tt_ids))
|
2026-04-27 18:25:23 +02:00
|
|
|
|
where.append(
|
|
|
|
|
|
"EXISTS (SELECT 1 FROM exercise_training_types ett "
|
2026-04-28 08:02:08 +02:00
|
|
|
|
f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))"
|
2026-04-27 18:25:23 +02:00
|
|
|
|
)
|
2026-04-28 08:02:08 +02:00
|
|
|
|
params.extend(tt_ids)
|
2026-04-27 18:25:23 +02:00
|
|
|
|
|
2026-04-28 08:02:08 +02:00
|
|
|
|
tg_ids = _merge_ids(target_group_ids, target_group_id)
|
|
|
|
|
|
if tg_ids:
|
|
|
|
|
|
ph = ",".join(["%s"] * len(tg_ids))
|
2026-04-27 18:25:23 +02:00
|
|
|
|
where.append(
|
|
|
|
|
|
"EXISTS (SELECT 1 FROM exercise_target_groups etg "
|
2026-04-28 08:02:08 +02:00
|
|
|
|
f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))"
|
2026-04-27 18:25:23 +02:00
|
|
|
|
)
|
2026-04-28 08:02:08 +02:00
|
|
|
|
params.extend(tg_ids)
|
2026-04-27 18:25:23 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
|
2026-04-27 18:25:23 +02:00
|
|
|
|
params.append(qtext)
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
|
2026-04-28 09:30:33 +02:00
|
|
|
|
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
|
|
|
|
|
|
)
|
2026-04-28 10:59:09 +02:00
|
|
|
|
ORDER BY ev.sequence_order NULLS LAST, ev.progression_level, ev.id
|
2026-04-28 09:30:33 +02:00
|
|
|
|
),
|
|
|
|
|
|
'[]'::json
|
|
|
|
|
|
)
|
|
|
|
|
|
FROM exercise_variants ev
|
|
|
|
|
|
WHERE ev.exercise_id = e.id
|
|
|
|
|
|
) AS variants"""
|
|
|
|
|
|
|
2026-04-27 14:27:25 +02:00
|
|
|
|
# Query (primary_focus_name für Listen-Ansicht gemäß Spec-Beispiel „focus_area“-Label)
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
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,
|
2026-04-27 14:27:25 +02:00
|
|
|
|
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
|
2026-05-06 12:20:22 +02:00
|
|
|
|
) 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
|
2026-04-28 09:30:33 +02:00
|
|
|
|
{variants_sql}
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
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])
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
|
|
|
|
|
cur.execute(query, params)
|
|
|
|
|
|
rows = cur.fetchall()
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
|
2026-04-27 14:27:25 +02:00
|
|
|
|
out = []
|
|
|
|
|
|
for r in rows:
|
|
|
|
|
|
d = r2d(r)
|
|
|
|
|
|
pfn = d.get("primary_focus_name")
|
|
|
|
|
|
d["focus_area"] = pfn
|
2026-05-06 12:20:22 +02:00
|
|
|
|
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"))
|
2026-04-28 09:30:33 +02:00
|
|
|
|
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"] = []
|
2026-04-27 14:27:25 +02:00
|
|
|
|
out.append(d)
|
|
|
|
|
|
return out
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/exercises/{exercise_id}")
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
def get_exercise(
|
|
|
|
|
|
exercise_id: int,
|
2026-05-05 21:51:53 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Exercise Detail mit allen M:N Relations (vollständig enriched).
|
|
|
|
|
|
"""
|
2026-05-05 21:51:53 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
exercise = enrich_exercise_detail(exercise_id, cur)
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
2026-05-05 16:12:17 +02:00
|
|
|
|
if not exercise:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
|
|
|
|
|
|
2026-05-05 22:11:05 +02:00
|
|
|
|
if not library_content_visible_to_profile(
|
2026-05-05 16:12:17 +02:00
|
|
|
|
cur,
|
|
|
|
|
|
profile_id,
|
|
|
|
|
|
exercise["visibility"],
|
|
|
|
|
|
exercise.get("club_id"),
|
|
|
|
|
|
exercise.get("created_by"),
|
2026-05-05 21:51:53 +02:00
|
|
|
|
tenant.global_role,
|
2026-05-05 16:12:17 +02:00
|
|
|
|
):
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Übung")
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
|
|
|
|
|
|
return exercise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/exercises", status_code=201)
|
|
|
|
|
|
def create_exercise(
|
|
|
|
|
|
body: ExerciseCreate,
|
2026-05-05 21:46:41 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Erstellt eine neue Übung mit allen M:N Relations.
|
|
|
|
|
|
"""
|
2026-05-05 21:46:41 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
|
|
|
|
|
|
# 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")
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
2026-05-05 21:46:41 +02:00
|
|
|
|
club_id = body.club_id
|
|
|
|
|
|
if body.visibility == "club" and club_id is None:
|
|
|
|
|
|
club_id = tenant.effective_club_id
|
|
|
|
|
|
|
2026-04-22 16:44:32 +02:00
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
2026-05-05 21:46:41 +02:00
|
|
|
|
assert_valid_governance_visibility(
|
|
|
|
|
|
cur, profile_id, tenant.global_role, body.visibility, club_id
|
|
|
|
|
|
)
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
# 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,
|
2026-05-05 21:46:41 +02:00
|
|
|
|
body.visibility, body.status, profile_id, club_id,
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-04-24 15:23:00 +02:00
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
exercise_id = row['id'] if isinstance(row, dict) else row[0]
|
2026-04-22 16:44:32 +02:00
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
# 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
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/exercises/{exercise_id}")
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
def update_exercise(
|
|
|
|
|
|
exercise_id: int,
|
|
|
|
|
|
body: ExerciseUpdate,
|
2026-05-05 21:51:53 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Aktualisiert eine Übung (Partial Update).
|
|
|
|
|
|
Nur Owner darf editieren.
|
|
|
|
|
|
"""
|
2026-05-05 21:51:53 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
|
2026-05-05 21:57:55 +02:00
|
|
|
|
cur.execute(
|
|
|
|
|
|
"SELECT created_by, visibility, club_id FROM exercises WHERE id = %s",
|
|
|
|
|
|
(exercise_id,),
|
|
|
|
|
|
)
|
2026-04-22 16:44:32 +02:00
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
if not row:
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
2026-04-23 08:51:45 +02:00
|
|
|
|
|
2026-04-27 14:27:25 +02:00
|
|
|
|
if _row_created_by(row) != profile_id:
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
raise HTTPException(status_code=403, detail="Nur der Ersteller darf editieren")
|
|
|
|
|
|
|
2026-05-05 21:57:55 +02:00
|
|
|
|
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)
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
|
|
|
|
|
|
data = body.dict(exclude_unset=True)
|
|
|
|
|
|
|
2026-05-05 21:57:55 +02:00
|
|
|
|
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 = []
|
|
|
|
|
|
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
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)
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
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
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/exercises/{exercise_id}")
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
def delete_exercise(
|
|
|
|
|
|
exercise_id: int,
|
2026-05-05 21:51:53 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Löscht eine Übung.
|
2026-04-22 16:44:32 +02:00
|
|
|
|
|
2026-05-06 13:52:24 +02:00
|
|
|
|
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.
|
|
|
|
|
|
"""
|
2026-04-22 16:44:32 +02:00
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
|
2026-05-06 13:52:24 +02:00
|
|
|
|
cur.execute(
|
|
|
|
|
|
"SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s",
|
|
|
|
|
|
(exercise_id,),
|
|
|
|
|
|
)
|
2026-04-22 16:44:32 +02:00
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
if not row:
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
2026-05-06 13:52:24 +02:00
|
|
|
|
ex = r2d(row)
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
|
2026-05-06 13:52:24 +02:00
|
|
|
|
_assert_can_delete_exercise(cur, tenant, ex)
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
|
2026-05-06 13:52:24 +02:00
|
|
|
|
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)
|
feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
2026-04-24 15:04:27 +02:00
|
|
|
|
|
2026-04-22 16:44:32 +02:00
|
|
|
|
cur.execute("DELETE FROM exercises WHERE id = %s", (exercise_id,))
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
return {"ok": True}
|
2026-04-27 14:27:25 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-28 10:59:09 +02:00
|
|
|
|
# --- Übungsvarianten (EXERCISES_API_SPEC.md) ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/exercises/{exercise_id}/variants/reorder")
|
|
|
|
|
|
def reorder_exercise_variants(
|
|
|
|
|
|
exercise_id: int,
|
|
|
|
|
|
body: ExerciseVariantsReorder,
|
2026-05-05 21:51:53 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-04-28 10:59:09 +02:00
|
|
|
|
):
|
2026-05-05 21:51:53 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
2026-04-28 10:59:09 +02:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-05 21:51:53 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-04-28 10:59:09 +02:00
|
|
|
|
):
|
2026-05-05 21:51:53 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
2026-04-28 10:59:09 +02:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-05 21:51:53 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-04-28 10:59:09 +02:00
|
|
|
|
):
|
2026-05-05 21:51:53 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
2026-04-28 10:59:09 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-05 21:51:53 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/exercises/{exercise_id}/variants/{variant_id}")
|
2026-04-28 10:59:09 +02:00
|
|
|
|
def delete_exercise_variant(
|
|
|
|
|
|
exercise_id: int,
|
|
|
|
|
|
variant_id: int,
|
2026-05-05 21:51:53 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-04-28 10:59:09 +02:00
|
|
|
|
):
|
2026-05-05 21:51:53 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
2026-04-28 10:59:09 +02:00
|
|
|
|
|
|
|
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-27 14:27:25 +02:00
|
|
|
|
# --- 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,
|
2026-05-05 21:51:53 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-04-27 14:27:25 +02:00
|
|
|
|
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),
|
|
|
|
|
|
):
|
2026-05-05 21:51:53 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
2026-04-27 14:27:25 +02:00
|
|
|
|
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()
|
2026-05-05 21:51:53 +02:00
|
|
|
|
max_upload = _upload_limit_bytes(tenant)
|
2026-04-28 13:56:26 +02:00
|
|
|
|
if len(raw) > max_upload:
|
2026-04-27 14:27:25 +02:00
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=413,
|
2026-04-28 13:56:26 +02:00
|
|
|
|
detail=f"Datei zu groß (max. {max_upload // (1024 * 1024)} MB)",
|
2026-04-27 14:27:25 +02:00
|
|
|
|
)
|
|
|
|
|
|
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,
|
2026-05-05 21:51:53 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-04-27 14:27:25 +02:00
|
|
|
|
):
|
2026-05-05 21:51:53 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
2026-04-27 14:27:25 +02:00
|
|
|
|
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,
|
2026-05-05 21:51:53 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-04-27 14:27:25 +02:00
|
|
|
|
):
|
2026-05-05 21:51:53 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
2026-04-27 14:27:25 +02:00
|
|
|
|
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,
|
2026-05-05 21:51:53 +02:00
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
2026-04-27 14:27:25 +02:00
|
|
|
|
):
|
2026-05-05 21:51:53 +02:00
|
|
|
|
profile_id = tenant.profile_id
|
2026-04-27 14:27:25 +02:00
|
|
|
|
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}
|