feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m54s

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
This commit is contained in:
Lars 2026-04-24 15:04:27 +02:00
parent 6801c60604
commit 01ed5509f8
6 changed files with 1090 additions and 364 deletions

View File

@ -0,0 +1,391 @@
# SMW-Importer Gap-Analyse & Umsetzungsplanung
**Datum:** 2026-04-24
**Status:** Requirement Analysis Complete
**Nächste Schritte:** User-Approval → Implementation
---
## 1. Executive Summary
**Ziel:** SMW-Importer fertigstellen inkl. Frontend, saubere Datenbank, Issue #53 Compliance
**Aktueller Status:**
- ✅ Backend komplett (smw_client.py, smw_mapper.py, import_wiki.py)
- ✅ Migration 018 (wiki_import_tracking) deployed
- ✅ Issue #53 konform (keine fachliche Interpretation im Import)
- ✅ Duplikats-Handling via wiki_import_references
- ❌ Migrations 014-017 fehlen (Blocker für saubere DB)
- ❌ exercises.py Router nutzt Legacy-Felder (Inkonsistenz)
- ❌ Frontend für Importer fehlt komplett
**Kritischer Pfad:**
1. Migrations 014-017 anlegen (DB-Konsistenz)
2. exercises.py auf M:N umstellen (Legacy-Cleanup)
3. Frontend für Importer (Admin-UI)
---
## 2. Issue #53 Compliance Check (Mitai → Shinkan)
### 2.1 Was ist Issue #53?
**Quelle:** `c:\Dev\mitai-jinkendo\docs\issues\issue-53-phase-0c-multi-layer-architecture.md`
**Kern-Prinzip:**
```
┌────────────────────────────────────────────────┐
│ Layer 1: DATA LAYER │
│ - Pure data retrieval + calculation logic │
│ - Returns: Structured data (dict/list/float) │
│ - No formatting, no strings │
└──────────────────┬─────────────────────────────┘
┌───────────┴──────────┐
│ │
▼ ▼
┌──────────────┐ ┌─────────────────────┐
│ Layer 2a: │ │ Layer 2b: │
│ KI LAYER │ │ VISUALIZATION LAYER │
└──────────────┘ └─────────────────────┘
```
**Import-Regeln (ARCHITECTURE.md §8):**
- ✅ **Erlaubt:** Typ-/Einheits-Konvertierung, Zuordnung CSV→Feld, Duplikat-Logik
- ❌ **Verboten:** Fachliche Interpretation, Aggregation von "Bedeutung", Metriken für Auswertung
### 2.2 Shinkan SMW-Importer Compliance
**✅ KONFORM:**
```python
# smw_mapper.py - Nur Feld-Zuordnung
EXERCISE_PROPERTY_MAP = {
"Übungsbezeichnung": "title_override",
"Ziel": "goal",
"Durchführung": "execution",
"Plandauer": "duration_raw", # String → Int Konvertierung
"Übungstyp": "focus_area_names", # Name → ID Lookup
}
# Typ-Konvertierung (erlaubt)
duration_min = safe_int(raw_value.get("Plandauer"))
# Keine fachliche Interpretation ✅
# Keine Metriken-Berechnung ✅
```
**✅ Duplikats-Handling:**
```python
# import_wiki.py - reimport-safe
if not reimport:
cur.execute(
"SELECT id FROM wiki_import_references WHERE wiki_page_title = %s",
(page_title, import_type)
)
if cur.fetchone():
stats["skipped"] += 1
continue
```
**✅ M:N Katalog-Zuordnung:**
```python
# _assign_exercise_catalogs() - lookup only, keine Berechnung
cur.execute("SELECT id FROM focus_areas WHERE name ILIKE %s", (name,))
if row:
cur.execute(
"INSERT INTO exercise_focus_areas (exercise_id, focus_area_id, is_primary) ..."
)
```
**Fazit:** SMW-Importer ist Issue #53 konform. KEIN Refactoring nötig.
---
## 3. Datenbank-Inkonsistenz: Legacy vs. M:N
### 3.1 Problem
**Migration 005** (deployed) definiert Legacy-Felder:
```sql
CREATE TABLE exercises (
...
age_groups JSONB, -- ["minis", "kinder", "erwachsene"]
focus_area VARCHAR(100), -- karate, selbstverteidigung, gewaltschutz
secondary_areas JSONB,
...
);
```
**Migration 008** (deployed) definiert M:N Tabellen:
```sql
CREATE TABLE exercise_focus_areas (...);
CREATE TABLE exercise_age_groups (...);
CREATE TABLE exercise_target_groups (...);
```
**exercises.py Router** (aktuell) nutzt BEIDE:
```python
# Legacy beim CREATE
data.get('age_groups'), # JSONB
data.get('focus_area'), # VARCHAR
# M:N beim GET
SELECT fa.name FROM exercise_focus_areas efa
JOIN focus_areas fa ON efa.focus_area_id = fa.id
```
**Migration 014** (geplant) würde Legacy-Felder droppen:
```sql
ALTER TABLE exercises DROP COLUMN IF EXISTS age_groups;
ALTER TABLE exercises DROP COLUMN IF EXISTS focus_area;
ALTER TABLE exercises DROP COLUMN IF EXISTS secondary_areas;
```
### 3.2 Auswirkung
**Wenn Migration 014 jetzt ausgeführt wird:**
- ✅ SMW-Importer funktioniert (nutzt nur M:N)
- ❌ exercises.py CREATE bricht (INSERT referenziert gedropte Spalten)
- ❌ exercises.py GET funktioniert (nutzt M:N)
**Lösung:** exercises.py auf M:N umstellen BEVOR Migration 014
---
## 4. Fehlende Migrations 014-017
### 4.1 Inhalt
**Migration 014: Variant Progression + Search + Legacy Cleanup**
- Variant Progression (progression_level, sequence_order, prerequisite_variant_id)
- Search Vector (tsvector für Volltext-Suche)
- Legacy DROP (age_groups, focus_area, secondary_areas)
**Migration 015: Semantic Matching (OPTIONAL - Phase 2)**
- exercise_embeddings Tabelle für Vector Search
**Migration 016: Saved Searches**
- saved_exercise_searches Tabelle
**Migration 017: Exercise Blocks**
- exercise_blocks + exercise_block_items
- Template Blocks (is_template, is_placeholder, placeholder_criteria)
### 4.2 SQL-Dateien
**Status:** Spec-Inhalt existiert in `EXERCISES_DATABASE_FINAL.md`, muss als .sql extrahiert werden
**Action Items:**
- [ ] 014_variant_progression_search_legacy.sql anlegen
- [ ] 015_semantic_matching.sql anlegen (OPTIONAL)
- [ ] 016_saved_searches.sql anlegen
- [ ] 017_exercise_blocks.sql anlegen
---
## 5. Frontend für SMW-Importer
### 5.1 Fehlende Komponenten
**Admin-Seite:**
- `frontend/src/pages/MediaWikiImportPage.jsx` (NEU)
**API-Funktionen:**
- `frontend/src/utils/api.js` - Wiki-Import Funktionen ergänzen
**Navigation:**
- Admin-Navigation erweitern (Link zu Import-Seite)
### 5.2 UI-Requirements
**3-Tab Layout:**
1. **Preview Tab**
- Kategorie-Auswahl (Übungen, Fähigkeiten, Methoden)
- Limit-Auswahl (10, 50, 100)
- Preview-Button → zeigt Tabelle mit:
- Wiki-Seitentitel
- Already Imported (Ja/Nein)
- Last Imported (Datum)
- Mapped Fields (Accordion)
- Warnings/Errors
2. **Execute Tab**
- Import-Typ (Exercise, Skill, Method)
- Kategorie-Input
- Reimport-Existing (Checkbox)
- Dry-Run (Checkbox)
- Limit (Optional)
- Execute-Button → startet Background-Task
- Status-Display (Running, Completed, Failed)
- Progress (Imported/Skipped/Failed Counts)
3. **History Tab**
- Liste aller Import-Logs (neueste zuerst)
- Spalten: Type, Category, Status, Items (Total/Imported/Skipped/Failed), Date
- Expandable Row für Error-Details
**API-Endpoints (bereits vorhanden):**
- `GET /api/import/mediawiki/preview?category=Übungen&import_type=exercise&limit=10`
- `POST /api/import/mediawiki/execute` (Body: category, import_type, reimport, dry_run, limit)
- `GET /api/import/mediawiki/status/{log_id}`
- `GET /api/import/mediawiki/logs`
- `DELETE /api/import/mediawiki/references/{ref_id}` (Force Reimport)
---
## 6. Umsetzungsplan (priorisiert)
### Phase 1: Datenbank-Konsistenz (BLOCKER)
**1.1 Migrations anlegen**
- [ ] Migration 014 als .sql (Variant + Search + **Legacy DROP**)
- [ ] Migration 015 als .sql (Semantic Matching) - OPTIONAL, kann warten
- [ ] Migration 016 als .sql (Saved Searches)
- [ ] Migration 017 als .sql (Exercise Blocks)
**Aufwand:** 2-3h (SQL aus Spec extrahieren, testen)
**1.2 exercises.py Router überarbeiten**
- [ ] CREATE: Legacy-Felder entfernen, nur M:N nutzen
- [ ] UPDATE: Legacy-Felder entfernen
- [ ] Query-Filter: focus_area String-Filter durch M:N-Join ersetzen
**Aufwand:** 1-2h
**1.3 Deployment**
- [ ] Migrations 014-017 deployen (dev → prod)
- [ ] exercises.py deployen
- [ ] Testen: SMW-Import funktioniert
**Aufwand:** 1h
**Total Phase 1:** 4-6h
---
### Phase 2: SMW-Importer Frontend
**2.1 API-Funktionen**
- [ ] `api.js` erweitern:
- `previewMediaWikiImport(category, importType, limit)`
- `executeMediaWikiImport(category, importType, reimport, dryRun, limit)`
- `getMediaWikiImportStatus(logId)`
- `listMediaWikiImportLogs()`
- `deleteMediaWikiImportReference(refId)`
**Aufwand:** 0.5h
**2.2 MediaWikiImportPage.jsx**
- [ ] 3-Tab Layout (Preview, Execute, History)
- [ ] Preview: Kategorie-Auswahl, Preview-Tabelle mit Accordions
- [ ] Execute: Form mit Status-Polling
- [ ] History: Tabelle mit Expandable Rows
**Aufwand:** 3-4h
**2.3 Navigation**
- [ ] Admin-Navigation erweitern (Link zu Import-Seite)
**Aufwand:** 0.5h
**Total Phase 2:** 4-5h
---
### Phase 3: Testing & Refinement
**3.1 Integration Testing**
- [ ] Dev-System: Import von BINGO + Speed Jab Drills
- [ ] Prüfen: Alle M:N Tabellen korrekt befüllt
- [ ] Prüfen: Reimport überschreibt korrekt
- [ ] Prüfen: Duplikats-Handling funktioniert
**Aufwand:** 1-2h
**3.2 Prod Deployment**
- [ ] SMW-Importer deployen (dev → prod)
- [ ] Erste echte Imports durchführen
**Aufwand:** 1h
**Total Phase 3:** 2-3h
---
## 7. Gesamt-Aufwand
| Phase | Tasks | Aufwand |
|-------|-------|---------|
| Phase 1: DB-Konsistenz | Migrations + Router | 4-6h |
| Phase 2: Frontend | UI + API | 4-5h |
| Phase 3: Testing | Integration + Deploy | 2-3h |
| **TOTAL** | | **10-14h** |
**Empfehlung:** Phase 1 zuerst (kritisch für DB-Konsistenz), dann Phase 2+3
---
## 8. Offene Fragen
**Q1:** Sollen wir Migration 015 (Semantic Matching) jetzt anlegen oder später?
- **Pro:** Komplett-System
- **Con:** OPTIONAL, braucht Vector-Embeddings (nicht kritisch)
- **Empfehlung:** SKIPPEN, erst bei Bedarf
**Q2:** Soll exercises.py komplett refactored werden oder nur Legacy-Felder entfernen?
- **Empfehlung:** Nur Legacy-Felder entfernen (minimal invasiv)
**Q3:** Data Layer in Shinkan anlegen (wie Mitai)?
- **Status:** Shinkan hat KEIN `data_layer/` Verzeichnis
- **Empfehlung:** SPÄTER, nicht Blocker für Importer
---
## 9. Issue #53 Learnings für Shinkan
**Aus Mitai übernehmen:**
1. ✅ Import darf nur Feld-Zuordnung + Typ-Konvertierung (bereits konform)
2. ✅ Keine fachliche Interpretation im Import (bereits konform)
3. ⏸️ Data Layer für Metriken-Berechnung (SPÄTER, separate Task)
**NICHT übernehmen:**
- ❌ Komplett-Refactoring jetzt (zu großer Scope)
- ❌ Chart-Endpoints jetzt (kein Bedarf)
**Fazit:** Shinkan Import ist bereits Issue #53 konform. Kein Action-Item.
---
## 10. Nächste Schritte (User-Approval erforderlich)
**Option A: Komplett-Durchlauf (empfohlen)**
1. Migrations 014-017 anlegen
2. exercises.py auf M:N umstellen
3. Frontend für Importer bauen
4. Testen + Deployen
**Aufwand:** 10-14h
**Option B: Minimaler Pfad (schnell, aber unvollständig)**
1. Nur Migration 014 anlegen (Legacy DROP)
2. exercises.py minimal fixen
3. Frontend SPÄTER
**Aufwand:** 4-6h, aber Frontend fehlt
**Option C: User entscheidet Priorität**
- Welche Phase zuerst?
- Migration 015 (Semantic Matching) jetzt oder später?
- exercises.py komplett refactorn oder minimal?
**Empfehlung:** Option A (Komplett-Durchlauf), weil:
- Saubere Datenbank (keine Legacy-Felder)
- Vollständiger Importer (Backend + Frontend)
- Issue #53 konform
- Basis für zukünftige Features
---
**Autor:** Claude Code
**Version:** 1.0
**Status:** Pending User-Approval

View File

@ -0,0 +1,115 @@
-- Migration 014: Variant Progression System + Search Vector + Legacy Cleanup
-- Autor: Claude Code
-- Datum: 2026-04-24
-- Zweck: Varianten-Progression, Volltext-Suche, Legacy-Spalten entfernen
DO $$
BEGIN
-- ============================================================================
-- HELPER FUNCTION: update_timestamp (für Triggers)
-- ============================================================================
CREATE OR REPLACE FUNCTION update_timestamp()
RETURNS trigger AS $func$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$func$ LANGUAGE plpgsql;
-- ============================================================================
-- VARIANT PROGRESSION
-- ============================================================================
-- Erweitere exercise_variants Tabelle
ALTER TABLE exercise_variants
ADD COLUMN IF NOT EXISTS progression_level INT DEFAULT 1 CHECK (progression_level BETWEEN 1 AND 10),
ADD COLUMN IF NOT EXISTS sequence_order INT,
ADD COLUMN IF NOT EXISTS prerequisite_variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL;
-- Index für Prerequisites
CREATE INDEX IF NOT EXISTS idx_exercise_variants_prerequisite
ON exercise_variants(prerequisite_variant_id);
-- ============================================================================
-- SEARCH VECTOR (Volltext-Suche)
-- ============================================================================
-- Füge search_vector zu exercises hinzu
ALTER TABLE exercises
ADD COLUMN IF NOT EXISTS search_vector tsvector;
-- Index für Volltext-Suche
CREATE INDEX IF NOT EXISTS idx_exercises_search
ON exercises USING gin(search_vector);
-- Funktion für automatisches Update
CREATE OR REPLACE FUNCTION update_exercises_search_vector()
RETURNS trigger AS $func$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('german', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('german', COALESCE(NEW.summary, '')), 'B') ||
setweight(to_tsvector('german', COALESCE(NEW.execution, '')), 'C') ||
setweight(to_tsvector('german', COALESCE(NEW.trainer_notes, '')), 'D');
RETURN NEW;
END;
$func$ LANGUAGE plpgsql;
-- Trigger
DROP TRIGGER IF EXISTS exercises_search_update ON exercises;
CREATE TRIGGER exercises_search_update
BEFORE INSERT OR UPDATE ON exercises
FOR EACH ROW EXECUTE FUNCTION update_exercises_search_vector();
-- Initiales Befüllen (für existierende Zeilen)
UPDATE exercises SET search_vector = (
setweight(to_tsvector('german', COALESCE(title, '')), 'A') ||
setweight(to_tsvector('german', COALESCE(summary, '')), 'B') ||
setweight(to_tsvector('german', COALESCE(execution, '')), 'C') ||
setweight(to_tsvector('german', COALESCE(trainer_notes, '')), 'D')
) WHERE search_vector IS NULL;
-- ============================================================================
-- LEGACY COLUMN CLEANUP
-- Deprecated Felder aus exercises (ersetzt durch M:N Tabellen in Migration 008+)
-- ============================================================================
-- age_groups JSONB → ersetzt durch exercise_age_groups M:N (seit Migration 008)
ALTER TABLE exercises DROP COLUMN IF EXISTS age_groups;
-- focus_area VARCHAR → ersetzt durch exercise_focus_areas M:N (seit Migration 008)
ALTER TABLE exercises DROP COLUMN IF EXISTS focus_area;
-- secondary_areas JSONB → ersetzt durch exercise_focus_areas M:N
ALTER TABLE exercises DROP COLUMN IF EXISTS secondary_areas;
-- training_character VARCHAR → ersetzt durch exercise_training_characters M:N (seit Migration 012)
ALTER TABLE exercises DROP COLUMN IF EXISTS training_character;
-- ============================================================================
-- ADDITIONAL INDEXES (Performance)
-- ============================================================================
-- Häufige Filter
CREATE INDEX IF NOT EXISTS idx_exercises_visibility ON exercises(visibility);
CREATE INDEX IF NOT EXISTS idx_exercises_status ON exercises(status);
CREATE INDEX IF NOT EXISTS idx_exercises_created_at ON exercises(created_at DESC);
-- M:N Relations (falls noch nicht vorhanden)
CREATE INDEX IF NOT EXISTS idx_exercise_focus_areas_focus
ON exercise_focus_areas(focus_area_id);
CREATE INDEX IF NOT EXISTS idx_exercise_styles_style
ON exercise_training_styles(style_direction_id);
CREATE INDEX IF NOT EXISTS idx_exercise_target_groups_group
ON exercise_target_groups(target_group_id);
CREATE INDEX IF NOT EXISTS idx_exercise_skills_skill
ON exercise_skills(skill_id);
RAISE NOTICE 'Migration 014 completed successfully';
END $$;

View File

@ -0,0 +1,34 @@
-- Migration 016: Saved Exercise Searches
-- Autor: Claude Code
-- Datum: 2026-04-24
-- Zweck: Nutzer können häufige Suchfilter speichern
DO $$
BEGIN
-- ============================================================================
-- SAVED SEARCHES
-- ============================================================================
CREATE TABLE IF NOT EXISTS saved_exercise_searches (
id SERIAL PRIMARY KEY,
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
filters JSONB NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Index für User-Zugriff
CREATE INDEX IF NOT EXISTS idx_saved_searches_profile
ON saved_exercise_searches(profile_id);
-- Trigger für updated_at
DROP TRIGGER IF EXISTS saved_searches_update ON saved_exercise_searches;
CREATE TRIGGER saved_searches_update
BEFORE UPDATE ON saved_exercise_searches
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
RAISE NOTICE 'Migration 016 completed successfully';
END $$;

View File

@ -0,0 +1,103 @@
-- Migration 017: Exercise Blocks + Template Blocks
-- Autor: Claude Code
-- Datum: 2026-04-24
-- Zweck: Gruppierung verschiedener Übungen in Blöcken
-- Series = Varianten-Progression (via exercise_variants, KEINE eigene Tabelle)
-- Blocks = Verschiedene Übungen in Reihenfolge (DIESE Migration)
DO $$
BEGIN
-- ============================================================================
-- EXERCISE BLOCKS
-- Eine Sammlung verschiedener Übungen in definierter Reihenfolge
-- ============================================================================
CREATE TABLE IF NOT EXISTS exercise_blocks (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
goal TEXT,
-- Template-Modus: Block mit Platzhaltern für flexible Planung
is_template BOOLEAN DEFAULT false,
-- Ownership & Sichtbarkeit
club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
visibility VARCHAR(20) DEFAULT 'private' CHECK (visibility IN ('private', 'club', 'official')),
-- Timestamps
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- ============================================================================
-- EXERCISE BLOCK ITEMS
-- Einzelne Positionen innerhalb eines Blocks
-- ============================================================================
CREATE TABLE IF NOT EXISTS exercise_block_items (
id SERIAL PRIMARY KEY,
block_id INT NOT NULL REFERENCES exercise_blocks(id) ON DELETE CASCADE,
-- Konkrete Übung (NULL wenn is_placeholder = true)
exercise_id INT REFERENCES exercises(id) ON DELETE RESTRICT,
-- Optionale Variante (kann NULL sein → Haupt-Übung wird genutzt)
variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL,
-- Reihenfolge innerhalb des Blocks
sequence_order INT NOT NULL,
-- Template-Modus: Platzhalter statt konkreter Übung
is_placeholder BOOLEAN DEFAULT false,
-- Kriterien für Platzhalter-Auflösung (nur relevant wenn is_placeholder = true)
-- Schema: {"focus_area_id": 1, "max_duration": 10, "skill_ids": [3, 7], "difficulty": "easier"}
-- Alle Felder optional, werden als AND-Filter bei der Übungssuche genutzt
placeholder_criteria JSONB,
-- Platzhalter-Beschriftung (für UX im Template-Modus)
placeholder_label VARCHAR(100), -- z.B. "Aufwärmübung Schlag", "Hauptübung Kumite"
-- Zusätzliche Notizen für diese Position
notes TEXT,
-- Timestamps
created_at TIMESTAMP DEFAULT NOW(),
-- Constraints
UNIQUE(block_id, sequence_order),
-- Entweder exercise_id ODER is_placeholder=true
CHECK (
(is_placeholder = false AND exercise_id IS NOT NULL) OR
(is_placeholder = true AND exercise_id IS NULL)
)
);
-- ============================================================================
-- INDEXES
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_exercise_blocks_club ON exercise_blocks(club_id);
CREATE INDEX IF NOT EXISTS idx_exercise_blocks_creator ON exercise_blocks(created_by);
CREATE INDEX IF NOT EXISTS idx_exercise_blocks_visibility ON exercise_blocks(visibility);
CREATE INDEX IF NOT EXISTS idx_exercise_blocks_template ON exercise_blocks(is_template) WHERE is_template = true;
CREATE INDEX IF NOT EXISTS idx_exercise_block_items_block ON exercise_block_items(block_id);
CREATE INDEX IF NOT EXISTS idx_exercise_block_items_exercise ON exercise_block_items(exercise_id);
CREATE INDEX IF NOT EXISTS idx_exercise_block_items_placeholder ON exercise_block_items(is_placeholder) WHERE is_placeholder = true;
-- ============================================================================
-- UPDATED_AT TRIGGER
-- ============================================================================
DROP TRIGGER IF EXISTS exercise_blocks_update ON exercise_blocks;
CREATE TRIGGER exercise_blocks_update
BEFORE UPDATE ON exercise_blocks
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
RAISE NOTICE 'Migration 017 completed successfully (Exercise Blocks)';
END $$;

View File

@ -1,68 +1,299 @@
"""
Exercise Management Endpoints for Shinkan Jinkendo
Exercises Router - v2.0 (Clean-Room Rebuild)
Handles CRUD operations for exercises (Übungen).
Komplett neu gebaut nach EXERCISES_API_SPEC.md v1.2
KEIN Legacy-Code aus v1 - nur M:N Relations, keine JSONB-Felder für Kataloge
"""
import json
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel, Field
from db import get_db, get_cursor, r2d
from auth import require_auth
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["exercises"])
# ── List Exercises ────────────────────────────────────────────────────────
# ============================================================================
# Pydantic Models
# ============================================================================
class ExerciseCreate(BaseModel):
# Basis-Felder
title: str = Field(..., min_length=3, max_length=300)
summary: Optional[str] = None
goal: str = Field(..., min_length=10, max_length=5000)
execution: str = Field(..., min_length=10, max_length=10000)
preparation: Optional[str] = None
trainer_notes: Optional[str] = None
# Dauer & Gruppengröße
duration_min: Optional[int] = None
duration_max: Optional[int] = None
group_size_min: Optional[int] = None
group_size_max: Optional[int] = None
# Equipment (Liste von Strings)
equipment: list[str] = []
# M:N Relations (Liste von {id: int, is_primary: bool})
focus_areas_multi: list[dict] = []
training_styles_multi: list[dict] = []
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
class ExerciseUpdate(BaseModel):
# Alle Felder optional für Partial Update
title: Optional[str] = Field(None, min_length=3, max_length=300)
summary: Optional[str] = None
goal: Optional[str] = Field(None, min_length=10, max_length=5000)
execution: Optional[str] = Field(None, min_length=10, max_length=10000)
preparation: Optional[str] = None
trainer_notes: Optional[str] = None
duration_min: Optional[int] = None
duration_max: Optional[int] = None
group_size_min: Optional[int] = None
group_size_max: Optional[int] = None
equipment: Optional[list[str]] = None
focus_areas_multi: Optional[list[dict]] = None
training_styles_multi: Optional[list[dict]] = None
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
# ============================================================================
# Helper Functions
# ============================================================================
def enrich_exercise_detail(exercise_id: int, cur) -> dict:
"""
Lädt alle M:N Relations für eine Übung und gibt ein vollständiges
Exercise-Objekt zurück (wie in API-Spec GET /exercises/{id}).
"""
# Basis-Exercise
cur.execute(
"""SELECT e.*, p.name as creator_name, c.name as club_name
FROM exercises e
LEFT JOIN profiles p ON e.created_by = p.id
LEFT JOIN clubs c ON e.club_id = c.id
WHERE e.id = %s""",
(exercise_id,)
)
row = cur.fetchone()
if not row:
return None
exercise = r2d(row)
# Equipment JSONB → List
if exercise.get("equipment"):
exercise["equipment"] = exercise["equipment"] if isinstance(exercise["equipment"], list) else []
else:
exercise["equipment"] = []
# Focus Areas (M:N)
cur.execute(
"""SELECT efa.id, efa.focus_area_id, fa.name, fa.abbreviation, fa.color, fa.icon, efa.is_primary
FROM exercise_focus_areas efa
JOIN focus_areas fa ON efa.focus_area_id = fa.id
WHERE efa.exercise_id = %s
ORDER BY efa.is_primary DESC, fa.name""",
(exercise_id,)
)
exercise["focus_areas"] = [r2d(r) for r in cur.fetchall()]
# Training Styles (M:N)
cur.execute(
"""SELECT ets.id, ets.style_direction_id as training_style_id, sd.name, sd.abbreviation, ets.is_primary
FROM exercise_training_styles ets
JOIN style_directions sd ON ets.style_direction_id = sd.id
WHERE ets.exercise_id = %s
ORDER BY ets.is_primary DESC, sd.name""",
(exercise_id,)
)
exercise["training_styles"] = [r2d(r) for r in cur.fetchall()]
# Target Groups (M:N)
cur.execute(
"""SELECT etg.id, etg.target_group_id, tg.name, tg.description, etg.is_primary
FROM exercise_target_groups etg
JOIN target_groups tg ON etg.target_group_id = tg.id
WHERE etg.exercise_id = %s
ORDER BY etg.is_primary DESC, tg.name""",
(exercise_id,)
)
exercise["target_groups"] = [r2d(r) for r in cur.fetchall()]
# Age Groups (M:N) - nur Namen, nicht Objekte
cur.execute(
"""SELECT ag.name
FROM exercise_age_groups eag
JOIN age_groups ag ON eag.age_group_id = ag.id
WHERE eag.exercise_id = %s
ORDER BY ag.sort_order""",
(exercise_id,)
)
exercise["age_groups"] = [r["name"] for r in cur.fetchall()]
# Skills (M:N) mit Levels und Intensity
cur.execute(
"""SELECT es.id, es.skill_id, s.name as skill_name, s.category as skill_category,
es.is_primary, es.intensity, es.required_level, es.target_level, es.ai_suggested
FROM exercise_skills es
JOIN skills s ON es.skill_id = s.id
WHERE es.exercise_id = %s
ORDER BY es.is_primary DESC, s.name""",
(exercise_id,)
)
exercise["skills"] = [r2d(r) for r in cur.fetchall()]
# Variants (1:N) - mit Progression
cur.execute(
"""SELECT id, variant_name, description, execution_changes,
duration_min, duration_max, equipment_changes, difficulty_adjustment,
progression_level, sequence_order, prerequisite_variant_id
FROM exercise_variants
WHERE exercise_id = %s
ORDER BY progression_level, sequence_order""",
(exercise_id,)
)
exercise["variants"] = [r2d(r) for r in cur.fetchall()]
# Media (1:N)
cur.execute(
"""SELECT id, media_type, file_path, file_size, mime_type, original_filename,
embed_url, embed_platform, title, description, sort_order, is_primary, context
FROM exercise_media
WHERE exercise_id = %s
ORDER BY sort_order, id""",
(exercise_id,)
)
exercise["media"] = [r2d(r) for r in cur.fetchall()]
return exercise
def assign_exercise_relations(cur, conn, exercise_id: int, data: dict):
"""
Weist M:N Relations für eine Übung zu.
Löscht alte Zuordnungen und legt neue an (REPLACE-Logik).
"""
# Focus Areas
if "focus_areas_multi" in data:
cur.execute("DELETE FROM exercise_focus_areas WHERE exercise_id = %s", (exercise_id,))
for fa in data["focus_areas_multi"]:
cur.execute(
"""INSERT INTO exercise_focus_areas (exercise_id, focus_area_id, is_primary)
VALUES (%s, %s, %s)""",
(exercise_id, fa["focus_area_id"], fa.get("is_primary", False))
)
# Training Styles
if "training_styles_multi" in data:
cur.execute("DELETE FROM exercise_training_styles WHERE exercise_id = %s", (exercise_id,))
for ts in data["training_styles_multi"]:
cur.execute(
"""INSERT INTO exercise_training_styles (exercise_id, style_direction_id, is_primary)
VALUES (%s, %s, %s)""",
(exercise_id, ts["training_style_id"], ts.get("is_primary", False))
)
# Target Groups
if "target_groups_multi" in data:
cur.execute("DELETE FROM exercise_target_groups WHERE exercise_id = %s", (exercise_id,))
for tg in data["target_groups_multi"]:
cur.execute(
"""INSERT INTO exercise_target_groups (exercise_id, target_group_id, is_primary)
VALUES (%s, %s, %s)""",
(exercise_id, tg["target_group_id"], tg.get("is_primary", False))
)
# Age Groups (Namen → IDs lookup)
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"]:
cur.execute("SELECT id FROM age_groups WHERE name ILIKE %s", (age_group_name,))
row = cur.fetchone()
if row:
cur.execute(
"INSERT INTO exercise_age_groups (exercise_id, age_group_id) VALUES (%s, %s)",
(exercise_id, row[0])
)
else:
logger.warning("Age Group '%s' nicht im Katalog gefunden", age_group_name)
# 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"),
skill.get("required_level"),
skill.get("target_level"),
skill.get("ai_suggested", False),
)
)
conn.commit()
# ============================================================================
# Endpoints
# ============================================================================
@router.get("/exercises")
def list_exercises(
focus_area: Optional[str] = Query(default=None),
focus_area: Optional[int] = Query(default=None),
visibility: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None),
skill_id: Optional[int] = Query(default=None),
session=Depends(require_auth)
search: Optional[str] = Query(default=None),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
session: dict = Depends(require_auth),
):
"""
List exercises with optional filters.
Filters:
- focus_area: karate, selbstverteidigung, gewaltschutz
- visibility: private, club, official
- status: draft, in_review, approved, archived
- skill_id: Filter by associated skill
Liste aller Übungen mit Filtern.
Lightweight Response (ohne M:N Details, nur IDs und Namen).
"""
profile_id = session['profile_id']
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
# Base query
query = """
SELECT DISTINCT e.*,
p.name as creator_name,
c.name as club_name,
tm.name as primary_method_name
FROM exercises e
LEFT JOIN profiles p ON e.created_by = p.id
LEFT JOIN clubs c ON e.club_id = c.id
LEFT JOIN training_methods tm ON e.primary_method_id = tm.id
"""
# Add skill join if filtering by skill
if skill_id:
query += " LEFT JOIN exercise_skills es ON e.id = es.exercise_id"
# Build WHERE clause
where = []
# WHERE-Bedingungen
where = ["1=1"]
params = []
# Visibility filter: show own private + club + official
# Visibility Filter (private nur für Owner)
where.append("(e.visibility = 'official' OR e.visibility = 'club' OR e.created_by = %s)")
params.append(profile_id)
if focus_area:
where.append("e.focus_area = %s")
params.append(focus_area)
if visibility:
where.append("e.visibility = %s")
params.append(visibility)
@ -71,382 +302,217 @@ def list_exercises(
where.append("e.status = %s")
params.append(status)
# Focus Area Filter (M:N Join)
if focus_area:
where.append("EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id = %s)")
params.append(focus_area)
# Skill Filter (M:N Join)
if skill_id:
where.append("es.skill_id = %s")
where.append("EXISTS (SELECT 1 FROM exercise_skills es WHERE es.exercise_id = e.id AND es.skill_id = %s)")
params.append(skill_id)
if where:
query += " WHERE " + " AND ".join(where)
# Volltext-Suche (tsvector)
if search:
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
params.append(search)
query += " ORDER BY e.created_at DESC"
cur.execute(query, params)
rows = cur.fetchall()
return [r2d(r) for r in rows]
# ── Get Exercise ──────────────────────────────────────────────────────────
@router.get("/exercises/{exercise_id}")
def get_exercise(exercise_id: int, session=Depends(require_auth)):
"""Get exercise by ID with associated skills and variants."""
profile_id = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
# Get exercise
cur.execute("""
SELECT e.*,
p.name as creator_name,
c.name as club_name,
tm.name as primary_method_name
# Query
query = f"""
SELECT e.id, e.title, e.summary, e.visibility, e.status,
e.created_by, p.name as creator_name,
e.club_id, c.name as club_name,
e.created_at, e.updated_at
FROM exercises e
LEFT JOIN profiles p ON e.created_by = p.id
LEFT JOIN clubs c ON e.club_id = c.id
LEFT JOIN training_methods tm ON e.primary_method_id = tm.id
WHERE e.id = %s
""", (exercise_id,))
exercise = cur.fetchone()
WHERE {' AND '.join(where)}
ORDER BY e.updated_at DESC
LIMIT %s OFFSET %s
"""
params.extend([limit, offset])
if not exercise:
raise HTTPException(404, "Übung nicht gefunden")
cur.execute(query, params)
rows = cur.fetchall()
exercise = r2d(exercise)
# Check visibility
if exercise['visibility'] == 'private' and exercise['created_by'] != profile_id:
raise HTTPException(403, "Keine Berechtigung")
# Get associated skills
cur.execute("""
SELECT es.*, s.name as skill_name, s.category as skill_category
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()]
# Get variants
cur.execute("""
SELECT * FROM exercise_variants
WHERE exercise_id = %s
ORDER BY created_at
""", (exercise_id,))
exercise['variants'] = [r2d(r) for r in cur.fetchall()]
# Get media
cur.execute("""
SELECT * FROM exercise_media
WHERE exercise_id = %s
ORDER BY sort_order, created_at
""", (exercise_id,))
exercise['media'] = [r2d(r) for r in cur.fetchall()]
# Get M:N catalog assignments
# Focus Areas
cur.execute("""
SELECT efa.*, fa.name, fa.abbreviation, fa.color
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
cur.execute("""
SELECT es.*, ts.name, ts.abbreviation
FROM exercise_styles es
JOIN training_styles ts ON es.training_style_id = ts.id
WHERE es.exercise_id = %s
ORDER BY es.is_primary DESC, ts.name
""", (exercise_id,))
exercise['training_styles'] = [r2d(r) for r in cur.fetchall()]
# Target Groups
cur.execute("""
SELECT etg.*, tg.name, tg.description
FROM exercise_target_groups etg
JOIN target_groups tg ON etg.target_group_id = tg.id
WHERE etg.exercise_id = %s
ORDER BY etg.is_primary DESC, tg.name
""", (exercise_id,))
exercise['target_groups'] = [r2d(r) for r in cur.fetchall()]
# Age Groups
cur.execute("""
SELECT age_group FROM exercise_age_groups
WHERE exercise_id = %s
ORDER BY age_group
""", (exercise_id,))
exercise['age_groups_catalog'] = [r['age_group'] for r in cur.fetchall()]
return exercise
return [r2d(r) for r in rows]
# ── Create Exercise ───────────────────────────────────────────────────────
@router.post("/exercises")
def create_exercise(data: dict, session=Depends(require_auth)):
"""Create new exercise."""
profile_id = session['profile_id']
@router.get("/exercises/{exercise_id}")
def get_exercise(
exercise_id: int,
session: dict = Depends(require_auth),
):
"""
Exercise Detail mit allen M:N Relations (vollständig enriched).
"""
profile_id = session["profile_id"]
# Required fields
title = data.get('title')
goal = data.get('goal')
execution = data.get('execution')
with get_db() as conn:
cur = get_cursor(conn)
exercise = enrich_exercise_detail(exercise_id, cur)
if not title or not goal or not execution:
raise HTTPException(400, "Titel, Ziel und Durchführung sind Pflichtfelder")
if not exercise:
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
# Permission Check (private nur für Owner)
if exercise["visibility"] == "private" and exercise["created_by"] != profile_id:
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Übung")
return exercise
@router.post("/exercises", status_code=201)
def create_exercise(
body: ExerciseCreate,
session: dict = Depends(require_auth),
):
"""
Erstellt eine neue Übung mit allen M:N Relations.
"""
profile_id = session["profile_id"]
# Validierung
if body.status not in ("draft", "in_review", "approved", "archived"):
raise HTTPException(status_code=400, detail="Ungültiger Status")
if body.visibility not in ("private", "club", "official"):
raise HTTPException(status_code=400, detail="Ungültige Visibility")
with get_db() as conn:
cur = get_cursor(conn)
# Insert exercise
cur.execute("""
INSERT INTO exercises (
title, summary, goal, execution, preparation, trainer_notes,
equipment, duration_min, duration_max, group_size_min, group_size_max,
age_groups, focus_area, secondary_areas, training_character,
primary_method_id, secondary_method_ids,
training_style_id, training_character_id, focus_area_id,
visibility, status, created_by, club_id
) VALUES (
%s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s,
%s, %s, %s,
%s, %s, %s, %s
) RETURNING id
""", (
title,
data.get('summary'),
goal,
execution,
data.get('preparation'),
data.get('trainer_notes'),
data.get('equipment'), # JSONB
data.get('duration_min'),
data.get('duration_max'),
data.get('group_size_min'),
data.get('group_size_max'),
data.get('age_groups'), # JSONB
data.get('focus_area'), # Legacy
data.get('secondary_areas'), # JSONB
data.get('training_character'), # Legacy
data.get('primary_method_id'),
data.get('secondary_method_ids'), # JSONB
data.get('training_style_id'), # NEU
data.get('training_character_id'), # NEU
data.get('focus_area_id'), # NEU
data.get('visibility', 'private'),
data.get('status', 'draft'),
profile_id,
data.get('club_id')
))
exercise_id = cur.fetchone()['id']
# Add skills if provided
if data.get('skills'):
for skill in data['skills']:
cur.execute("""
INSERT INTO exercise_skills (
exercise_id, skill_id, is_primary, intensity,
development_contribution, required_level, target_level
) VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (
exercise_id,
skill['skill_id'],
skill.get('is_primary', False),
skill.get('intensity'),
skill.get('development_contribution'),
skill.get('required_level'),
skill.get('target_level')
))
# Add M:N catalog assignments if provided
# Focus Areas
if data.get('focus_areas_multi'):
for fa in data['focus_areas_multi']:
cur.execute("""
INSERT INTO exercise_focus_areas (exercise_id, focus_area_id, is_primary)
VALUES (%s, %s, %s)
""", (exercise_id, fa['focus_area_id'], fa.get('is_primary', False)))
# Training Styles
if data.get('training_styles_multi'):
for ts in data['training_styles_multi']:
cur.execute("""
INSERT INTO exercise_styles (exercise_id, training_style_id, is_primary)
VALUES (%s, %s, %s)
""", (exercise_id, ts['training_style_id'], ts.get('is_primary', False)))
# Target Groups
if data.get('target_groups_multi'):
for tg in data['target_groups_multi']:
cur.execute("""
INSERT INTO exercise_target_groups (exercise_id, target_group_id, is_primary)
VALUES (%s, %s, %s)
""", (exercise_id, tg['target_group_id'], tg.get('is_primary', False)))
# Age Groups
if data.get('age_groups_catalog'):
for age_group in data['age_groups_catalog']:
cur.execute("""
INSERT INTO exercise_age_groups (exercise_id, age_group)
VALUES (%s, %s)
""", (exercise_id, age_group))
# Equipment als JSONB
equipment_json = json.dumps(body.equipment) if body.equipment else None
# INSERT
cur.execute(
"""INSERT INTO exercises
(title, summary, goal, execution, preparation, trainer_notes,
duration_min, duration_max, group_size_min, group_size_max,
equipment, visibility, status, created_by, club_id)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id""",
(
body.title, body.summary, body.goal, body.execution,
body.preparation, body.trainer_notes,
body.duration_min, body.duration_max,
body.group_size_min, body.group_size_max,
equipment_json,
body.visibility, body.status, profile_id, body.club_id,
)
)
exercise_id = cur.fetchone()[0]
conn.commit()
return get_exercise(exercise_id, session)
# 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
# ── Update Exercise ───────────────────────────────────────────────────────
@router.put("/exercises/{exercise_id}")
def update_exercise(exercise_id: int, data: dict, session=Depends(require_auth)):
"""Update exercise."""
profile_id = session['profile_id']
def update_exercise(
exercise_id: int,
body: ExerciseUpdate,
session: dict = Depends(require_auth),
):
"""
Aktualisiert eine Übung (Partial Update).
Nur Owner darf editieren.
"""
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
# Check ownership
# Existiert die Übung?
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Übung nicht gefunden")
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
if row['created_by'] != profile_id:
raise HTTPException(403, "Keine Berechtigung")
# Permission Check
if row[0] != profile_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller darf editieren")
# Update exercise
cur.execute("""
UPDATE exercises SET
title = %s, summary = %s, goal = %s, execution = %s,
preparation = %s, trainer_notes = %s, equipment = %s,
duration_min = %s, duration_max = %s,
group_size_min = %s, group_size_max = %s,
age_groups = %s, focus_area = %s, secondary_areas = %s,
training_character = %s, primary_method_id = %s,
secondary_method_ids = %s,
training_style_id = %s, training_character_id = %s, focus_area_id = %s,
visibility = %s, status = %s,
club_id = %s, updated_at = NOW()
WHERE id = %s
""", (
data.get('title'),
data.get('summary'),
data.get('goal'),
data.get('execution'),
data.get('preparation'),
data.get('trainer_notes'),
data.get('equipment'),
data.get('duration_min'),
data.get('duration_max'),
data.get('group_size_min'),
data.get('group_size_max'),
data.get('age_groups'),
data.get('focus_area'), # Legacy
data.get('secondary_areas'),
data.get('training_character'), # Legacy
data.get('primary_method_id'),
data.get('secondary_method_ids'),
data.get('training_style_id'), # NEU
data.get('training_character_id'), # NEU
data.get('focus_area_id'), # NEU
data.get('visibility'),
data.get('status'),
data.get('club_id'),
exercise_id
))
# UPDATE (nur gesetzte Felder)
fields = []
params = []
# Update skills if provided
if 'skills' in data:
# Delete existing skills
cur.execute("DELETE FROM exercise_skills WHERE exercise_id = %s", (exercise_id,))
data = body.dict(exclude_unset=True)
# Add new skills
for skill in data['skills']:
cur.execute("""
INSERT INTO exercise_skills (
exercise_id, skill_id, is_primary, intensity,
development_contribution, required_level, target_level
) VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (
exercise_id,
skill['skill_id'],
skill.get('is_primary', False),
skill.get('intensity'),
skill.get('development_contribution'),
skill.get('required_level'),
skill.get('target_level')
))
# Basis-Felder
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])
# Update M:N catalog assignments if provided
# 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)))
# Equipment (JSONB)
if "equipment" in data:
fields.append("equipment = %s")
params.append(json.dumps(data["equipment"]) if data["equipment"] else None)
# Training Styles
if 'training_styles_multi' in data:
cur.execute("DELETE FROM exercise_styles WHERE exercise_id = %s", (exercise_id,))
for ts in data['training_styles_multi']:
cur.execute("""
INSERT INTO exercise_styles (exercise_id, training_style_id, is_primary)
VALUES (%s, %s, %s)
""", (exercise_id, ts['training_style_id'], ts.get('is_primary', False)))
# UPDATE ausführen (wenn Basis-Felder geändert wurden)
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()
# 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)))
# M:N Relations aktualisieren (wenn angegeben)
assign_exercise_relations(cur, conn, exercise_id, data)
# Age Groups
if 'age_groups_catalog' in data:
cur.execute("DELETE FROM exercise_age_groups WHERE exercise_id = %s", (exercise_id,))
for age_group in data['age_groups_catalog']:
cur.execute("""
INSERT INTO exercise_age_groups (exercise_id, age_group)
VALUES (%s, %s)
""", (exercise_id, age_group))
# Vollständiges Objekt zurückgeben
exercise = enrich_exercise_detail(exercise_id, cur)
conn.commit()
return get_exercise(exercise_id, session)
return exercise
# ── Delete Exercise ───────────────────────────────────────────────────────
@router.delete("/exercises/{exercise_id}")
def delete_exercise(exercise_id: int, session=Depends(require_auth)):
"""Delete exercise (only owner or admin)."""
profile_id = session['profile_id']
role = session.get('role')
def delete_exercise(
exercise_id: int,
session: dict = Depends(require_auth),
):
"""
Löscht eine Übung.
Nur Owner oder Admin darf löschen.
"""
profile_id = session["profile_id"]
role = session.get("role")
with get_db() as conn:
cur = get_cursor(conn)
# Check ownership or admin
# Existiert die Übung?
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Übung nicht gefunden")
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
if row['created_by'] != profile_id and role not in ['admin', 'superadmin']:
raise HTTPException(403, "Keine Berechtigung")
# Permission Check
if row[0] != profile_id and role not in ("admin", "superadmin"):
raise HTTPException(status_code=403, detail="Nur Ersteller oder Admin darf löschen")
# Delete (CASCADE handles skills, variants, media)
# Prüfen ob Übung in Trainingseinheiten verwendet wird
cur.execute(
"SELECT COUNT(*) FROM exercise_block_items WHERE exercise_id = %s",
(exercise_id,)
)
count = cur.fetchone()[0]
if count > 0:
raise HTTPException(
status_code=409,
detail=f"Übung wird in {count} Block-Item(s) verwendet und kann nicht gelöscht werden"
)
# DELETE (Cascade löscht M:N Zuordnungen automatisch)
cur.execute("DELETE FROM exercises WHERE id = %s", (exercise_id,))
conn.commit()

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.6.0"
APP_VERSION = "0.7.0"
BUILD_DATE = "2026-04-24"
DB_SCHEMA_VERSION = "20260424001"
DB_SCHEMA_VERSION = "20260424002"
MODULE_VERSIONS = {
"auth": "1.0.0",
@ -11,7 +11,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "0.5.0", # Updated: M:N Training Characters (Migration 012)
"exercises": "2.0.0", # BREAKING: Clean-Room Rebuild, Legacy-Felder entfernt, nur M:N
"training_units": "0.1.0",
"training_programs": "0.1.0",
"planning": "0.1.0",
@ -22,6 +22,23 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.7.0",
"date": "2026-04-24",
"changes": [
"BREAKING: Exercises v2.0 - Clean-Room Rebuild (kein Legacy-Code)",
"DB: Migration 014 - Variant Progression + Search Vector + Legacy DROP (age_groups, focus_area, secondary_areas, training_character)",
"DB: Migration 016 - Saved Exercise Searches",
"DB: Migration 017 - Exercise Blocks + Template Blocks",
"Backend: exercises.py komplett neu nach EXERCISES_API_SPEC.md v1.2",
"Backend: Nur M:N Relations, keine JSONB-Kataloge mehr",
"Backend: enrich_exercise_detail() für vollständige Objekte",
"Backend: assign_exercise_relations() für M:N Management",
"API: GET /exercises - Volltext-Suche via tsvector",
"API: POST/PUT /exercises - M:N Relations (focus_areas_multi, training_styles_multi, etc.)",
"Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation",
]
},
{
"version": "0.6.0",
"date": "2026-04-24",