From 01ed5509f84f0692988fb3457f7f14898c613f4b Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 24 Apr 2026 15:04:27 +0200 Subject: [PATCH] feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../docs/working/SMW_IMPORTER_GAP_ANALYSIS.md | 391 +++++++++ .../014_variant_progression_search_legacy.sql | 115 +++ backend/migrations/016_saved_searches.sql | 34 + backend/migrations/017_exercise_blocks.sql | 103 +++ backend/routers/exercises.py | 788 ++++++++++-------- backend/version.py | 23 +- 6 files changed, 1090 insertions(+), 364 deletions(-) create mode 100644 .claude/docs/working/SMW_IMPORTER_GAP_ANALYSIS.md create mode 100644 backend/migrations/014_variant_progression_search_legacy.sql create mode 100644 backend/migrations/016_saved_searches.sql create mode 100644 backend/migrations/017_exercise_blocks.sql diff --git a/.claude/docs/working/SMW_IMPORTER_GAP_ANALYSIS.md b/.claude/docs/working/SMW_IMPORTER_GAP_ANALYSIS.md new file mode 100644 index 0000000..434af17 --- /dev/null +++ b/.claude/docs/working/SMW_IMPORTER_GAP_ANALYSIS.md @@ -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 diff --git a/backend/migrations/014_variant_progression_search_legacy.sql b/backend/migrations/014_variant_progression_search_legacy.sql new file mode 100644 index 0000000..00705fa --- /dev/null +++ b/backend/migrations/014_variant_progression_search_legacy.sql @@ -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 $$; diff --git a/backend/migrations/016_saved_searches.sql b/backend/migrations/016_saved_searches.sql new file mode 100644 index 0000000..3ca8e02 --- /dev/null +++ b/backend/migrations/016_saved_searches.sql @@ -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 $$; diff --git a/backend/migrations/017_exercise_blocks.sql b/backend/migrations/017_exercise_blocks.sql new file mode 100644 index 0000000..b11688a --- /dev/null +++ b/backend/migrations/017_exercise_blocks.sql @@ -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 $$; diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 5520e6a..8a67aa3 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -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() diff --git a/backend/version.py b/backend/version.py index 0e39674..dbe5534 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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",