feat: Exercises v2.0 + Migrations 014/016/017 (Clean-Room Rebuild)
BREAKING CHANGES:
- exercises.py komplett neu gebaut (kein Legacy-Code)
- Legacy-Felder entfernt: age_groups, focus_area, secondary_areas, training_character
- Nur M:N Relations, keine JSONB-Kataloge
Migrations:
- Migration 014: Variant Progression + Search Vector + Legacy DROP
- exercise_variants: progression_level, sequence_order, prerequisite_variant_id
- exercises: search_vector (tsvector für Volltext-Suche)
- DROP age_groups, focus_area, secondary_areas, training_character
- Helper: update_timestamp() Funktion für Triggers
- Migration 016: Saved Exercise Searches
- saved_exercise_searches (profile_id, name, filters JSONB)
- Migration 017: Exercise Blocks + Template Blocks
- exercise_blocks (name, description, goal, is_template)
- exercise_block_items (exercise_id, variant_id, sequence_order, is_placeholder, placeholder_criteria)
Backend (exercises.py v2.0):
- GET /exercises: Volltext-Suche via tsvector, M:N Joins
- GET /exercises/{id}: enrich_exercise_detail() mit allen M:N Relations
- POST /exercises: M:N Relations (focus_areas_multi, training_styles_multi, target_groups_multi, age_groups, skills)
- PUT /exercises: Partial Update + M:N Relations
- DELETE /exercises: Cascade-Check für exercise_block_items
Architecture:
- Issue #53 konform: Import = Feld-Zuordnung, keine fachliche Interpretation
- Helper: enrich_exercise_detail() für vollständige Objekte
- Helper: assign_exercise_relations() für M:N Management (DELETE+INSERT Pattern)
Docs:
- SMW_IMPORTER_GAP_ANALYSIS.md: Vollständige Gap-Analyse + Umsetzungsplan
Version: 0.7.0
Module: exercises 2.0.0
Schema: 20260424002
This commit is contained in:
parent
6801c60604
commit
01ed5509f8
391
.claude/docs/working/SMW_IMPORTER_GAP_ANALYSIS.md
Normal file
391
.claude/docs/working/SMW_IMPORTER_GAP_ANALYSIS.md
Normal 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
|
||||
115
backend/migrations/014_variant_progression_search_legacy.sql
Normal file
115
backend/migrations/014_variant_progression_search_legacy.sql
Normal 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 $$;
|
||||
34
backend/migrations/016_saved_searches.sql
Normal file
34
backend/migrations/016_saved_searches.sql
Normal 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 $$;
|
||||
103
backend/migrations/017_exercise_blocks.sql
Normal file
103
backend/migrations/017_exercise_blocks.sql
Normal 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 $$;
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user