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 typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Query from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel, Field
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth from auth import require_auth
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["exercises"]) 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") @router.get("/exercises")
def list_exercises( def list_exercises(
focus_area: Optional[str] = Query(default=None), focus_area: Optional[int] = Query(default=None),
visibility: Optional[str] = Query(default=None), visibility: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None), status: Optional[str] = Query(default=None),
skill_id: Optional[int] = 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. Liste aller Übungen mit Filtern.
Lightweight Response (ohne M:N Details, nur IDs und Namen).
Filters:
- focus_area: karate, selbstverteidigung, gewaltschutz
- visibility: private, club, official
- status: draft, in_review, approved, archived
- skill_id: Filter by associated skill
""" """
profile_id = session['profile_id'] profile_id = session["profile_id"]
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
# Base query # WHERE-Bedingungen
query = """ where = ["1=1"]
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 = []
params = [] 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)") where.append("(e.visibility = 'official' OR e.visibility = 'club' OR e.created_by = %s)")
params.append(profile_id) params.append(profile_id)
if focus_area:
where.append("e.focus_area = %s")
params.append(focus_area)
if visibility: if visibility:
where.append("e.visibility = %s") where.append("e.visibility = %s")
params.append(visibility) params.append(visibility)
@ -71,382 +302,217 @@ def list_exercises(
where.append("e.status = %s") where.append("e.status = %s")
params.append(status) 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: 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) params.append(skill_id)
if where: # Volltext-Suche (tsvector)
query += " WHERE " + " AND ".join(where) if search:
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
params.append(search)
query += " ORDER BY e.created_at DESC" # Query
query = f"""
cur.execute(query, params) SELECT e.id, e.title, e.summary, e.visibility, e.status,
rows = cur.fetchall() e.created_by, p.name as creator_name,
return [r2d(r) for r in rows] e.club_id, c.name as club_name,
e.created_at, e.updated_at
# ── 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
FROM exercises e FROM exercises e
LEFT JOIN profiles p ON e.created_by = p.id LEFT JOIN profiles p ON e.created_by = p.id
LEFT JOIN clubs c ON e.club_id = c.id LEFT JOIN clubs c ON e.club_id = c.id
LEFT JOIN training_methods tm ON e.primary_method_id = tm.id WHERE {' AND '.join(where)}
WHERE e.id = %s ORDER BY e.updated_at DESC
""", (exercise_id,)) LIMIT %s OFFSET %s
exercise = cur.fetchone() """
params.extend([limit, offset])
cur.execute(query, params)
rows = cur.fetchall()
return [r2d(r) for r in rows]
@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"]
with get_db() as conn:
cur = get_cursor(conn)
exercise = enrich_exercise_detail(exercise_id, cur)
if not exercise: if not exercise:
raise HTTPException(404, "Übung nicht gefunden") raise HTTPException(status_code=404, detail="Übung nicht gefunden")
exercise = r2d(exercise) # Permission Check (private nur für Owner)
if exercise["visibility"] == "private" and exercise["created_by"] != profile_id:
# Check visibility raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Übung")
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 exercise
# ── Create Exercise ─────────────────────────────────────────────────────── @router.post("/exercises", status_code=201)
@router.post("/exercises") def create_exercise(
def create_exercise(data: dict, session=Depends(require_auth)): body: ExerciseCreate,
"""Create new exercise.""" session: dict = Depends(require_auth),
profile_id = session['profile_id'] ):
"""
Erstellt eine neue Übung mit allen M:N Relations.
"""
profile_id = session["profile_id"]
# Required fields # Validierung
title = data.get('title') if body.status not in ("draft", "in_review", "approved", "archived"):
goal = data.get('goal') raise HTTPException(status_code=400, detail="Ungültiger Status")
execution = data.get('execution') if body.visibility not in ("private", "club", "official"):
raise HTTPException(status_code=400, detail="Ungültige Visibility")
if not title or not goal or not execution:
raise HTTPException(400, "Titel, Ziel und Durchführung sind Pflichtfelder")
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
# Insert exercise # Equipment als JSONB
cur.execute(""" equipment_json = json.dumps(body.equipment) if body.equipment else None
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))
# 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() 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}") @router.put("/exercises/{exercise_id}")
def update_exercise(exercise_id: int, data: dict, session=Depends(require_auth)): def update_exercise(
"""Update exercise.""" exercise_id: int,
profile_id = session['profile_id'] 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
# Check ownership # Existiert die Übung?
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,)) cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise HTTPException(404, "Übung nicht gefunden") raise HTTPException(status_code=404, detail="Übung nicht gefunden")
if row['created_by'] != profile_id: # Permission Check
raise HTTPException(403, "Keine Berechtigung") if row[0] != profile_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller darf editieren")
# Update exercise # UPDATE (nur gesetzte Felder)
cur.execute(""" fields = []
UPDATE exercises SET params = []
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 skills if provided data = body.dict(exclude_unset=True)
if 'skills' in data:
# Delete existing skills
cur.execute("DELETE FROM exercise_skills WHERE exercise_id = %s", (exercise_id,))
# Add new skills # Basis-Felder
for skill in data['skills']: for field in ["title", "summary", "goal", "execution", "preparation", "trainer_notes",
cur.execute(""" "duration_min", "duration_max", "group_size_min", "group_size_max",
INSERT INTO exercise_skills ( "visibility", "status", "club_id"]:
exercise_id, skill_id, is_primary, intensity, if field in data and data[field] is not None:
development_contribution, required_level, target_level fields.append(f"{field} = %s")
) VALUES (%s, %s, %s, %s, %s, %s, %s) params.append(data[field])
""", (
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')
))
# Update M:N catalog assignments if provided # Equipment (JSONB)
# Focus Areas if "equipment" in data:
if 'focus_areas_multi' in data: fields.append("equipment = %s")
cur.execute("DELETE FROM exercise_focus_areas WHERE exercise_id = %s", (exercise_id,)) params.append(json.dumps(data["equipment"]) if data["equipment"] else None)
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_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)))
# 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
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))
# 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() conn.commit()
return get_exercise(exercise_id, session) # M:N Relations aktualisieren (wenn angegeben)
assign_exercise_relations(cur, conn, exercise_id, data)
# Vollständiges Objekt zurückgeben
exercise = enrich_exercise_detail(exercise_id, cur)
return exercise
# ── Delete Exercise ───────────────────────────────────────────────────────
@router.delete("/exercises/{exercise_id}") @router.delete("/exercises/{exercise_id}")
def delete_exercise(exercise_id: int, session=Depends(require_auth)): def delete_exercise(
"""Delete exercise (only owner or admin).""" exercise_id: int,
profile_id = session['profile_id'] session: dict = Depends(require_auth),
role = session.get('role') ):
"""
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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
# Check ownership or admin # Existiert die Übung?
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,)) cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
row = cur.fetchone() row = cur.fetchone()
if not row: 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']: # Permission Check
raise HTTPException(403, "Keine Berechtigung") 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,)) cur.execute("DELETE FROM exercises WHERE id = %s", (exercise_id,))
conn.commit() conn.commit()

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.6.0" APP_VERSION = "0.7.0"
BUILD_DATE = "2026-04-24" BUILD_DATE = "2026-04-24"
DB_SCHEMA_VERSION = "20260424001" DB_SCHEMA_VERSION = "20260424002"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.0.0", "auth": "1.0.0",
@ -11,7 +11,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "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_units": "0.1.0",
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.1.0", "planning": "0.1.0",
@ -22,6 +22,23 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.6.0",
"date": "2026-04-24", "date": "2026-04-24",