feat: Add MediaWiki import functionality with tracking and mapping
- Implemented a new SQL migration for wiki import tracking tables. - Created an import router for handling MediaWiki imports of exercises, skills, and methods. - Developed a Semantic MediaWiki API client for direct API interactions. - Added a mapper to convert SMW properties to local database fields. - Introduced background tasks for asynchronous import processing. - Implemented logging and error handling for import operations. - Added endpoints for previewing imports, checking import status, and managing import references.
This commit is contained in:
parent
0e0b709768
commit
6801c60604
599
.claude/docs/REVIEW_PROMPT_EXERCISES_SPECS.md
Normal file
599
.claude/docs/REVIEW_PROMPT_EXERCISES_SPECS.md
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
# Review Prompt: Exercises System Specifications
|
||||
|
||||
**Zweck:** Vollständiger Review der erstellten Spezifikationen gegen ursprüngliche Anforderungen und User-Ergänzungen
|
||||
|
||||
**Reviewer:** Plan Agent (oder anderer spezialisierter Agent)
|
||||
|
||||
**Status:** ✅ Review abgeschlossen + Fixes implementiert
|
||||
|
||||
**Datum:** 2026-04-24
|
||||
**Review-Datum:** 2026-04-24
|
||||
**Ergebnis:** APPROVED WITH CHANGES – alle BLOCKER und MAJOR Issues behoben
|
||||
|
||||
---
|
||||
|
||||
## 1. Kontext & Auftrag
|
||||
|
||||
Du bist ein Plan Agent, der die erstellten Spezifikationen für das Exercise System in Shinkan Jinkendo reviewt. Deine Aufgabe ist es, die 7 Core-Specs gegen die ursprünglichen Anforderungen und User-Ergänzungen zu prüfen und sicherzustellen, dass **keine Anforderungen vergessen** wurden und **keine architektonischen Drifts** entstanden sind.
|
||||
|
||||
---
|
||||
|
||||
## 2. Zu reviewende Spezifikationen
|
||||
|
||||
### 2.1 Core Specs (Phase 0 Foundation)
|
||||
|
||||
**Lokation:** `c:/Dev/shinkan-jinkendo/.claude/docs/technical/`
|
||||
|
||||
1. **EXERCISES_ARCHITECTURE.md** (ca. 800 Zeilen)
|
||||
- Architektur-Entscheidungen
|
||||
- Exercise/Variant/Block/Series Definitionen
|
||||
- Media-Strategie
|
||||
- API-Konventionen
|
||||
- DB-Konventionen
|
||||
- Decision Log
|
||||
|
||||
2. **UI_COMPONENTS_SPEC.md** (ca. 600 Zeilen)
|
||||
- Base Components (Chip, MultiSelect, Accordion, DragDropList)
|
||||
- Exercise Components (Card, QuickInfo, VariantCard, MediaGallery, MediaUploader)
|
||||
- Layout Patterns
|
||||
- Responsive Breakpoints
|
||||
- Accessibility
|
||||
|
||||
3. **EXERCISES_API_SPEC.md** (ca. 574 Zeilen)
|
||||
- Alle Endpoints (Exercises, Variants, Media)
|
||||
- Request/Response Beispiele
|
||||
- Validierung Rules
|
||||
- Error Format
|
||||
- HTTP Status Codes
|
||||
|
||||
4. **EXERCISES_FRONTEND_ROUTING.md** (ca. 400 Zeilen)
|
||||
- Route-Struktur
|
||||
- Navigation Patterns
|
||||
- State Management (URL, Local, Context)
|
||||
- Deep Linking
|
||||
- Navigation Guards
|
||||
- Performance (Lazy Loading, Prefetching)
|
||||
|
||||
5. **MEDIA_UPLOAD_SPEC.md** (ca. 500 Zeilen)
|
||||
- Hybrid Upload-Strategie (Lokal + Embeds)
|
||||
- Upload-Flow (Client + Server)
|
||||
- Embed-Parsing (YouTube, Vimeo, Instagram, TikTok)
|
||||
- Media-Galerie
|
||||
- Drag & Drop Reordering
|
||||
- Security & Performance
|
||||
|
||||
6. **SEARCH_FILTER_SPEC.md** (ca. 450 Zeilen)
|
||||
- Multi-Layer Search (Keyword, Katalog, Meta)
|
||||
- Volltext-Suche (tsvector)
|
||||
- Frontend Filter-UI
|
||||
- Erweiterte Filter (Multi-Select, Range, Equipment)
|
||||
- Faceted Search
|
||||
- Saved Searches
|
||||
- Sortierung
|
||||
|
||||
7. **EXERCISES_DATABASE_FINAL.md** (ca. 600 Zeilen)
|
||||
- Migration 014 (Variant Progression + Search Vector)
|
||||
- Migration 015 (Semantic Matching - Optional)
|
||||
- Migration 016 (Saved Searches)
|
||||
- Vollständige Tabellenstruktur
|
||||
- Indizes
|
||||
- Constraints & Business Rules
|
||||
- Trigger-Funktionen
|
||||
|
||||
---
|
||||
|
||||
## 3. Ursprüngliche Anforderungen
|
||||
|
||||
### 3.1 User-Anforderungen (Initial Message)
|
||||
|
||||
**Quelle:** Erste User-Message in Session
|
||||
|
||||
**Kern-Anforderungen:**
|
||||
|
||||
1. **Formatierte Ausgabe:** Übungen mit Embedded Videos, Zeichnungen, optimiert für verschiedene Use Cases
|
||||
2. **Varianten:** Mehrere Varianten mit progressiven Schwierigkeitsstufen
|
||||
3. **Exercise Series:** Progression durch Varianten derselben Übung
|
||||
4. **Exercise Blocks:** Sammlungen verschiedener Übungen
|
||||
5. **Multi-dimensionale Kategorisierung:** Stil, Fokus, Zielgruppe, Fähigkeiten-Matrix mit Levels
|
||||
6. **Trainingsplan-Integration:** Muss mit Trainingsplan-System integrieren
|
||||
7. **MediaWiki-Import:** Direct API Integration (NICHT export-basiert)
|
||||
8. **Robustheit:** Kein ständiger Drift, skalierbar, umfassend
|
||||
|
||||
**Spec-First Approach gewählt:** Alle Specs VOR Implementation
|
||||
|
||||
### 3.2 User-Ergänzungen (Follow-up)
|
||||
|
||||
**Quelle:** Zweite User-Message mit 5 Klarstellungen
|
||||
|
||||
**Präzisierungen:**
|
||||
|
||||
1. **Priorisierung OK:** Spec-First Ansatz bestätigt
|
||||
2. **Web + Mobile Priorität (NICHT Print/PDF):**
|
||||
- Kurze vs. detaillierte Beschreibungen für Trainingspläne
|
||||
3. **Media-Strategie:** Lokaler Upload + YouTube/Instagram Embeds
|
||||
4. **Exercise Series vs. Blocks Klarstellung:**
|
||||
- **Series:** Progressive Varianten derselben Übung (via Variant-Metadata)
|
||||
- **Blocks:** Gruppierung VERSCHIEDENER Übungen (neue Entity)
|
||||
- **Template Blocks:** Blocks mit Platzhaltern (gefüllt beim Planen)
|
||||
5. **MediaWiki Import:** Via Direct API (NICHT Export)
|
||||
|
||||
### 3.3 Referenz-Dokumente
|
||||
|
||||
**Quelle:** Zu lesende Dokumente vor Spec-Erstellung
|
||||
|
||||
1. **shinkan_anforderungsdokument_entwurf.md** (1425 Zeilen)
|
||||
- Fachliche Anforderungen
|
||||
- Reifegradmodelle
|
||||
- Übungen & Methoden
|
||||
- Trainingsplanung
|
||||
- MVP Scope: Trainer-fokussiert
|
||||
|
||||
2. **DOMAIN_MODEL.md** (Version 0.3.4)
|
||||
- Hierarchische Struktur: Fokusbereich → Stil → Zielgruppen (M:N)
|
||||
- Primary/Secondary Pattern für M:N
|
||||
- Migration 009 deployed (Zielgruppen M:N Refactoring)
|
||||
|
||||
3. **DATABASE_SCHEMA.md** (Version 0.3.4)
|
||||
- Existierende M:N Tabellen
|
||||
- Migrations 001-013 deployed
|
||||
|
||||
4. **backend/routers/exercises.py** (454 Zeilen)
|
||||
- Existierendes CRUD
|
||||
- Enrichment-Pattern für M:N Relations
|
||||
- Filter-Support
|
||||
|
||||
5. **backend/migrations/005_exercises.sql**
|
||||
- Base exercises table
|
||||
- exercise_skills, exercise_variants, exercise_media
|
||||
|
||||
6. **backend/migrations/008_mn_exercise_relations.sql**
|
||||
- M:N Migration (focus_areas, styles, target_groups, age_groups)
|
||||
|
||||
7. **frontend/src/pages/ExercisesPage.jsx** (609 Zeilen)
|
||||
- Existierende UI (Grid, Filters, Modal)
|
||||
- Limitation: Nutzt M:N Backend-Daten NICHT
|
||||
|
||||
---
|
||||
|
||||
## 4. Review-Kriterien
|
||||
|
||||
### 4.1 Vollständigkeit (Completeness)
|
||||
|
||||
**Prüfe gegen ursprüngliche Anforderungen:**
|
||||
|
||||
- [ ] **Formatierte Ausgabe:** Sind Embedded Videos, Zeichnungen, verschiedene Use Cases abgedeckt?
|
||||
- [ ] **Varianten mit Progression:** Sind progressive Schwierigkeitsstufen spezifiziert?
|
||||
- [ ] **Exercise Series:** Ist Progression durch Varianten klar definiert (OHNE separate Entity)?
|
||||
- [ ] **Exercise Blocks:** Ist Gruppierung verschiedener Übungen spezifiziert?
|
||||
- [ ] **Template Blocks:** Sind Platzhalter-Blocks für Planung definiert?
|
||||
- [ ] **Multi-dimensionale Kategorisierung:** Sind Stil, Fokus, Zielgruppe, Skills mit Levels abgedeckt?
|
||||
- [ ] **Trainingsplan-Integration:** Ist Integration mit Training Plan System spezifiziert?
|
||||
- [ ] **MediaWiki-Import:** Ist Direct API Integration (NICHT Export) spezifiziert?
|
||||
- [ ] **Kurz vs. Detail:** Sind verschiedene Beschreibungslängen für Trainingspläne definiert?
|
||||
|
||||
### 4.2 Konsistenz (Consistency)
|
||||
|
||||
**Prüfe gegen Referenz-Dokumente:**
|
||||
|
||||
- [ ] **DOMAIN_MODEL.md:** Folgen Specs der hierarchischen Struktur (Fokusbereich → Stil → Zielgruppen)?
|
||||
- [ ] **M:N Pattern:** Wird `is_primary` Flag korrekt genutzt?
|
||||
- [ ] **DATABASE_SCHEMA.md:** Bauen Migrationen 014-016 auf 001-013 auf?
|
||||
- [ ] **Existierender Code:** Sind Specs kompatibel mit `backend/routers/exercises.py`?
|
||||
- [ ] **Migration-Pattern:** Folgen neue Migrationen dem idempotenten DO $$ Pattern?
|
||||
|
||||
### 4.3 Architektonische Korrektheit (No Drift)
|
||||
|
||||
**Prüfe gegen User-Ergänzungen:**
|
||||
|
||||
- [ ] **Series vs. Blocks:** Ist die Unterscheidung klar und korrekt umgesetzt?
|
||||
- Series = Progression durch Varianten (Metadata in `exercise_variants`)
|
||||
- Blocks = Neue Entity für Gruppierung verschiedener Übungen
|
||||
- [ ] **Media-Strategie:** Hybrid (Lokal + Embeds) korrekt spezifiziert?
|
||||
- [ ] **Keine Cloud-Only Annahme:** Lokaler Upload vorhanden?
|
||||
- [ ] **Direct API statt Export:** MediaWiki-Import via API spezifiziert?
|
||||
|
||||
### 4.4 Technische Machbarkeit (Feasibility)
|
||||
|
||||
- [ ] **Performance:** Sind Performance-Targets realistisch (<50ms List, <150ms Search)?
|
||||
- [ ] **Skalierbarkeit:** DB-Schema skaliert auf 5.000+ Übungen?
|
||||
- [ ] **Security:** File-Upload-Validierung mit python-magic (nicht nur Extension)?
|
||||
- [ ] **UX:** Mobile-Optimierung (Filter-Drawer, Sticky Search, Bottom-Nav) vorhanden?
|
||||
|
||||
### 4.5 Gaps & Fehlende Features
|
||||
|
||||
**Prüfe auf vergessene Features:**
|
||||
|
||||
- [ ] **Trainingscharakter (M:N):** Ist `exercise_training_characters` spezifiziert?
|
||||
- [ ] **Fähigkeiten-Levels:** Sind `required_level` und `target_level` in `exercise_skills` definiert?
|
||||
- [ ] **Visibility-Workflow:** Ist `private → club → official` Status-Flow spezifiziert?
|
||||
- [ ] **Permissions:** Sind Owner-Checks (nur Ersteller darf bearbeiten) definiert?
|
||||
- [ ] **Pagination:** Ist Pagination in List-Endpoints spezifiziert?
|
||||
- [ ] **Reordering:** Ist Drag & Drop Reordering für Variants + Media definiert?
|
||||
|
||||
---
|
||||
|
||||
## 5. Review-Prozess
|
||||
|
||||
### 5.1 Schritt-für-Schritt Anleitung
|
||||
|
||||
**Schritt 1: Dokumente lesen**
|
||||
1. Lies alle 7 Core-Specs vollständig durch
|
||||
2. Mache dir Notizen zu Auffälligkeiten
|
||||
3. Markiere unklare oder widersprüchliche Stellen
|
||||
|
||||
**Schritt 2: Checkliste durchgehen**
|
||||
1. Gehe durch Abschnitt 4.1 (Vollständigkeit) - jede Anforderung einzeln prüfen
|
||||
2. Gehe durch Abschnitt 4.2 (Konsistenz) - gegen Referenz-Docs prüfen
|
||||
3. Gehe durch Abschnitt 4.3 (Architektur) - gegen User-Ergänzungen prüfen
|
||||
4. Gehe durch Abschnitt 4.4 (Machbarkeit) - technische Risiken bewerten
|
||||
5. Gehe durch Abschnitt 4.5 (Gaps) - fehlende Features identifizieren
|
||||
|
||||
**Schritt 3: Cross-Check zwischen Specs**
|
||||
1. ARCHITECTURE.md ↔ API_SPEC.md: Sind Decisions in API umgesetzt?
|
||||
2. API_SPEC.md ↔ DATABASE_FINAL.md: Matchen Endpoints zur DB-Struktur?
|
||||
3. UI_COMPONENTS_SPEC.md ↔ FRONTEND_ROUTING.md: Passen Komponenten zu Routes?
|
||||
4. MEDIA_UPLOAD_SPEC.md ↔ DATABASE_FINAL.md: Passt `exercise_media` Tabelle?
|
||||
5. SEARCH_FILTER_SPEC.md ↔ DATABASE_FINAL.md: Ist `search_vector` definiert?
|
||||
|
||||
**Schritt 4: Gap-Analyse**
|
||||
1. Erstelle Liste aller **fehlenden** Anforderungen
|
||||
2. Erstelle Liste aller **widersprüchlichen** Definitionen
|
||||
3. Erstelle Liste aller **unklaren** Spezifikationen
|
||||
4. Erstelle Liste aller **technischen Risiken**
|
||||
|
||||
**Schritt 5: Review-Report erstellen**
|
||||
1. Folge Template in Abschnitt 6
|
||||
2. Fülle alle Abschnitte vollständig aus
|
||||
3. Sei konkret: Datei + Zeile + Problem + Lösungsvorschlag
|
||||
|
||||
---
|
||||
|
||||
## 6. Review-Report Template
|
||||
|
||||
**Nutze dieses Template für deinen Review-Report:**
|
||||
|
||||
```markdown
|
||||
# Exercise System Specs - Review Report
|
||||
|
||||
**Reviewer:** [Dein Agent-Name]
|
||||
**Review-Datum:** [Datum]
|
||||
**Review-Dauer:** [ca. X Minuten]
|
||||
**Gesamtbewertung:** [APPROVED / APPROVED WITH CHANGES / REJECTED]
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
[2-3 Sätze: Sind die Specs bereit für Implementation? Größte Probleme?]
|
||||
|
||||
---
|
||||
|
||||
## 2. Vollständigkeit (Completeness)
|
||||
|
||||
### 2.1 Erfüllte Anforderungen ✅
|
||||
|
||||
- [x] Formatierte Ausgabe: Abgedeckt in MEDIA_UPLOAD_SPEC.md (Abschnitt 4)
|
||||
- [x] Varianten mit Progression: Abgedeckt in EXERCISES_DATABASE_FINAL.md (Migration 014)
|
||||
- [... weitere ...]
|
||||
|
||||
### 2.2 Fehlende Anforderungen ❌
|
||||
|
||||
| Anforderung | Wo fehlt es? | Kritikalität | Lösungsvorschlag |
|
||||
|-------------|--------------|--------------|------------------|
|
||||
| Exercise Blocks Entity | EXERCISES_ARCHITECTURE.md erwähnt, aber nicht in DATABASE_FINAL.md | HIGH | Migration 017 für `exercise_blocks` Tabelle hinzufügen |
|
||||
| Template Blocks Platzhalter | Nirgendwo spezifiziert | MEDIUM | Erweitere `exercise_blocks` um `is_template` + `placeholders` JSONB |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
---
|
||||
|
||||
## 3. Konsistenz (Consistency)
|
||||
|
||||
### 3.1 Inkonsistenzen zwischen Specs
|
||||
|
||||
| Problem | Betroffene Dateien | Impact | Fix |
|
||||
|---------|-------------------|--------|-----|
|
||||
| API_SPEC.md definiert `age_groups_catalog` als JSONB, aber DOMAIN_MODEL.md sagt M:N | API_SPEC.md Zeile 104, DOMAIN_MODEL.md Abschnitt 3 | MEDIUM | Entscheide: JSONB ODER M:N (empfohlen: M:N für Konsistenz) |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
### 3.2 Inkonsistenzen mit Referenz-Dokumenten
|
||||
|
||||
| Problem | Spec-Datei | Referenz-Dokument | Fix |
|
||||
|---------|-----------|-------------------|-----|
|
||||
| DOMAIN_MODEL.md sagt Zielgruppen sind global (M:N), aber API_SPEC.md zeigt `target_group_id` als FK | API_SPEC.md Zeile 267 | DOMAIN_MODEL.md Abschnitt 3.3 | Korrigiere API_SPEC zu M:N Array |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
---
|
||||
|
||||
## 4. Architektur-Drift
|
||||
|
||||
### 4.1 Abweichungen von User-Ergänzungen
|
||||
|
||||
| User-Anforderung | Spec-Umsetzung | Konform? | Problem / Fix |
|
||||
|-----------------|----------------|----------|---------------|
|
||||
| "Series = Varianten-Progression OHNE separate Entity" | EXERCISES_ARCHITECTURE.md Abschnitt 2.2 definiert Series korrekt als Metadata | ✅ JA | - |
|
||||
| "Blocks = neue Entity für verschiedene Übungen" | EXERCISES_ARCHITECTURE.md erwähnt, DATABASE_FINAL.md fehlt Migration | ❌ NEIN | Migration 017 fehlt |
|
||||
| "Direct API statt Export" | Nirgendwo spezifiziert | ❌ NEIN | Neue Spec MEDIAWIKI_IMPORT_SPEC.md nötig |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
---
|
||||
|
||||
## 5. Technische Risiken
|
||||
|
||||
| Risiko | Betroffene Spec | Wahrscheinlichkeit | Impact | Mitigation |
|
||||
|--------|----------------|-------------------|--------|------------|
|
||||
| tsvector auf großen Texten langsam | SEARCH_FILTER_SPEC.md | MEDIUM | HIGH | Teste Performance mit 10.000 Zeichen Execution-Text |
|
||||
| 50MB File-Upload blockiert Server | MEDIA_UPLOAD_SPEC.md | HIGH | MEDIUM | Chunk-Upload oder separater Upload-Worker |
|
||||
| pgvector Extension nicht verfügbar | DATABASE_FINAL.md Migration 015 | LOW | LOW | Migration 015 ist optional (Phase 2) |
|
||||
| ... | ... | ... | ... | ... |
|
||||
|
||||
---
|
||||
|
||||
## 6. Gap-Analyse
|
||||
|
||||
### 6.1 Komplett fehlende Features
|
||||
|
||||
1. **Exercise Blocks Implementation**
|
||||
- Wo benötigt: DATABASE_FINAL.md, API_SPEC.md, UI_COMPONENTS_SPEC.md
|
||||
- Kritikalität: HIGH (User-Anforderung #4)
|
||||
- Aufwand: 1 Migration + 3 Endpoints + 2 Komponenten
|
||||
|
||||
2. **Template Blocks mit Platzhaltern**
|
||||
- Wo benötigt: DATABASE_FINAL.md, API_SPEC.md
|
||||
- Kritikalität: MEDIUM (User-Anforderung #4 Teil 2)
|
||||
- Aufwand: Erweitere Blocks-Tabelle + 1 Endpoint
|
||||
|
||||
3. **MediaWiki Direct API Import**
|
||||
- Wo benötigt: Neue Spec MEDIAWIKI_IMPORT_SPEC.md
|
||||
- Kritikalität: HIGH (User-Anforderung #7)
|
||||
- Aufwand: Neue Spec + Backend-Integration
|
||||
|
||||
4. [... weitere ...]
|
||||
|
||||
### 6.2 Unvollständig spezifizierte Features
|
||||
|
||||
1. **Trainingsplan-Integration**
|
||||
- Status: Erwähnt in ARCHITECTURE.md, aber keine konkreten Endpoints
|
||||
- Fehlende Details: Wie werden Übungen in Pläne eingefügt? API-Struktur?
|
||||
- Fix: Erweitere API_SPEC.md um `/training-plans/{id}/exercises` Endpoints
|
||||
|
||||
2. [... weitere ...]
|
||||
|
||||
---
|
||||
|
||||
## 7. Kritische Findings (Must-Fix vor Implementation)
|
||||
|
||||
### 🔴 BLOCKER (Implementation NICHT starten ohne Fix)
|
||||
|
||||
1. **Exercise Blocks fehlen komplett**
|
||||
- Impact: Kern-Anforderung #4 nicht erfüllt
|
||||
- Fix: Migration 017 + API-Endpoints + UI-Komponenten spezifizieren
|
||||
|
||||
2. **MediaWiki-Import nicht spezifiziert**
|
||||
- Impact: Kern-Anforderung #7 nicht erfüllt
|
||||
- Fix: Neue Spec MEDIAWIKI_IMPORT_SPEC.md erstellen
|
||||
|
||||
### 🟠 MAJOR (Fix vor Phase 1 Abschluss)
|
||||
|
||||
1. **age_groups: JSONB vs. M:N Inkonsistenz**
|
||||
- Impact: Daten-Modell unklar, spätere Migration schwierig
|
||||
- Fix: Entscheide für M:N (Konsistenz) oder JSONB (Einfachheit)
|
||||
|
||||
2. [... weitere ...]
|
||||
|
||||
### 🟡 MINOR (Fix in Phase 2 akzeptabel)
|
||||
|
||||
1. **Semantic Matching (Migration 015) optional**
|
||||
- Impact: Nice-to-have Feature, kein MVP-Blocker
|
||||
- Fix: Kein Fix nötig, als Phase 2 markieren
|
||||
|
||||
2. [... weitere ...]
|
||||
|
||||
---
|
||||
|
||||
## 8. Empfehlungen
|
||||
|
||||
### 8.1 Vor Implementation starten
|
||||
|
||||
1. **Fixe alle BLOCKER:** Exercise Blocks + MediaWiki-Import spezifizieren
|
||||
2. **Kläre MAJOR Issues:** age_groups Inkonsistenz auflösen
|
||||
3. **Ergänze Extended Specs:** VARIANTS_PROGRESSION_SPEC.md, EXERCISE_BLOCKS_SPEC.md, PERMISSIONS_VISIBILITY_SPEC.md
|
||||
4. **User-Review:** Zeige Specs dem User, hole Feedback zu Blocks/Template-Blocks
|
||||
|
||||
### 8.2 Implementation-Reihenfolge (falls APPROVED)
|
||||
|
||||
**Phase 1A - Foundation:**
|
||||
1. Migration 014 (Search Vector + Variant Progression)
|
||||
2. Migration 016 (Saved Searches)
|
||||
3. Backend: GET/POST/PUT/DELETE `/exercises` + `/exercises/{id}/variants`
|
||||
4. Frontend: ExercisesListPage + ExerciseDetailPage (ohne Blocks)
|
||||
|
||||
**Phase 1B - Media:**
|
||||
1. Backend: `/exercises/{id}/media` Endpoints
|
||||
2. Frontend: MediaUploader + MediaGallery Komponenten
|
||||
|
||||
**Phase 1C - Search:**
|
||||
1. Backend: Search + Filter in GET `/exercises`
|
||||
2. Frontend: SearchFilterBar Komponente
|
||||
|
||||
**Phase 2 - Blocks (nach BLOCKER-Fix):**
|
||||
1. Migration 017 (Exercise Blocks)
|
||||
2. Backend: `/exercise-blocks` Endpoints
|
||||
3. Frontend: BlockEditor Komponente
|
||||
|
||||
**Phase 3 - Import:**
|
||||
1. MEDIAWIKI_IMPORT_SPEC.md erstellen
|
||||
2. Backend: MediaWiki API Connector
|
||||
3. Frontend: Import-UI
|
||||
|
||||
---
|
||||
|
||||
## 9. Positive Findings ✅
|
||||
|
||||
[Was ist gut gelaufen? Was ist besonders gut spezifiziert?]
|
||||
|
||||
1. **Sehr detaillierte API-Spec:** Alle Request/Response-Beispiele vorhanden
|
||||
2. **Konsistentes M:N Pattern:** is_primary Flag durchgängig genutzt
|
||||
3. **Performance-bewusst:** Indizes, Caching, Lazy Loading spezifiziert
|
||||
4. **Accessibility:** ARIA, Keyboard-Nav, Skip-Links definiert
|
||||
5. [... weitere ...]
|
||||
|
||||
---
|
||||
|
||||
## 10. Nächste Schritte
|
||||
|
||||
### Für Spec-Autor (Original Agent):
|
||||
1. [ ] Fixe BLOCKER #1: Exercise Blocks spezifizieren
|
||||
2. [ ] Fixe BLOCKER #2: MediaWiki-Import spezifizieren
|
||||
3. [ ] Kläre MAJOR Issue: age_groups JSONB vs. M:N
|
||||
4. [ ] Aktualisiere betroffene Specs (siehe Abschnitt 7)
|
||||
|
||||
### Für User:
|
||||
1. [ ] Lese Executive Summary + Kritische Findings
|
||||
2. [ ] Entscheide über Blocks/Template-Blocks Umsetzung
|
||||
3. [ ] Priorisiere MediaWiki-Import (jetzt oder später?)
|
||||
4. [ ] Gib Feedback zu age_groups Entscheidung
|
||||
|
||||
### Für Implementation:
|
||||
- **WARTE** bis alle BLOCKER gefixt sind
|
||||
- **DANACH:** Starte mit Phase 1A (Foundation)
|
||||
|
||||
---
|
||||
|
||||
**Review abgeschlossen:** [Datum/Uhrzeit]
|
||||
**Nächster Review:** [Nach BLOCKER-Fixes]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Wichtige Hinweise für Reviewer
|
||||
|
||||
### 7.1 Sei konkret
|
||||
|
||||
❌ **SCHLECHT:** "Die API-Spec ist inkonsistent"
|
||||
✅ **GUT:** "API_SPEC.md Zeile 267 zeigt `target_group_id` als FK, aber DOMAIN_MODEL.md Abschnitt 3.3 definiert Zielgruppen als M:N"
|
||||
|
||||
### 7.2 Priorisiere nach Impact
|
||||
|
||||
- **BLOCKER:** Verhindert Implementation-Start
|
||||
- **MAJOR:** Muss vor Phase 1 Abschluss gefixt werden
|
||||
- **MINOR:** Kann in Phase 2 gefixt werden
|
||||
|
||||
### 7.3 Schlage Lösungen vor
|
||||
|
||||
Nicht nur Probleme nennen, sondern auch:
|
||||
- Konkrete Fix-Vorschläge
|
||||
- Alternative Ansätze
|
||||
- Trade-offs aufzeigen
|
||||
|
||||
### 7.4 Sei fair
|
||||
|
||||
- Lobe gute Spezifikationen
|
||||
- Erkenne schwierige Trade-offs an
|
||||
- Berücksichtige MVP-Scope (nicht alles muss perfekt sein)
|
||||
|
||||
---
|
||||
|
||||
## 8. Erfolgs-Kriterien für APPROVED
|
||||
|
||||
Specs sind **APPROVED**, wenn:
|
||||
|
||||
1. ✅ Alle User-Anforderungen (1-8) spezifiziert sind
|
||||
2. ✅ Alle User-Ergänzungen (1-5) korrekt umgesetzt sind
|
||||
3. ✅ Keine architektonischen Widersprüche existieren
|
||||
4. ✅ Specs konsistent mit Referenz-Dokumenten sind
|
||||
5. ✅ Keine BLOCKER-Issues offen sind
|
||||
6. ✅ Technische Machbarkeit gegeben ist
|
||||
7. ✅ Implementation-Reihenfolge klar definiert ist
|
||||
|
||||
Specs sind **APPROVED WITH CHANGES**, wenn:
|
||||
- 1-2 MAJOR Issues existieren (aber klar, wie zu fixen)
|
||||
- Mehrere MINOR Issues existieren
|
||||
- Implementation kann starten, während Fixes parallel gemacht werden
|
||||
|
||||
Specs sind **REJECTED**, wenn:
|
||||
- 3+ BLOCKER Issues existieren
|
||||
- Fundamentale architektonische Probleme vorhanden
|
||||
- Große Teile der Anforderungen fehlen
|
||||
|
||||
---
|
||||
|
||||
## 9. Beispiel-Review-Snippet
|
||||
|
||||
**So sollte ein gutes Review-Finding aussehen:**
|
||||
|
||||
```markdown
|
||||
### 🔴 BLOCKER #1: Exercise Blocks fehlen in DB-Schema
|
||||
|
||||
**Problem:**
|
||||
- EXERCISES_ARCHITECTURE.md Abschnitt 2.3 definiert Exercise Blocks als "Gruppierung verschiedener Übungen"
|
||||
- EXERCISES_DATABASE_FINAL.md (Migration 014-016) enthält KEINE `exercise_blocks` Tabelle
|
||||
- API_SPEC.md spezifiziert KEINE `/exercise-blocks` Endpoints
|
||||
|
||||
**Impact:**
|
||||
- User-Anforderung #4 ("Exercise Blocks: Sammlungen verschiedener Übungen") NICHT erfüllt
|
||||
- Trainingsplan-Integration (Anforderung #6) ohne Blocks schwierig
|
||||
|
||||
**Betroffene Dateien:**
|
||||
- EXERCISES_DATABASE_FINAL.md (fehlt Migration 017)
|
||||
- EXERCISES_API_SPEC.md (fehlt Abschnitt "Exercise Blocks")
|
||||
- UI_COMPONENTS_SPEC.md (fehlt "BlockEditor" Komponente)
|
||||
|
||||
**Lösungsvorschlag:**
|
||||
|
||||
1. **Migration 017: Exercise Blocks**
|
||||
```sql
|
||||
CREATE TABLE exercise_blocks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
is_template BOOLEAN DEFAULT FALSE,
|
||||
placeholders JSONB, -- für Template-Blocks
|
||||
created_by INT REFERENCES profiles(id),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE exercise_block_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
block_id INT REFERENCES exercise_blocks(id) ON DELETE CASCADE,
|
||||
exercise_id INT REFERENCES exercises(id) ON DELETE CASCADE,
|
||||
sequence_order INT NOT NULL,
|
||||
notes TEXT,
|
||||
UNIQUE(block_id, sequence_order)
|
||||
);
|
||||
```
|
||||
|
||||
2. **API-Endpoints ergänzen:**
|
||||
- `GET /exercise-blocks` - List blocks
|
||||
- `POST /exercise-blocks` - Create block
|
||||
- `PUT /exercise-blocks/{id}` - Update block
|
||||
- `POST /exercise-blocks/{id}/items` - Add exercise to block
|
||||
- `PUT /exercise-blocks/{id}/items/reorder` - Reorder exercises
|
||||
|
||||
3. **UI-Komponente spezifizieren:**
|
||||
- BlockEditor mit Drag & Drop für Exercise-Reihenfolge
|
||||
- Template-Modus mit Platzhalter-UI
|
||||
|
||||
**Aufwand:** ~4-6h (1 Migration + 5 Endpoints + 1 Komponente spezifizieren)
|
||||
|
||||
**Kritikalität:** BLOCKER - Implementation kann ohne Blocks NICHT User-Anforderungen erfüllen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Review starten
|
||||
|
||||
**Bereit? Dann:**
|
||||
|
||||
1. Lies alle 7 Spec-Dokumente gründlich
|
||||
2. Gehe die Checkliste durch (Abschnitt 4)
|
||||
3. Erstelle deinen Review-Report (Template Abschnitt 6)
|
||||
4. Sei konkret, fair und konstruktiv
|
||||
5. Priorisiere nach Impact (BLOCKER > MAJOR > MINOR)
|
||||
|
||||
**Viel Erfolg beim Review! 🚀**
|
||||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung:** 2026-04-24
|
||||
**Version:** 1.0
|
||||
**Autor:** Claude Code (Spec-Ersteller)
|
||||
602
.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md
Normal file
602
.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
# KI-Prompt-System – Universelle Admin-Konfiguration
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** DRAFT
|
||||
**Autor:** Claude Code
|
||||
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System
|
||||
|
||||
---
|
||||
|
||||
## 1. Konzept
|
||||
|
||||
### 1.1 Ziel
|
||||
|
||||
Alle KI-Aufrufe in Shinkan sind durch **admin-konfigurierbare Prompt-Templates**
|
||||
steuerbar. Kein KI-Aufruf ist fest im Code verdrahtet.
|
||||
|
||||
**Admins können:**
|
||||
- Prompt-Texte anpassen (ohne Code-Änderung)
|
||||
- Neue Prompt-Typen hinzufügen
|
||||
- Prompts aktivieren/deaktivieren
|
||||
- Prompts testen (Preview mit aufgelösten Platzhaltern)
|
||||
- Platzhalter-Katalog einsehen
|
||||
|
||||
### 1.2 Anwendungsfälle in Shinkan
|
||||
|
||||
| Prompt-Slug | Verwendung |
|
||||
|-------------|-----------|
|
||||
| `exercise_summary` | Generiert `exercises.summary` aus goal + execution |
|
||||
| `exercise_skill_suggestions` | Empfiehlt Skills + Stufen für eine Übung |
|
||||
| `exercise_category_suggestions` | Empfiehlt Fokusbereich, Stil, Zielgruppe |
|
||||
| `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix |
|
||||
| `training_plan_notes` | Erzeugt Trainer-Notizen für Trainingseinheiten |
|
||||
| `wiki_import_cleanup` | Bereinigt importierten Wikitext in lesbares Deutsch |
|
||||
|
||||
### 1.3 Architektur (aus Mitai übernommen, vereinfacht)
|
||||
|
||||
```
|
||||
Admin konfiguriert Prompt-Template in DB (ai_prompts)
|
||||
│
|
||||
▼
|
||||
KI-Aufruf: Backend lädt Template, löst {{Platzhalter}} auf
|
||||
│
|
||||
▼
|
||||
OpenRouter API → Antwort
|
||||
│
|
||||
▼
|
||||
Antwort wird dem Trainer als Vorschlag angeboten (nicht blind gespeichert)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Datenbank
|
||||
|
||||
### 2.1 Migration (020_ai_prompts.sql)
|
||||
|
||||
```sql
|
||||
-- Migration 020: AI Prompt System (admin-konfigurierbar)
|
||||
-- Basis: Mitai Jinkendo prompts system (vereinfacht für Shinkan MVP)
|
||||
-- Autor: Claude Code
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
|
||||
-- ============================================================================
|
||||
-- AI PROMPTS (Admin-verwaltete Prompt-Templates)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_prompts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug VARCHAR(100) NOT NULL UNIQUE, -- z.B. 'exercise_summary'
|
||||
display_name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
|
||||
-- Template mit {{Platzhalter}}-Syntax
|
||||
template TEXT NOT NULL,
|
||||
|
||||
-- Kategorie: welcher Bereich der App nutzt diesen Prompt?
|
||||
category VARCHAR(50) DEFAULT 'exercise'
|
||||
CHECK (category IN ('exercise', 'training', 'matrix', 'import', 'admin')),
|
||||
|
||||
-- Output-Format: was gibt die KI zurück?
|
||||
output_format VARCHAR(10) DEFAULT 'text'
|
||||
CHECK (output_format IN ('text', 'json')),
|
||||
|
||||
-- JSON Schema für Validierung des KI-Outputs (nur wenn output_format='json')
|
||||
output_schema JSONB,
|
||||
|
||||
-- System-Default: kann auf Original zurückgesetzt werden
|
||||
is_system_default BOOLEAN DEFAULT false,
|
||||
default_template TEXT, -- Backup des originalen Templates
|
||||
|
||||
-- Admin-Controls
|
||||
active BOOLEAN DEFAULT true,
|
||||
sort_order INT DEFAULT 0,
|
||||
|
||||
-- Meta
|
||||
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_prompts_slug ON ai_prompts(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_prompts_category ON ai_prompts(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_prompts_active ON ai_prompts(active, sort_order);
|
||||
|
||||
-- ============================================================================
|
||||
-- TRIGGER
|
||||
-- ============================================================================
|
||||
|
||||
DROP TRIGGER IF EXISTS ai_prompts_update ON ai_prompts;
|
||||
CREATE TRIGGER ai_prompts_update
|
||||
BEFORE UPDATE ON ai_prompts
|
||||
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
|
||||
|
||||
-- ============================================================================
|
||||
-- SYSTEM-DEFAULT PROMPTS
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO ai_prompts (slug, display_name, description, template, category, output_format, is_system_default, default_template, sort_order) VALUES
|
||||
|
||||
-- 1. Exercise Summary
|
||||
('exercise_summary',
|
||||
'Übungs-Zusammenfassung',
|
||||
'Generiert eine kurze Zusammenfassung (2-3 Sätze) einer Übung für Listen und Trainingspläne.',
|
||||
$$Du bist Assistent für einen Kampfsport-Trainer.
|
||||
Erstelle eine prägnante Zusammenfassung dieser Übung für die Anzeige in Listen und Trainingsplänen.
|
||||
Die Zusammenfassung soll:
|
||||
- 2-3 Sätze lang sein (maximal 200 Zeichen)
|
||||
- Das Wesentliche: Was trainiert die Übung? Wie läuft sie ab?
|
||||
- Sachlich und klar (keine Werbebotschaften)
|
||||
- Auf Deutsch
|
||||
|
||||
Übung: {{exercise_title}}
|
||||
Fokusbereich: {{exercise_focus_area}}
|
||||
Ziel: {{exercise_goal}}
|
||||
Durchführung: {{exercise_execution}}
|
||||
|
||||
Antworte NUR mit dem Zusammenfassungstext, ohne Anführungszeichen.$$,
|
||||
'exercise', 'text', true,
|
||||
$$Du bist Assistent für einen Kampfsport-Trainer.
|
||||
Erstelle eine prägnante Zusammenfassung dieser Übung für die Anzeige in Listen und Trainingsplänen.
|
||||
Die Zusammenfassung soll:
|
||||
- 2-3 Sätze lang sein (maximal 200 Zeichen)
|
||||
- Das Wesentliche: Was trainiert die Übung? Wie läuft sie ab?
|
||||
- Sachlich und klar (keine Werbebotschaften)
|
||||
- Auf Deutsch
|
||||
|
||||
Übung: {{exercise_title}}
|
||||
Fokusbereich: {{exercise_focus_area}}
|
||||
Ziel: {{exercise_goal}}
|
||||
Durchführung: {{exercise_execution}}
|
||||
|
||||
Antworte NUR mit dem Zusammenfassungstext, ohne Anführungszeichen.$$,
|
||||
1),
|
||||
|
||||
-- 2. Skill Suggestions
|
||||
('exercise_skill_suggestions',
|
||||
'Fähigkeiten-Empfehlungen',
|
||||
'Empfiehlt passende Fähigkeiten + Stufen aus dem Katalog für eine Übung.',
|
||||
$$Du bist Assistent für einen Kampfsport-Trainer.
|
||||
Analysiere diese Übung und empfehle passende Fähigkeiten aus dem Katalog.
|
||||
|
||||
Übung: {{exercise_title}}
|
||||
Fokusbereich: {{exercise_focus_area}}
|
||||
Ziel: {{exercise_goal}}
|
||||
Durchführung: {{exercise_execution}}
|
||||
|
||||
Verfügbare Fähigkeiten:
|
||||
{{skills_catalog}}
|
||||
|
||||
Wähle maximal 5 passende Fähigkeiten. Für jede gib an:
|
||||
- skill_id (aus der Liste)
|
||||
- required_level: Voraussetzung (einsteiger|grundlagen|aufbau|fortgeschritten|experte)
|
||||
- target_level: Ziel nach regelmäßigem Training (gleiche Werte)
|
||||
- intensity: Trainingsintensität (niedrig|mittel|hoch)
|
||||
- is_primary: true wenn Hauptfähigkeit
|
||||
|
||||
Antworte NUR als JSON-Array:
|
||||
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
|
||||
|
||||
Wenn keine Fähigkeit passt, antworte mit [].$$,
|
||||
'exercise', 'json', true, NULL, 2),
|
||||
|
||||
-- 3. Category Suggestions
|
||||
('exercise_category_suggestions',
|
||||
'Kategorie-Empfehlungen',
|
||||
'Empfiehlt Fokusbereich, Stilrichtung und Zielgruppe für eine Übung.',
|
||||
$$Du bist Assistent für einen Kampfsport-Trainer.
|
||||
Ordne diese Übung in die Katalog-Struktur ein.
|
||||
|
||||
Übung: {{exercise_title}}
|
||||
Beschreibung: {{exercise_goal}}
|
||||
Durchführung: {{exercise_execution}}
|
||||
|
||||
Verfügbare Fokusbereiche: {{focus_areas_catalog}}
|
||||
Verfügbare Stilrichtungen: {{style_directions_catalog}}
|
||||
Verfügbare Zielgruppen: {{target_groups_catalog}}
|
||||
|
||||
Wähle die passendsten Zuordnungen. Antworte NUR als JSON:
|
||||
{
|
||||
"focus_areas": [{"id": 1, "is_primary": true}],
|
||||
"style_directions": [{"id": 2, "is_primary": true}],
|
||||
"target_groups": [{"id": 5, "is_primary": true}]
|
||||
}$$,
|
||||
'exercise', 'json', true, NULL, 3),
|
||||
|
||||
-- 4. Matrix Level Description
|
||||
('model_skill_level_description',
|
||||
'Fähigkeitsmatrix-Stufenbeschreibung',
|
||||
'Generiert Beschreibungen für Stufen in der Fähigkeitsmatrix.',
|
||||
$$Du bist Fachexperte für {{focus_area}} und erstellst Lernziel-Beschreibungen.
|
||||
|
||||
Modell: {{model_name}}
|
||||
Fokusbereich: {{focus_area}}
|
||||
Zielgruppe: {{target_group}}
|
||||
Fähigkeit: {{skill_name}}
|
||||
Stufenanzahl: {{level_count}}
|
||||
Stufen: {{level_names}}
|
||||
|
||||
Beschreibe für JEDE Stufe konkret und beobachtbar, was ein Schüler auf dieser Stufe
|
||||
der Fähigkeit "{{skill_name}}" können soll.
|
||||
|
||||
Antworte NUR als JSON-Array ({{level_count}} Einträge):
|
||||
[
|
||||
{"level_number": 1, "description": "...", "observable_criteria": "..."},
|
||||
{"level_number": 2, "description": "...", "observable_criteria": "..."}
|
||||
]$$,
|
||||
'matrix', 'json', true, NULL, 4),
|
||||
|
||||
-- 5. Wiki Import Cleanup
|
||||
('wiki_import_cleanup',
|
||||
'Wiki-Text-Bereinigung',
|
||||
'Bereinigt importierten Wikitext in lesbares, strukturiertes Deutsch.',
|
||||
$$Bereinige folgenden Text aus einem Trainings-Wiki für die Darstellung in einer modernen App.
|
||||
|
||||
Original-Text:
|
||||
{{wiki_raw_text}}
|
||||
|
||||
Regeln:
|
||||
- Entferne alle Wiki-Markup-Syntax ([[Links]], {{Templates}}, ==Überschriften==)
|
||||
- Behalte den fachlichen Inhalt vollständig
|
||||
- Schreibe in klarem, professionellem Deutsch
|
||||
- Strukturiere mit Absätzen (nicht mit Wiki-Markup)
|
||||
- Maximal 1000 Zeichen
|
||||
|
||||
Antworte NUR mit dem bereinigten Text.$$,
|
||||
'import', 'text', true, NULL, 5)
|
||||
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
|
||||
RAISE NOTICE 'Migration 020 completed successfully (AI Prompt System)';
|
||||
|
||||
END $$;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Platzhalter-Katalog
|
||||
|
||||
### 3.1 Verfügbare Platzhalter
|
||||
|
||||
Alle `{{Platzhalter}}` werden vom Backend-Service `prompt_resolver.py` aufgelöst.
|
||||
|
||||
**Kontext: exercise** (für exercise_summary, skill_suggestions, category_suggestions)
|
||||
|
||||
| Platzhalter | Beschreibung | Beispielwert |
|
||||
|-------------|--------------|--------------|
|
||||
| `{{exercise_title}}` | Titel der Übung | "Maai - Distanzübung" |
|
||||
| `{{exercise_goal}}` | Ziel (erste 500 Zeichen) | "Distanzgefühl entwickeln..." |
|
||||
| `{{exercise_execution}}` | Durchführung (erste 500 Zeichen) | "1. Partnerwahl..." |
|
||||
| `{{exercise_preparation}}` | Vorbereitung | "Matten auslegen..." |
|
||||
| `{{exercise_focus_area}}` | Primärer Fokusbereich | "Karate" |
|
||||
| `{{exercise_duration}}` | Dauer | "15-20 min" |
|
||||
| `{{exercise_group_size}}` | Gruppengröße | "8-12 Personen" |
|
||||
| `{{skills_catalog}}` | Liste aller Skills: "ID Name (Kategorie)" | "- ID 1: Dachi Waza (Kihon)\n..." |
|
||||
| `{{focus_areas_catalog}}` | Liste aller Fokusbereiche | "- ID 1: Karate\n..." |
|
||||
| `{{style_directions_catalog}}` | Liste aller Stilrichtungen | "- ID 1: Shotokan\n..." |
|
||||
| `{{target_groups_catalog}}` | Liste aller Zielgruppen | "- ID 1: Breitensportler\n..." |
|
||||
|
||||
**Kontext: matrix** (für model_skill_level_description)
|
||||
|
||||
| Platzhalter | Beschreibung | Beispielwert |
|
||||
|-------------|--------------|--------------|
|
||||
| `{{model_name}}` | Name des Reifegradmodells | "Karate Shotokan Breitensport" |
|
||||
| `{{focus_area}}` | Fokusbereich des Modells | "Karate" |
|
||||
| `{{style_direction}}` | Stilrichtung | "Shotokan" |
|
||||
| `{{target_group}}` | Zielgruppe | "Breitensportler" |
|
||||
| `{{skill_name}}` | Fähigkeitsname | "Distanzgefühl" |
|
||||
| `{{skill_description}}` | Fähigkeitsbeschreibung | "Kontrolle der Kampfdistanz..." |
|
||||
| `{{level_count}}` | Anzahl der Stufen | "5" |
|
||||
| `{{level_names}}` | Stufen-Namen kommagetrennt | "Einsteiger, Grundlagen, Aufbau, Fortgeschritten, Experte" |
|
||||
|
||||
**Kontext: import**
|
||||
|
||||
| Platzhalter | Beschreibung |
|
||||
|-------------|--------------|
|
||||
| `{{wiki_raw_text}}` | Roher Wikitext aus MediaWiki |
|
||||
|
||||
### 3.2 Platzhalter-Auflösung (Backend)
|
||||
|
||||
```python
|
||||
# backend/prompt_resolver.py
|
||||
|
||||
class ExercisePromptContext:
|
||||
"""Kontext für exercise-bezogene Prompts."""
|
||||
def resolve(self, template: str, exercise_data: dict, db) -> str:
|
||||
variables = {
|
||||
"exercise_title": exercise_data.get("title", ""),
|
||||
"exercise_goal": exercise_data.get("goal", "")[:500],
|
||||
"exercise_execution": exercise_data.get("execution", "")[:500],
|
||||
"exercise_preparation": exercise_data.get("preparation", ""),
|
||||
"exercise_focus_area": self._get_primary_focus_area(exercise_data, db),
|
||||
"exercise_duration": self._format_duration(exercise_data),
|
||||
"exercise_group_size": self._format_group_size(exercise_data),
|
||||
"skills_catalog": self._format_skills_catalog(db),
|
||||
"focus_areas_catalog": self._format_catalog(db, "focus_areas"),
|
||||
"style_directions_catalog":self._format_catalog(db, "style_directions"),
|
||||
"target_groups_catalog": self._format_catalog(db, "target_groups"),
|
||||
}
|
||||
return self._replace_placeholders(template, variables)
|
||||
```
|
||||
|
||||
### 3.3 Unbekannte Platzhalter
|
||||
|
||||
Wenn ein Platzhalter nicht aufgelöst werden kann:
|
||||
- `{{unknown_key}}` → bleibt als `[NICHT VERFÜGBAR]` im Template
|
||||
- Warnung im API-Response
|
||||
- Kein Abbruch des KI-Aufrufs
|
||||
|
||||
---
|
||||
|
||||
## 4. API-Endpoints
|
||||
|
||||
### 4.1 Übersicht
|
||||
|
||||
| Method | Endpoint | Beschreibung |
|
||||
|--------|----------|--------------|
|
||||
| GET | `/admin/ai-prompts` | Liste aller Prompts (Admin) |
|
||||
| GET | `/admin/ai-prompts/{id}` | Prompt-Detail |
|
||||
| POST | `/admin/ai-prompts` | Neuen Prompt anlegen |
|
||||
| PUT | `/admin/ai-prompts/{id}` | Prompt bearbeiten |
|
||||
| DELETE | `/admin/ai-prompts/{id}` | Prompt löschen (nur custom, nicht system) |
|
||||
| POST | `/admin/ai-prompts/{id}/reset` | Auf System-Default zurücksetzen |
|
||||
| POST | `/admin/ai-prompts/{id}/preview` | Platzhalter auflösen ohne KI-Call |
|
||||
| GET | `/admin/ai-prompts/placeholders` | Platzhalter-Katalog mit Beschreibungen |
|
||||
| POST | `/admin/ai-prompts/test` | Prompt mit Beispiel-Daten testen (echter KI-Call) |
|
||||
|
||||
### 4.2 `GET /admin/ai-prompts`
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"slug": "exercise_summary",
|
||||
"display_name": "Übungs-Zusammenfassung",
|
||||
"description": "Generiert eine kurze Zusammenfassung...",
|
||||
"category": "exercise",
|
||||
"output_format": "text",
|
||||
"active": true,
|
||||
"is_system_default": true,
|
||||
"is_modified": false,
|
||||
"sort_order": 1
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`is_modified`: true wenn `template != default_template`
|
||||
|
||||
---
|
||||
|
||||
### 4.3 `PUT /admin/ai-prompts/{id}`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"template": "Du bist ein erfahrener Karate-Trainer...\n{{exercise_title}}...",
|
||||
"active": true,
|
||||
"display_name": "Übungs-Zusammenfassung (angepasst)"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `200 OK` (Prompt-Objekt)
|
||||
|
||||
**Constraints:**
|
||||
- Nur `template`, `display_name`, `description`, `active`, `sort_order` änderbar
|
||||
- `slug`, `output_format`, `category` nicht änderbar (würde Code brechen)
|
||||
- System-Default-Backup bleibt immer erhalten
|
||||
|
||||
---
|
||||
|
||||
### 4.4 `POST /admin/ai-prompts/{id}/preview`
|
||||
|
||||
Löst Platzhalter auf und zeigt das finale Prompt – **OHNE KI-Call**.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"context": "exercise",
|
||||
"example_data": {
|
||||
"exercise_id": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"resolved_template": "Du bist Assistent für einen Kampfsport-Trainer.\nÜbung: Maai - Distanzübung\n...",
|
||||
"placeholders_resolved": ["exercise_title", "exercise_goal"],
|
||||
"placeholders_missing": [],
|
||||
"estimated_tokens": 320
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.5 `GET /admin/ai-prompts/placeholders`
|
||||
|
||||
Liefert den vollständigen Platzhalter-Katalog.
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"categories": {
|
||||
"exercise": [
|
||||
{
|
||||
"key": "exercise_title",
|
||||
"placeholder": "{{exercise_title}}",
|
||||
"description": "Titel der Übung",
|
||||
"example": "Maai - Distanzübung",
|
||||
"required_context": "exercise_id oder title direkt"
|
||||
}
|
||||
],
|
||||
"matrix": [...],
|
||||
"import": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Verwendung in Backend-Code
|
||||
|
||||
### 5.1 Standard-Pattern für KI-Aufrufe
|
||||
|
||||
```python
|
||||
# backend/services/ai_service.py
|
||||
|
||||
async def run_ai_prompt(
|
||||
slug: str,
|
||||
context_data: dict,
|
||||
db,
|
||||
openrouter_key: str
|
||||
) -> dict:
|
||||
"""
|
||||
Lädt Prompt aus DB, löst Platzhalter auf, ruft KI auf.
|
||||
Wirft AiNotConfiguredError wenn key fehlt oder Prompt inaktiv.
|
||||
"""
|
||||
# 1. Prompt laden
|
||||
prompt = db.fetchrow("SELECT * FROM ai_prompts WHERE slug=$1 AND active=true", slug)
|
||||
if not prompt:
|
||||
raise AiNotConfiguredError(f"Prompt '{slug}' nicht gefunden oder inaktiv")
|
||||
|
||||
# 2. Platzhalter auflösen
|
||||
resolved = resolve_placeholders(
|
||||
template=prompt["template"],
|
||||
context=context_data,
|
||||
db=db
|
||||
)
|
||||
|
||||
# 3. KI-Call
|
||||
response = await call_openrouter(
|
||||
prompt=resolved,
|
||||
model=settings.openrouter_model,
|
||||
api_key=openrouter_key
|
||||
)
|
||||
|
||||
# 4. JSON validieren (wenn output_format='json')
|
||||
if prompt["output_format"] == "json":
|
||||
response = parse_and_validate_json(response, prompt["output_schema"])
|
||||
|
||||
return {
|
||||
"output": response,
|
||||
"prompt_slug": slug,
|
||||
"ai_generated": True,
|
||||
"model": settings.openrouter_model
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Aufruf in exercises Router
|
||||
|
||||
```python
|
||||
# backend/routers/exercises.py
|
||||
|
||||
@router.post("/exercises/ai/suggest")
|
||||
async def suggest_for_exercise(data: ExerciseSuggestRequest, session=Depends(require_auth)):
|
||||
result = {}
|
||||
|
||||
# Summary
|
||||
try:
|
||||
summary = await ai_service.run_ai_prompt(
|
||||
slug="exercise_summary",
|
||||
context_data={"exercise": data.dict()},
|
||||
db=db,
|
||||
openrouter_key=settings.openrouter_api_key
|
||||
)
|
||||
result["summary"] = summary
|
||||
except AiNotConfiguredError:
|
||||
result["summary"] = None
|
||||
|
||||
# Skills
|
||||
try:
|
||||
skills = await ai_service.run_ai_prompt(
|
||||
slug="exercise_skill_suggestions",
|
||||
context_data={"exercise": data.dict()},
|
||||
db=db,
|
||||
openrouter_key=settings.openrouter_api_key
|
||||
)
|
||||
result["skills"] = skills
|
||||
except AiNotConfiguredError:
|
||||
result["skills"] = None
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend: Admin-UI
|
||||
|
||||
### 6.1 Route
|
||||
|
||||
```
|
||||
/admin/ai-prompts → AdminAiPromptsPage (Liste)
|
||||
/admin/ai-prompts/{id} → AdminAiPromptDetailPage (Edit)
|
||||
```
|
||||
|
||||
### 6.2 Layout (Prompt-Editor)
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ ✨ KI-Prompts [+ Neu] │
|
||||
│ ───────────────────────────────────────────── │
|
||||
│ exercise_summary [Aktiv] [Bearbeiten] │
|
||||
│ exercise_skill_suggestions [Aktiv] [Bearbeiten]│
|
||||
│ model_skill_level_description [Aktiv] │
|
||||
└───────────────────────────────────────────────┘
|
||||
|
||||
── Detailansicht ──────────────────────────────────
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ Übungs-Zusammenfassung │
|
||||
│ slug: exercise_summary · Kategorie: exercise │
|
||||
│ ───────────────────────────────────────────── │
|
||||
│ Template: │
|
||||
│ ┌────────────────────────────────────────────┐ │
|
||||
│ │ Du bist Assistent für einen Kampfsport- │ │
|
||||
│ │ Trainer. ... │ │
|
||||
│ │ {{exercise_title}} │ │ ← Syntaxhighlight
|
||||
│ │ {{exercise_goal}} │ │
|
||||
│ └────────────────────────────────────────────┘ │
|
||||
│ [Verfügbare Platzhalter ▼] [Vorschau] │
|
||||
│ ───────────────────────────────────────────── │
|
||||
│ [Mit Beispiel testen] [Original wiederherstellen]│
|
||||
│ [Speichern] │
|
||||
└───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Syntax-Highlighting:** `{{placeholder}}` in Farbe hervorheben.
|
||||
**Platzhalter-Panel:** Ausklappbare Liste aller verfügbaren Platzhalter (aus `/admin/ai-prompts/placeholders`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Zusammenspiel KI_FEATURES_SPEC ↔ AI_PROMPT_SYSTEM_SPEC
|
||||
|
||||
Die `KI_FEATURES_SPEC.md` beschreibt die **User-Flows** (wann wird KI angeboten,
|
||||
wie sieht die Bestätigung aus).
|
||||
|
||||
Die `AI_PROMPT_SYSTEM_SPEC.md` (diese Datei) beschreibt die **technische Basis**
|
||||
(DB-Schema, Platzhalter-System, Admin-UI).
|
||||
|
||||
```
|
||||
KI_FEATURES_SPEC: POST /exercises/ai/suggest
|
||||
│
|
||||
▼
|
||||
AI_PROMPT_SYSTEM_SPEC: ai_service.run_ai_prompt("exercise_summary", ...)
|
||||
│
|
||||
▼
|
||||
DB: ai_prompts WHERE slug='exercise_summary'
|
||||
│
|
||||
▼
|
||||
Template + {{Platzhalter}} auflösen
|
||||
│
|
||||
▼
|
||||
OpenRouter API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** DRAFT
|
||||
934
.claude/docs/technical/EXERCISES_API_SPEC.md
Normal file
934
.claude/docs/technical/EXERCISES_API_SPEC.md
Normal file
|
|
@ -0,0 +1,934 @@
|
|||
# Exercises API Specification
|
||||
|
||||
**Version:** 1.2
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** REVIEWED - Pending Implementation
|
||||
**Autor:** Claude Code
|
||||
**Änderungen v1.2:** KI-Assistenz Endpoints, Skill-Level-System (benannte Stufen), intensity als low/medium/high
|
||||
**Änderungen v1.1:** Exercise Blocks Endpoints, Permissions dokumentiert, age_groups korrigiert
|
||||
|
||||
---
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
Production: https://shinkan.jinkendo.de/api
|
||||
Development: https://dev.shinkan.jinkendo.de/api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
**Header:** `X-Auth-Token: <token>`
|
||||
|
||||
**Alle Endpoints** (außer `/auth/*`) erfordern Authentication.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints Overview
|
||||
|
||||
| Method | Endpoint | Beschreibung |
|
||||
|--------|----------|--------------|
|
||||
| **Exercises** |
|
||||
| GET | `/exercises` | List exercises mit Filtern |
|
||||
| GET | `/exercises/{id}` | Exercise Detail mit Enrichment |
|
||||
| POST | `/exercises` | Create exercise |
|
||||
| PUT | `/exercises/{id}` | Update exercise |
|
||||
| DELETE | `/exercises/{id}` | Delete exercise |
|
||||
| **Variants** |
|
||||
| POST | `/exercises/{id}/variants` | Create variant |
|
||||
| PUT | `/exercises/{id}/variants/{variant_id}` | Update variant |
|
||||
| DELETE | `/exercises/{id}/variants/{variant_id}` | Delete variant |
|
||||
| PUT | `/exercises/{id}/variants/reorder` | Reorder variants (DnD) |
|
||||
| **Media** |
|
||||
| POST | `/exercises/{id}/media` | Upload/Embed media |
|
||||
| PUT | `/exercises/{id}/media/{media_id}` | Update media metadata |
|
||||
| DELETE | `/exercises/{id}/media/{media_id}` | Delete media |
|
||||
| PUT | `/exercises/{id}/media/reorder` | Reorder media (DnD) |
|
||||
| **KI-Assistenz** |
|
||||
| POST | `/exercises/ai/suggest` | KI-Vorschläge (Summary + Skills) für neues Formular |
|
||||
| POST | `/exercises/{id}/ai/regenerate` | KI-Vorschläge neu generieren für bestehende Übung |
|
||||
| **Exercise Blocks** |
|
||||
| GET | `/exercise-blocks` | List blocks (mit Filtern) |
|
||||
| GET | `/exercise-blocks/{id}` | Block Detail mit Items |
|
||||
| POST | `/exercise-blocks` | Create block |
|
||||
| PUT | `/exercise-blocks/{id}` | Update block |
|
||||
| DELETE | `/exercise-blocks/{id}` | Delete block |
|
||||
| POST | `/exercise-blocks/{id}/items` | Add item to block |
|
||||
| PUT | `/exercise-blocks/{id}/items/{item_id}` | Update item |
|
||||
| DELETE | `/exercise-blocks/{id}/items/{item_id}` | Remove item |
|
||||
| PUT | `/exercise-blocks/{id}/items/reorder` | Reorder items (DnD) |
|
||||
|
||||
---
|
||||
|
||||
## Exercises
|
||||
|
||||
### `GET /exercises`
|
||||
|
||||
**Query Parameters:**
|
||||
- `focus_area` (int, optional) - Focus Area ID
|
||||
- `visibility` (enum, optional) - `private | club | official`
|
||||
- `status` (enum, optional) - `draft | in_review | approved | archived`
|
||||
- `skill_id` (int, optional) - Skill ID
|
||||
- `search` (string, optional) - Volltext (title, summary, execution)
|
||||
- `limit` (int, optional, default: 50, max: 100)
|
||||
- `offset` (int, optional, default: 0)
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Maai - Distanzübung",
|
||||
"summary": "Distanzgefühl entwickeln...",
|
||||
"focus_area": "karate",
|
||||
"visibility": "club",
|
||||
"status": "approved",
|
||||
"created_by": 1,
|
||||
"creator_name": "Lars",
|
||||
"club_id": 1,
|
||||
"club_name": "Dojo Berlin",
|
||||
"created_at": "2026-04-20T10:00:00Z",
|
||||
"updated_at": "2026-04-22T14:30:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `401` - Unauthorized
|
||||
- `403` - Forbidden
|
||||
|
||||
---
|
||||
|
||||
### `GET /exercises/{id}`
|
||||
|
||||
**Path Parameters:**
|
||||
- `id` (int, required)
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Maai - Distanzübung",
|
||||
"summary": "Kurzbeschreibung...",
|
||||
"goal": "Distanzgefühl entwickeln durch Partnerübungen...",
|
||||
"execution": "1. Partnerwahl\n2. Ausgangsstellung Zenkutsu Dachi...",
|
||||
"preparation": "Matten auslegen, Pratzen bereitlegen",
|
||||
"trainer_notes": "Auf korrekten Abstand achten!",
|
||||
"equipment": ["Matten", "Pratzen"],
|
||||
"duration_min": 15,
|
||||
"duration_max": 20,
|
||||
"group_size_min": 8,
|
||||
"group_size_max": 12,
|
||||
|
||||
"focus_areas": [
|
||||
{
|
||||
"id": 1,
|
||||
"focus_area_id": 1,
|
||||
"name": "Karate",
|
||||
"abbreviation": "KAR",
|
||||
"color": "#E63946",
|
||||
"icon": "🥋",
|
||||
"is_primary": true
|
||||
}
|
||||
],
|
||||
"training_styles": [
|
||||
{
|
||||
"id": 1,
|
||||
"training_style_id": 2,
|
||||
"name": "Shotokan",
|
||||
"abbreviation": "SKA",
|
||||
"is_primary": true
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"training_style_id": 3,
|
||||
"name": "Goju-Ryu",
|
||||
"abbreviation": "GJR",
|
||||
"is_primary": false
|
||||
}
|
||||
],
|
||||
"target_groups": [
|
||||
{
|
||||
"id": 1,
|
||||
"target_group_id": 5,
|
||||
"name": "Breitensportler",
|
||||
"description": "Karate für Freizeit und Fitness",
|
||||
"is_primary": true
|
||||
}
|
||||
],
|
||||
"age_groups": ["Kinder", "Teenager"],
|
||||
|
||||
"skills": [
|
||||
{
|
||||
"id": 1,
|
||||
"skill_id": 10,
|
||||
"skill_name": "Distanzgefühl",
|
||||
"skill_category": "Kumite",
|
||||
"is_primary": true,
|
||||
"intensity": "hoch",
|
||||
"required_level": "grundlagen",
|
||||
"target_level": "aufbau",
|
||||
"ai_suggested": false
|
||||
}
|
||||
],
|
||||
|
||||
"variants": [
|
||||
{
|
||||
"id": 1,
|
||||
"variant_name": "Ohne Partner",
|
||||
"description": "Solo-Variante mit Schattenboxen",
|
||||
"execution_changes": "Stelle dir einen imaginären Partner vor...",
|
||||
"duration_min": 10,
|
||||
"duration_max": 15,
|
||||
"equipment_changes": [],
|
||||
"difficulty_adjustment": "easier",
|
||||
"progression_level": 1,
|
||||
"sequence_order": 1,
|
||||
"prerequisite_variant_id": null
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"variant_name": "Mit Pratzen",
|
||||
"description": "Fortgeschrittene Variante mit Pratzen-Training",
|
||||
"execution_changes": "Partner hält Pratzen, Ausführung wie Haupt...",
|
||||
"duration_min": 15,
|
||||
"duration_max": 25,
|
||||
"equipment_changes": ["+ Pratzen"],
|
||||
"difficulty_adjustment": "harder",
|
||||
"progression_level": 3,
|
||||
"sequence_order": 3,
|
||||
"prerequisite_variant_id": 1
|
||||
}
|
||||
],
|
||||
|
||||
"media": [
|
||||
{
|
||||
"id": 1,
|
||||
"media_type": "video",
|
||||
"file_path": "/media/exercises/a1b2c3d4_demo.mp4",
|
||||
"file_size": 5242880,
|
||||
"mime_type": "video/mp4",
|
||||
"original_filename": "demo.mp4",
|
||||
"embed_url": null,
|
||||
"embed_platform": null,
|
||||
"title": "Durchführung Demo",
|
||||
"description": "Zeigt korrekten Abstand",
|
||||
"sort_order": 1,
|
||||
"is_primary": true,
|
||||
"context": "ablauf"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"media_type": "video",
|
||||
"file_path": null,
|
||||
"file_size": null,
|
||||
"mime_type": null,
|
||||
"original_filename": null,
|
||||
"embed_url": "https://www.youtube.com/watch?v=abc123",
|
||||
"embed_platform": "youtube",
|
||||
"title": "Erweiterte Techniken",
|
||||
"description": "YouTube-Tutorial von Sensei XY",
|
||||
"sort_order": 2,
|
||||
"is_primary": false,
|
||||
"context": "detail"
|
||||
}
|
||||
],
|
||||
|
||||
"visibility": "club",
|
||||
"status": "approved",
|
||||
"created_by": 1,
|
||||
"creator_name": "Lars",
|
||||
"club_id": 1,
|
||||
"club_name": "Dojo Berlin",
|
||||
"import_source": null,
|
||||
"import_id": null,
|
||||
"created_at": "2026-04-20T10:00:00Z",
|
||||
"updated_at": "2026-04-22T14:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `401` - Unauthorized
|
||||
- `403` - Forbidden (private + not owner)
|
||||
- `404` - Not found
|
||||
|
||||
---
|
||||
|
||||
### `POST /exercises`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"title": "Neue Übung",
|
||||
"summary": "Kurzbeschreibung...",
|
||||
"goal": "Ziel der Übung...",
|
||||
"execution": "Durchführung Schritt für Schritt...",
|
||||
"preparation": "Aufbau und Vorbereitung...",
|
||||
"trainer_notes": "Hinweise für Trainer...",
|
||||
"equipment": ["Matten", "Pratzen"],
|
||||
"duration_min": 15,
|
||||
"duration_max": 20,
|
||||
"group_size_min": 8,
|
||||
"group_size_max": 12,
|
||||
|
||||
"focus_areas_multi": [
|
||||
{"focus_area_id": 1, "is_primary": true},
|
||||
{"focus_area_id": 2, "is_primary": false}
|
||||
],
|
||||
"training_styles_multi": [
|
||||
{"training_style_id": 2, "is_primary": true}
|
||||
],
|
||||
"target_groups_multi": [
|
||||
{"target_group_id": 5, "is_primary": true}
|
||||
],
|
||||
"age_groups": ["Kinder", "Teenager"],
|
||||
|
||||
"skills": [
|
||||
{
|
||||
"skill_id": 10,
|
||||
"is_primary": true,
|
||||
"intensity": "hoch",
|
||||
"required_level": "grundlagen",
|
||||
"target_level": "aufbau"
|
||||
}
|
||||
],
|
||||
|
||||
"visibility": "private",
|
||||
"status": "draft",
|
||||
"club_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Required Fields:**
|
||||
- `title` (3-300 chars)
|
||||
- `goal` (10-5000 chars)
|
||||
- `execution` (10-10000 chars)
|
||||
|
||||
**Response:** `201 Created` (full exercise object wie GET)
|
||||
|
||||
**Errors:**
|
||||
- `400` - Bad Request (validation error)
|
||||
- `401` - Unauthorized
|
||||
- `403` - Forbidden
|
||||
|
||||
---
|
||||
|
||||
### `PUT /exercises/{id}`
|
||||
|
||||
**Request Body:** Same as POST (all fields optional except id)
|
||||
|
||||
**Response:** `200 OK` (full exercise object)
|
||||
|
||||
**Errors:**
|
||||
- `400` - Bad Request
|
||||
- `401` - Unauthorized
|
||||
- `403` - Forbidden (not owner)
|
||||
- `404` - Not found
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /exercises/{id}`
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{"ok": true}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `401` - Unauthorized
|
||||
- `403` - Forbidden (not owner or admin)
|
||||
- `404` - Not found
|
||||
- `409` - Conflict (used in training units)
|
||||
|
||||
---
|
||||
|
||||
## Variants
|
||||
|
||||
### `POST /exercises/{id}/variants`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"variant_name": "Mit Pratzen",
|
||||
"description": "Fortgeschrittene Variante...",
|
||||
"execution_changes": "Statt freier Distanz mit Pratzen...",
|
||||
"duration_min": 15,
|
||||
"duration_max": 25,
|
||||
"equipment_changes": ["+ Pratzen"],
|
||||
"difficulty_adjustment": "harder",
|
||||
"progression_level": 3,
|
||||
"sequence_order": 3,
|
||||
"prerequisite_variant_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Required Fields:**
|
||||
- `variant_name` (3-200 chars)
|
||||
|
||||
**Response:** `201 Created`
|
||||
```json
|
||||
{
|
||||
"id": 2,
|
||||
"exercise_id": 1,
|
||||
"variant_name": "Mit Pratzen",
|
||||
"description": "...",
|
||||
"progression_level": 3,
|
||||
"sequence_order": 3,
|
||||
"prerequisite_variant_id": 1,
|
||||
"created_at": "2026-04-24T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `400` - Bad Request
|
||||
- `401` - Unauthorized
|
||||
- `403` - Forbidden (not owner)
|
||||
- `404` - Exercise not found
|
||||
- `409` - Conflict (prerequisite_variant_id nicht zur gleichen Übung)
|
||||
|
||||
---
|
||||
|
||||
### `PUT /exercises/{id}/variants/{variant_id}`
|
||||
|
||||
**Request Body:** Same as POST
|
||||
|
||||
**Response:** `200 OK` (variant object)
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /exercises/{id}/variants/{variant_id}`
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{"ok": true}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `409` - Conflict (andere Varianten referenzieren diese als Prerequisite)
|
||||
|
||||
---
|
||||
|
||||
### `PUT /exercises/{id}/variants/reorder`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"variant_ids": [3, 1, 2]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{"ok": true, "reordered": 3}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `400` - variant_ids nicht vollständig oder falsche Exercise
|
||||
|
||||
---
|
||||
|
||||
## Media
|
||||
|
||||
### `POST /exercises/{id}/media`
|
||||
|
||||
**Content-Type:** `multipart/form-data`
|
||||
|
||||
**Form Fields:**
|
||||
- `file` (File, optional) - Image/Video file
|
||||
- `embed_url` (string, optional) - YouTube/Instagram/Vimeo URL
|
||||
- `media_type` (enum, required) - `image | video | document | sketch`
|
||||
- `title` (string, optional)
|
||||
- `description` (string, optional)
|
||||
- `context` (enum, optional) - `ablauf | detail | trainer_hint`
|
||||
|
||||
**Entweder `file` ODER `embed_url` (nicht beides).**
|
||||
|
||||
**Response:** `201 Created`
|
||||
```json
|
||||
{
|
||||
"id": 5,
|
||||
"exercise_id": 1,
|
||||
"media_type": "video",
|
||||
"file_path": "/media/exercises/a1b2c3d4_demo.mp4",
|
||||
"file_size": 5242880,
|
||||
"mime_type": "video/mp4",
|
||||
"original_filename": "demo.mp4",
|
||||
"embed_url": null,
|
||||
"embed_platform": null,
|
||||
"title": "Demo",
|
||||
"description": null,
|
||||
"sort_order": 3,
|
||||
"is_primary": false,
|
||||
"context": "ablauf",
|
||||
"created_at": "2026-04-24T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `400` - Bad Request (invalid file type, missing file/embed_url)
|
||||
- `401` - Unauthorized
|
||||
- `403` - Forbidden
|
||||
- `413` - File too large (> 50MB)
|
||||
|
||||
**Supported Formats:**
|
||||
- **Images:** `image/jpeg`, `image/png`, `image/gif`
|
||||
- **Videos:** `video/mp4`
|
||||
- **Documents:** `application/pdf`
|
||||
- **Embeds:** `youtube.com`, `youtu.be`, `instagram.com`, `vimeo.com`
|
||||
|
||||
---
|
||||
|
||||
### `PUT /exercises/{id}/media/{media_id}`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"title": "Neuer Titel",
|
||||
"description": "Neue Beschreibung",
|
||||
"is_primary": true,
|
||||
"context": "detail"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `200 OK` (media object)
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /exercises/{id}/media/{media_id}`
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{"ok": true}
|
||||
```
|
||||
|
||||
**Deletes:** DB-Eintrag + Datei (wenn `file_path` gesetzt)
|
||||
|
||||
---
|
||||
|
||||
### `PUT /exercises/{id}/media/reorder`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"media_ids": [2, 1, 3]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{"ok": true, "reordered": 3}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## KI-Assistenz
|
||||
|
||||
### `POST /exercises/ai/suggest`
|
||||
|
||||
Generiert KI-Vorschläge für eine **noch nicht gespeicherte** Übung.
|
||||
Wird beim Klick auf „✨ KI-Vorschlag" im Formular aufgerufen.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"title": "Maai - Distanzübung",
|
||||
"goal": "Distanzgefühl entwickeln...",
|
||||
"execution": "1. Partnerwahl\n2. Ausgangsstellung..."
|
||||
}
|
||||
```
|
||||
|
||||
**Mindestanforderung:** `goal` oder `execution` muss vorhanden sein (min. 50 Zeichen)
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"text": "Partnerübung zur Entwicklung des Distanzgefühls. Trainiert räumliche Wahrnehmung und reaktives Verhalten.",
|
||||
"ai_generated": true,
|
||||
"model": "anthropic/claude-sonnet-4"
|
||||
},
|
||||
"skills": [
|
||||
{
|
||||
"skill_id": 10,
|
||||
"skill_name": "Distanzgefühl",
|
||||
"skill_category": "Kumite",
|
||||
"required_level": "grundlagen",
|
||||
"target_level": "aufbau",
|
||||
"intensity": "hoch",
|
||||
"is_primary": true,
|
||||
"confidence": 0.92
|
||||
},
|
||||
{
|
||||
"skill_id": 15,
|
||||
"skill_name": "Reaktionsschnelligkeit",
|
||||
"skill_category": "Athletik",
|
||||
"required_level": "einsteiger",
|
||||
"target_level": "grundlagen",
|
||||
"intensity": "mittel",
|
||||
"is_primary": false,
|
||||
"confidence": 0.74
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `400` - Zu wenig Text (< 50 Zeichen)
|
||||
- `503` - KI nicht verfügbar (OPENROUTER_API_KEY nicht konfiguriert)
|
||||
|
||||
---
|
||||
|
||||
### `POST /exercises/{id}/ai/regenerate`
|
||||
|
||||
Generiert Vorschläge für eine **bestehende** Übung neu.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"regenerate": ["summary", "skills"]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `200 OK` (gleiche Struktur wie `/ai/suggest`)
|
||||
|
||||
**Hinweis:** Gibt nur Vorschläge zurück – nichts wird automatisch gespeichert.
|
||||
Trainer muss im Frontend aktiv übernehmen.
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
### Sichtbarkeits-Workflow
|
||||
|
||||
| Von → Nach | Wer darf das? |
|
||||
|-----------|---------------|
|
||||
| `draft → in_review` | Ersteller, Club-Admin |
|
||||
| `in_review → approved` | Club-Admin, Super-Admin |
|
||||
| `approved → archived` | Club-Admin, Super-Admin |
|
||||
| `* → draft` | Super-Admin |
|
||||
|
||||
### Sichtbarkeit (`visibility`)
|
||||
|
||||
| Änderung | Wer darf das? |
|
||||
|----------|---------------|
|
||||
| `private → club` | Ersteller, Club-Admin |
|
||||
| `club → official` | Club-Admin, Super-Admin |
|
||||
| `official → club` | Super-Admin |
|
||||
|
||||
### Owner-Checks
|
||||
|
||||
- **Bearbeiten** (PUT): Nur Ersteller oder Club-Admin
|
||||
- **Löschen** (DELETE): Nur Ersteller oder Super-Admin
|
||||
- **Lesen** (`private`): Nur Ersteller
|
||||
|
||||
**403 Fehler-Beispiel:**
|
||||
```json
|
||||
{"detail": "Keine Berechtigung. Nur der Ersteller oder ein Admin kann diese Übung bearbeiten."}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Exercise Blocks
|
||||
|
||||
### `GET /exercise-blocks`
|
||||
|
||||
**Query Parameters:**
|
||||
- `is_template` (bool, optional) - nur Templates oder nur reguläre Blocks
|
||||
- `visibility` (enum, optional) - `private | club | official`
|
||||
- `club_id` (int, optional) - Filter nach Verein
|
||||
- `search` (string, optional) - Suche in name
|
||||
- `limit` (int, optional, default: 50)
|
||||
- `offset` (int, optional, default: 0)
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Aufwärmblock Kumite",
|
||||
"description": "Standard-Aufwärmprogramm für Kumite",
|
||||
"is_template": false,
|
||||
"item_count": 4,
|
||||
"club_id": 1,
|
||||
"club_name": "Dojo Berlin",
|
||||
"created_by": 1,
|
||||
"creator_name": "Lars",
|
||||
"visibility": "club",
|
||||
"created_at": "2026-04-20T10:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /exercise-blocks/{id}`
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Aufwärmblock Kumite",
|
||||
"description": "Standard-Aufwärmprogramm für Kumite",
|
||||
"goal": "Sportler auf Kumite-Training vorbereiten",
|
||||
"is_template": false,
|
||||
"club_id": 1,
|
||||
"club_name": "Dojo Berlin",
|
||||
"created_by": 1,
|
||||
"creator_name": "Lars",
|
||||
"visibility": "club",
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"sequence_order": 1,
|
||||
"is_placeholder": false,
|
||||
"exercise_id": 5,
|
||||
"exercise_title": "Maai - Distanzübung",
|
||||
"exercise_summary": "Distanzgefühl entwickeln...",
|
||||
"variant_id": null,
|
||||
"variant_name": null,
|
||||
"notes": "Leicht anfangen, nur 5min",
|
||||
"placeholder_criteria": null,
|
||||
"placeholder_label": null
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"sequence_order": 2,
|
||||
"is_placeholder": true,
|
||||
"exercise_id": null,
|
||||
"exercise_title": null,
|
||||
"variant_id": null,
|
||||
"notes": "Hier eine Schlag-Übung einsetzen",
|
||||
"placeholder_criteria": {"focus_area_id": 1, "max_duration": 10},
|
||||
"placeholder_label": "Schlag-Übung (max. 10 min)"
|
||||
}
|
||||
],
|
||||
"created_at": "2026-04-20T10:00:00Z",
|
||||
"updated_at": "2026-04-22T14:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `403` - Forbidden (private Block, nicht Ersteller)
|
||||
- `404` - Not found
|
||||
|
||||
---
|
||||
|
||||
### `POST /exercise-blocks`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"name": "Aufwärmblock Kumite",
|
||||
"description": "Standard-Aufwärmprogramm",
|
||||
"goal": "Sportler vorbereiten",
|
||||
"is_template": false,
|
||||
"visibility": "private",
|
||||
"club_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Required Fields:**
|
||||
- `name` (3-200 chars)
|
||||
|
||||
**Response:** `201 Created` (full block object wie GET)
|
||||
|
||||
---
|
||||
|
||||
### `PUT /exercise-blocks/{id}`
|
||||
|
||||
**Request Body:** Same as POST (all fields optional)
|
||||
|
||||
**Response:** `200 OK` (full block object)
|
||||
|
||||
**Errors:**
|
||||
- `403` - Forbidden (nicht Ersteller/Admin)
|
||||
- `404` - Not found
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /exercise-blocks/{id}`
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{"ok": true}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `403` - Forbidden
|
||||
- `404` - Not found
|
||||
- `409` - Conflict (Block in Trainingsplan verwendet)
|
||||
|
||||
---
|
||||
|
||||
### `POST /exercise-blocks/{id}/items`
|
||||
|
||||
**Request Body (konkretes Exercise):**
|
||||
```json
|
||||
{
|
||||
"exercise_id": 5,
|
||||
"variant_id": null,
|
||||
"sequence_order": 3,
|
||||
"notes": "Leicht anfangen"
|
||||
}
|
||||
```
|
||||
|
||||
**Request Body (Platzhalter):**
|
||||
```json
|
||||
{
|
||||
"is_placeholder": true,
|
||||
"placeholder_label": "Schlag-Übung (max. 10 min)",
|
||||
"placeholder_criteria": {
|
||||
"focus_area_id": 1,
|
||||
"max_duration": 10
|
||||
},
|
||||
"sequence_order": 4,
|
||||
"notes": "Variabel je nach Gruppe"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201 Created`
|
||||
```json
|
||||
{
|
||||
"id": 5,
|
||||
"block_id": 1,
|
||||
"sequence_order": 3,
|
||||
"is_placeholder": false,
|
||||
"exercise_id": 5,
|
||||
"exercise_title": "Maai - Distanzübung",
|
||||
"variant_id": null,
|
||||
"notes": "Leicht anfangen",
|
||||
"created_at": "2026-04-24T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `400` - Bad Request (weder exercise_id noch is_placeholder=true)
|
||||
- `404` - Block oder Exercise nicht gefunden
|
||||
- `409` - Conflict (sequence_order bereits vergeben)
|
||||
|
||||
---
|
||||
|
||||
### `PUT /exercise-blocks/{id}/items/{item_id}`
|
||||
|
||||
**Request Body:** Gleiche Felder wie POST, alle optional
|
||||
|
||||
**Response:** `200 OK` (item object)
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /exercise-blocks/{id}/items/{item_id}`
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{"ok": true}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `PUT /exercise-blocks/{id}/items/reorder`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"item_ids": [3, 1, 2, 4]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"reordered": 4,
|
||||
"items": [
|
||||
{"id": 3, "sequence_order": 1},
|
||||
{"id": 1, "sequence_order": 2},
|
||||
{"id": 2, "sequence_order": 3},
|
||||
{"id": 4, "sequence_order": 4}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `400` - item_ids unvollständig oder gehören nicht zu diesem Block
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules
|
||||
|
||||
### Exercise
|
||||
- `title`: 3-300 chars, unique per club
|
||||
- `goal`: 10-5000 chars
|
||||
- `execution`: 10-10000 chars
|
||||
- `duration_min/max`: 0-480 (8 hours)
|
||||
- `group_size_min/max`: 1-100
|
||||
- `visibility`: enum (private, club, official)
|
||||
- `status`: enum (draft, in_review, approved, archived)
|
||||
|
||||
### Variant
|
||||
- `variant_name`: 3-200 chars
|
||||
- `progression_level`: 1-10
|
||||
- `prerequisite_variant_id`: must exist, must be same exercise
|
||||
|
||||
### Media
|
||||
- File size: max 50MB
|
||||
- Mime types: `image/jpeg, image/png, image/gif, video/mp4, application/pdf`
|
||||
- Embed platforms: `youtube, instagram, vimeo`
|
||||
|
||||
### Exercise Block
|
||||
- `name`: 3-200 chars
|
||||
|
||||
### Exercise Skills
|
||||
- `required_level`: enum – `einsteiger | grundlagen | aufbau | fortgeschritten | experte` (optional/nullable)
|
||||
- `target_level`: enum – gleiche Werte (optional/nullable)
|
||||
- `intensity`: enum – `niedrig | mittel | hoch` (optional/nullable)
|
||||
- `target_level` sollte >= `required_level` sein (Warnung, kein Fehler)
|
||||
|
||||
### Exercise Block Item
|
||||
- `sequence_order`: muss unique pro Block sein
|
||||
- `exercise_id`: muss existieren und zugänglich sein (visibility check)
|
||||
- `is_placeholder = true`: `exercise_id` muss NULL sein
|
||||
- `placeholder_criteria`: bekannte Keys nur (focus_area_id, training_style_id, target_group_id, skill_ids, max_duration, min_duration, difficulty, visibility)
|
||||
|
||||
---
|
||||
|
||||
## Error Response Format
|
||||
|
||||
**Standard:**
|
||||
```json
|
||||
{
|
||||
"detail": "Human-readable error message"
|
||||
}
|
||||
```
|
||||
|
||||
**Validation (detailliert):**
|
||||
```json
|
||||
{
|
||||
"detail": "Validation failed",
|
||||
"errors": [
|
||||
{"field": "title", "message": "Titel muss mindestens 3 Zeichen lang sein"},
|
||||
{"field": "goal", "message": "Ziel ist ein Pflichtfeld"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP Status Codes
|
||||
|
||||
- `200 OK` - Erfolgreiche GET/PUT/DELETE
|
||||
- `201 Created` - Erfolgreiche POST
|
||||
- `400 Bad Request` - Validierung fehlgeschlagen
|
||||
- `401 Unauthorized` - Kein/ungültiges Token
|
||||
- `403 Forbidden` - Keine Berechtigung
|
||||
- `404 Not Found` - Ressource nicht gefunden
|
||||
- `409 Conflict` - Duplikat, Constraint
|
||||
- `413 Payload Too Large` - Datei zu groß
|
||||
- `500 Internal Server Error` - Server-Fehler
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0
|
||||
**Letzte Änderung:** 2026-04-24
|
||||
**Status:** DRAFT - Awaiting Review
|
||||
638
.claude/docs/technical/EXERCISES_ARCHITECTURE.md
Normal file
638
.claude/docs/technical/EXERCISES_ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,638 @@
|
|||
# Exercise System Architecture
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** DRAFT - Awaiting Review
|
||||
**Autor:** Claude Code
|
||||
|
||||
---
|
||||
|
||||
## 1. Datenmodell-Entscheidungen
|
||||
|
||||
### 1.1 Übung vs. Variante vs. Block vs. Serie
|
||||
|
||||
**Definitionen:**
|
||||
|
||||
- **Übung (Exercise):** Atomare Trainingseinheit mit Ziel, Durchführung, Vorbereitung
|
||||
- Tabelle: `exercises`
|
||||
- Beispiel: "Maai - Distanzübung"
|
||||
|
||||
- **Variante (Variant):** Angepasste Ausführung EINER Übung
|
||||
- Tabelle: `exercise_variants`
|
||||
- Beispiel: "Maai - Ohne Partner", "Maai - Mit Pratzen"
|
||||
- Beziehung: 1:N (eine Übung hat 0-n Varianten)
|
||||
|
||||
- **Serie (Series):** Progression durch Varianten derselben Übung
|
||||
- **KEINE eigene Tabelle** - Progression via Metadata in `exercise_variants`
|
||||
- Felder: `progression_level`, `sequence_order`, `prerequisite_variant_id`
|
||||
- Beispiel: Liegestütz → Level 1 (Knie), Level 2 (Normal), Level 3 (Erhöht), Level 4 (Einarmig)
|
||||
|
||||
- **Block (Block):** Sammlung UNTERSCHIEDLICHER Übungen als zusammenhängender Trainingsabschnitt
|
||||
- Tabelle: `exercise_blocks` + `exercise_block_items`
|
||||
- Beispiel: "Zirkel Oberkörper" → Liegestütz + Klimmzüge + Dips + Planks
|
||||
- Beziehung: M:N (ein Block enthält viele Übungen, eine Übung kann in vielen Blöcken sein)
|
||||
|
||||
- **Template-Block:** Block mit Platzhaltern für spätere Befüllung
|
||||
- Flag: `is_template = true` in `exercise_blocks`
|
||||
- Beispiel: "Zirkeltraining (6 Stationen)" → Station 1: [Platzhalter: Push-Übung], Station 2: [Platzhalter: Pull-Übung], etc.
|
||||
|
||||
**Architektur-Diagramm:**
|
||||
```
|
||||
Exercise (1) ──────┬──── (N) Variants
|
||||
│ ├── progression_level
|
||||
│ ├── sequence_order
|
||||
│ └── prerequisite_variant_id
|
||||
│
|
||||
└──── (M:N) Skills
|
||||
└──── (M:N) Focus Areas
|
||||
└──── (M:N) Training Styles
|
||||
└──── (M:N) Target Groups
|
||||
└──── (1:N) Media
|
||||
|
||||
Exercise Block ──── (N) Block Items ──── (1) Exercise
|
||||
└── (1) Variant (optional)
|
||||
└── is_placeholder (for templates)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Medien-Strategie
|
||||
|
||||
**Zwei Medien-Typen:**
|
||||
|
||||
1. **Lokale Medien** (eigene Uploads)
|
||||
- Storage: `/app/media/exercises/{uuid}_{filename}`
|
||||
- Unterstützte Formate:
|
||||
- Bilder: `image/jpeg`, `image/png`, `image/gif`
|
||||
- Videos: `video/mp4`
|
||||
- Dokumente: `application/pdf` (für Skizzen)
|
||||
- Max Size: **50MB pro Datei**
|
||||
- Max Count: **10 Dateien pro Übung**
|
||||
|
||||
2. **Embeds** (externe Plattformen)
|
||||
- Unterstützte Plattformen:
|
||||
- YouTube: `youtube.com/watch?v=...`, `youtu.be/...`
|
||||
- Instagram: `instagram.com/p/...`, `instagram.com/reel/...`
|
||||
- Vimeo: `vimeo.com/...`
|
||||
- Speicherung: Nur URL + Platform-Identifier
|
||||
- Rendering: iframe-Embed im Frontend
|
||||
|
||||
**Medien-Felder:**
|
||||
- `file_path` (lokale Medien) **XOR** `embed_url` (externe Medien)
|
||||
- `mime_type` (nur bei lokalen Medien)
|
||||
- `embed_platform` (nur bei Embeds: 'youtube', 'instagram', 'vimeo')
|
||||
- `sort_order` (für Reorder via Drag & Drop)
|
||||
- `is_primary` (Hauptmedium, z.B. Vorschau-Bild)
|
||||
- `context` (Kontext: 'ablauf', 'detail', 'trainer_hint')
|
||||
|
||||
**Storage-Management:**
|
||||
- Uploads via FastAPI `UploadFile`
|
||||
- Speicherort: Docker Volume `/app/media/exercises/`
|
||||
- Cleanup: Bei Exercise-Deletion automatisch via CASCADE + Cleanup-Job
|
||||
|
||||
---
|
||||
|
||||
### 1.3 M:N Beziehungen (Primary/Secondary Pattern)
|
||||
|
||||
**Regel:** Alle Katalog-Zuordnungen nutzen M:N mit `is_primary` Flag.
|
||||
|
||||
**Betroffene Relationen:**
|
||||
- `exercise_focus_areas` (Übung ↔ Fokusbereiche)
|
||||
- `exercise_styles` (Übung ↔ Trainingsstile)
|
||||
- `exercise_target_groups` (Übung ↔ Zielgruppen)
|
||||
- `exercise_training_characters` (Übung ↔ Trainingscharaktere)
|
||||
- `exercise_skills` (Übung ↔ Fähigkeiten)
|
||||
|
||||
**Primary/Secondary Semantik:**
|
||||
- **Primary:** Hauptzuordnung, entscheidend für Filter/Suche
|
||||
- **Secondary:** Nebenzuordnung, zusätzlicher Kontext
|
||||
- **Regel:** Genau EINE Primary-Zuordnung pro Dimension
|
||||
- **UI:** Primary wird visuell hervorgehoben (z.B. fett, farbig)
|
||||
|
||||
**Legacy-Felder (DEPRECATED):**
|
||||
- `exercises.focus_area` → Ignorieren, nutze `exercise_focus_areas`
|
||||
- `exercises.training_style_id` → Ignorieren, nutze `exercise_styles`
|
||||
- `exercises.training_character_id` → Ignorieren, nutze `exercise_training_characters`
|
||||
- **NICHT löschen** (Rückwärtskompatibilität), aber **nicht verwenden**
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Sichtbarkeit & Freigabe
|
||||
|
||||
**3 Sichtbarkeits-Ebenen:**
|
||||
- `private` - Nur Ersteller sieht Übung
|
||||
- `club` - Alle Vereinsmitglieder sehen Übung
|
||||
- `official` - Alle Nutzer sehen Übung (globale Standards)
|
||||
|
||||
**4 Status-Stufen:**
|
||||
- `draft` - Entwurf, in Bearbeitung
|
||||
- `in_review` - Zur Prüfung eingereicht
|
||||
- `approved` - Freigegeben, produktiv nutzbar
|
||||
- `archived` - Archiviert, nicht mehr aktiv
|
||||
|
||||
**Berechtigungsmatrix:**
|
||||
|
||||
| Aktion | Private | Club | Official |
|
||||
|--------|---------|------|----------|
|
||||
| **Sehen** | Nur Owner | Alle Club-Member | Alle Nutzer |
|
||||
| **Bearbeiten** | Nur Owner | Nur Owner | Nur Superadmin |
|
||||
| **Löschen** | Nur Owner | Nur Owner + Club-Admin | Nur Superadmin |
|
||||
| **Freigeben** | Owner → Club | Club-Admin → Official | Superadmin |
|
||||
|
||||
**Freigabe-Workflow (vereinfacht in Phase 1):**
|
||||
1. Trainer erstellt Übung (`private`, `draft`)
|
||||
2. Trainer setzt Status auf `in_review`
|
||||
3. Club-Admin prüft, setzt `approved` + `visibility = 'club'`
|
||||
4. Superadmin kann auf `visibility = 'official'` setzen
|
||||
|
||||
**Phase 2+:** Explizite Freigabe-Requests (`content_change_requests` Tabelle)
|
||||
|
||||
---
|
||||
|
||||
## 2. API-Konventionen
|
||||
|
||||
### 2.1 Naming
|
||||
|
||||
**URL-Struktur:**
|
||||
- **Collections:** Plural (`/api/exercises`)
|
||||
- **Items:** Singular + ID (`/api/exercises/{id}`)
|
||||
- **Sub-Resources:** Nested (`/api/exercises/{id}/variants`, `/api/exercises/{id}/media`)
|
||||
- **Actions:** HTTP-Verben (POST, PUT, DELETE, GET)
|
||||
|
||||
**HTTP-Methoden:**
|
||||
- `GET` - Read (Liste oder Detail)
|
||||
- `POST` - Create (neue Ressource)
|
||||
- `PUT` - Update (bestehende Ressource)
|
||||
- `DELETE` - Delete
|
||||
|
||||
**Query-Parameter:**
|
||||
- Snake-case: `focus_area`, `skill_id`, `search`
|
||||
- Optional Filter: `?focus_area=karate&status=approved`
|
||||
- Pagination: `?limit=50&offset=0`
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Response-Format
|
||||
|
||||
**Erfolgreiche Responses:**
|
||||
```json
|
||||
// Liste (Array)
|
||||
[
|
||||
{"id": 1, "title": "...", ...},
|
||||
{"id": 2, "title": "...", ...}
|
||||
]
|
||||
|
||||
// Detail (Object mit Enrichment)
|
||||
{
|
||||
"id": 1,
|
||||
"title": "...",
|
||||
"skills": [...], // enriched M:N
|
||||
"variants": [...], // enriched 1:N
|
||||
"media": [...], // enriched 1:N
|
||||
"focus_areas": [...], // enriched M:N
|
||||
"training_styles": [...], // enriched M:N
|
||||
"target_groups": [...], // enriched M:N
|
||||
"age_groups_catalog": ["Kinder", "Teenager"]
|
||||
}
|
||||
|
||||
// Create/Update (Created Object)
|
||||
{
|
||||
"id": 42,
|
||||
... // full object wie GET Detail
|
||||
}
|
||||
|
||||
// Delete
|
||||
{"ok": true}
|
||||
```
|
||||
|
||||
**Fehler-Responses:**
|
||||
```json
|
||||
{
|
||||
"detail": "Human-readable error message"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Error-Handling
|
||||
|
||||
**HTTP-Status-Codes:**
|
||||
- `200 OK` - Erfolgreiche GET/PUT/DELETE
|
||||
- `201 Created` - Erfolgreiche POST
|
||||
- `400 Bad Request` - Validierung fehlgeschlagen, fehlende Pflichtfelder
|
||||
- `401 Unauthorized` - Kein oder ungültiges Auth-Token
|
||||
- `403 Forbidden` - Keine Berechtigung (Sichtbarkeit/Ownership)
|
||||
- `404 Not Found` - Ressource nicht gefunden
|
||||
- `409 Conflict` - Duplikat, Constraint-Verletzung
|
||||
- `413 Payload Too Large` - Datei zu groß
|
||||
- `500 Internal Server Error` - Server-Fehler
|
||||
|
||||
**Error-Detail-Format:**
|
||||
```json
|
||||
{
|
||||
"detail": "Titel, Ziel und Durchführung sind Pflichtfelder"
|
||||
}
|
||||
```
|
||||
|
||||
**Validation Errors (detailliert):**
|
||||
```json
|
||||
{
|
||||
"detail": "Validation failed",
|
||||
"errors": [
|
||||
{"field": "title", "message": "Titel muss mindestens 3 Zeichen lang sein"},
|
||||
{"field": "goal", "message": "Ziel ist ein Pflichtfeld"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend-Architektur
|
||||
|
||||
### 3.1 Route-Struktur
|
||||
|
||||
**Übungen-Routes:**
|
||||
- `/exercises` → Grid/List mit Filtern (ExercisesPage)
|
||||
- `/exercises/:id` → Detail-Ansicht (ExerciseDetailPage)
|
||||
- `/exercises/:id/edit` → Edit-Formular (ExerciseEditPage - eigene Route, kein Modal)
|
||||
- `/exercises/new` → Create-Formular (ExerciseCreatePage - eigene Route, kein Modal)
|
||||
|
||||
**Rationale für eigene Routes statt Modals:**
|
||||
- Bessere Deep-Linking (shareable URLs)
|
||||
- Browser-Navigation (Back-Button)
|
||||
- State-Management einfacher
|
||||
- Mobile-freundlicher
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Komponenten-Hierarchie
|
||||
|
||||
```
|
||||
pages/
|
||||
ExercisesPage.jsx (Grid + Filters)
|
||||
ExerciseDetailPage.jsx (Formatted View)
|
||||
ExerciseEditPage.jsx (Edit Form)
|
||||
ExerciseCreatePage.jsx (Create Form)
|
||||
|
||||
components/
|
||||
exercise/
|
||||
ExerciseCard.jsx (Grid-Item)
|
||||
ExerciseQuickInfo.jsx (Summary-Box)
|
||||
ExerciseSection.jsx (Collapsible Section)
|
||||
VariantsList.jsx (Varianten-Akkordeon)
|
||||
VariantCard.jsx (Einzelne Variante)
|
||||
MediaGallery.jsx (Medien-Display)
|
||||
MediaUploader.jsx (Upload/Embed-UI)
|
||||
SkillsList.jsx (Fähigkeiten-Chips)
|
||||
CatalogAssignments.jsx (Zuordnungen-Display)
|
||||
|
||||
forms/
|
||||
ExerciseForm.jsx (Haupt-Formular, wiederverwendbar)
|
||||
VariantForm.jsx (Varianten-Formular)
|
||||
MediaUploadForm.jsx (Upload/Embed-Tabs)
|
||||
MultiSelect.jsx (M:N Auswahl mit Primary-Toggle)
|
||||
SkillsSelector.jsx (Skills mit Intensity/Level)
|
||||
|
||||
common/
|
||||
Chip.jsx (Badge/Tag)
|
||||
Badge.jsx (Status/Visibility)
|
||||
Accordion.jsx (Collapsible Section)
|
||||
Modal.jsx (Generisches Modal)
|
||||
DragDropList.jsx (Reorder-Support)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 State-Management
|
||||
|
||||
**Strategie: Minimal, lokal, server-zentriert**
|
||||
|
||||
- **Local State:** `useState` für UI (filter open/close, modal visible)
|
||||
- **Server State:** `useEffect` + API-Calls (keine globale Cache)
|
||||
- **Kein Redux/Zustand:** Context nur für Auth + Profile
|
||||
- **Optimistic Updates:** Nur bei unkritischen Actions (reorder)
|
||||
|
||||
**Beispiel (ExercisesPage):**
|
||||
```jsx
|
||||
const [exercises, setExercises] = useState([])
|
||||
const [filters, setFilters] = useState({focus_area: '', status: ''})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadExercises(filters)
|
||||
}, [filters])
|
||||
|
||||
const loadExercises = async (filters) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.listExercises(filters)
|
||||
setExercises(data)
|
||||
} catch (err) {
|
||||
alert(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Datenbank-Konventionen
|
||||
|
||||
### 4.1 Naming
|
||||
|
||||
- **Tabellen:** Plural, snake_case (`exercises`, `exercise_variants`)
|
||||
- **Spalten:** snake_case (`created_at`, `is_primary`)
|
||||
- **IDs:** `{table}_id` (`exercise_id`, `skill_id`)
|
||||
- **Flags:** `is_*`, `has_*` (`is_primary`, `is_template`, `has_media`)
|
||||
- **Timestamps:** `created_at`, `updated_at`
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Standard-Spalten (IMMER)
|
||||
|
||||
**Pflicht für alle Tabellen:**
|
||||
```sql
|
||||
id SERIAL PRIMARY KEY
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
updated_at TIMESTAMP DEFAULT NOW() -- nur bei mutablen Tabellen
|
||||
```
|
||||
|
||||
**Mutable vs. Immutable:**
|
||||
- **Mutable:** `exercises`, `exercise_variants`, `exercise_blocks` → haben `updated_at`
|
||||
- **Immutable:** `exercise_skills`, `exercise_focus_areas` → kein `updated_at`
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Foreign Keys
|
||||
|
||||
**Regeln:**
|
||||
- **Immer mit ON DELETE:** `CASCADE` oder `RESTRICT` (nie ohne!)
|
||||
- **Immer mit Index:** Für alle FK-Spalten
|
||||
- **Naming:** `fk_{from_table}_{to_table}` (optional, für Debugging)
|
||||
|
||||
**Beispiele:**
|
||||
```sql
|
||||
-- CASCADE: Löschen der Parent-Ressource löscht auch Children
|
||||
exercise_id INT REFERENCES exercises(id) ON DELETE CASCADE
|
||||
|
||||
-- RESTRICT: Löschen der Parent-Ressource verhindert wenn Children existieren
|
||||
skill_id INT REFERENCES skills(id) ON DELETE RESTRICT
|
||||
|
||||
-- SET NULL: Löschen setzt FK auf NULL (für optionale Beziehungen)
|
||||
prerequisite_variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 JSONB vs. Tabellen
|
||||
|
||||
**JSONB verwenden wenn:**
|
||||
- Daten unstrukturiert
|
||||
- Selten gefiltert/gesucht
|
||||
- Flexible Schema-Änderungen nötig
|
||||
|
||||
**Beispiele:**
|
||||
- `equipment` JSONB - Liste von Geräten (["Matten", "Pratzen"])
|
||||
- `secondary_method_ids` JSONB - Liste von IDs ([2, 5, 7])
|
||||
- `placeholder_criteria` JSONB - Filter-Kriterien für Platzhalter
|
||||
|
||||
**Tabelle verwenden wenn:**
|
||||
- Daten strukturiert
|
||||
- Oft gefiltert/gesucht
|
||||
- Referentielle Integrität nötig (Foreign Keys)
|
||||
|
||||
**Beispiele:**
|
||||
- `exercise_skills` - M:N Beziehung, oft gefiltert
|
||||
- `exercise_variants` - Strukturierte Daten, oft einzeln abgerufen
|
||||
- `exercise_media` - Referentielle Integrität (ON DELETE CASCADE)
|
||||
|
||||
---
|
||||
|
||||
## 5. Migrations-Strategie
|
||||
|
||||
### 5.1 Naming
|
||||
|
||||
**Format:** `{NNN}_{descriptive_name}.sql`
|
||||
- NNN = laufende Nummer (3-stellig mit führenden Nullen)
|
||||
- Beschreibend, nicht kryptisch
|
||||
- Beispiele:
|
||||
- `014_variants_progression.sql`
|
||||
- `015_media_upload.sql`
|
||||
- `016_exercise_blocks.sql`
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Idempotenz
|
||||
|
||||
**Regel:** Migrationen müssen mehrfach ausführbar sein ohne Fehler.
|
||||
|
||||
**Immer verwenden:**
|
||||
```sql
|
||||
-- Tabellen
|
||||
CREATE TABLE IF NOT EXISTS ...
|
||||
|
||||
-- Spalten
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='...' AND column_name='...') THEN
|
||||
ALTER TABLE ... ADD COLUMN ...;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Indizes
|
||||
CREATE INDEX IF NOT EXISTS ...
|
||||
|
||||
-- Constraints
|
||||
ALTER TABLE ... ADD CONSTRAINT ... IF NOT EXISTS ...
|
||||
```
|
||||
|
||||
**Niemals:**
|
||||
```sql
|
||||
DROP TABLE ... -- ohne IF EXISTS
|
||||
ALTER TABLE ... DROP COLUMN ... -- ohne Check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.3 Data Migration
|
||||
|
||||
**Reihenfolge bei Schema-Änderungen:**
|
||||
1. **Backup:** Alte Daten sichern (z.B. `_backup` Suffix)
|
||||
2. **Schema-Änderung:** Neue Spalten/Tabellen hinzufügen
|
||||
3. **Data-Migration:** Alte Daten in neues Schema überführen
|
||||
4. **Validation:** Daten-Integrität prüfen
|
||||
5. **Cleanup:** Alte Spalten/Tabellen als deprecated markieren (nicht sofort löschen!)
|
||||
|
||||
**Beispiel (Migration 008):**
|
||||
```sql
|
||||
-- 1. Backup (implizit via alte Spalten behalten)
|
||||
-- 2. Schema
|
||||
CREATE TABLE exercise_focus_areas ...
|
||||
|
||||
-- 3. Data Migration
|
||||
INSERT INTO exercise_focus_areas (exercise_id, focus_area_id, is_primary)
|
||||
SELECT id, focus_area_id, true
|
||||
FROM exercises
|
||||
WHERE focus_area_id IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 4. Validation (manuell via Query)
|
||||
-- 5. Cleanup (später, wenn sicher)
|
||||
-- ALTER TABLE exercises DROP COLUMN focus_area_id; -- NICHT JETZT!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.4 Rollback-Plan
|
||||
|
||||
**Jede Migration braucht:**
|
||||
- Dokumentierter Rollback-Plan (im Kommentar)
|
||||
- Test auf Dev-System
|
||||
- Validierung nach Ausführung
|
||||
|
||||
**Beispiel:**
|
||||
```sql
|
||||
-- Migration 014: Variants Progression
|
||||
-- Rollback: DROP COLUMNS progression_level, sequence_order, prerequisite_variant_id
|
||||
-- Risk: LOW (nur neue Spalten, kein Data Loss)
|
||||
-- Validation: SELECT COUNT(*) FROM exercise_variants WHERE progression_level IS NOT NULL;
|
||||
|
||||
ALTER TABLE exercise_variants
|
||||
ADD COLUMN IF NOT EXISTS progression_level INT;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing-Strategie (Phase 2)
|
||||
|
||||
### 6.1 Backend
|
||||
|
||||
**Unit-Tests:**
|
||||
- Resolver-Funktionen (z.B. `get_exercise()`)
|
||||
- Validation-Logik (z.B. `validate_exercise()`)
|
||||
- Helper-Funktionen (z.B. `parse_embed_url()`)
|
||||
|
||||
**Integration-Tests:**
|
||||
- API-Endpoints (Request → Response)
|
||||
- DB-Transaktionen (Create → Read → Update → Delete)
|
||||
- Permissions (Auth + Visibility)
|
||||
|
||||
**Migration-Tests:**
|
||||
- Up/Down-Migrationen
|
||||
- Data-Integrity nach Migration
|
||||
|
||||
---
|
||||
|
||||
### 6.2 Frontend
|
||||
|
||||
**Component-Tests (Jest + React Testing Library):**
|
||||
- ExerciseCard rendert korrekt
|
||||
- ExerciseForm validiert Inputs
|
||||
- MultiSelect verhält sich korrekt
|
||||
|
||||
**E2E-Tests (Playwright):**
|
||||
- User kann Übung erstellen
|
||||
- User kann Übung bearbeiten
|
||||
- User kann Varianten hinzufügen
|
||||
- User kann Medien hochladen
|
||||
|
||||
**Visual Regression (optional):**
|
||||
- Chromatic / Percy
|
||||
- Screenshots vergleichen
|
||||
|
||||
---
|
||||
|
||||
## 7. Dokumentations-Konventionen
|
||||
|
||||
### 7.1 Code-Kommentare
|
||||
|
||||
**Python Docstrings:**
|
||||
```python
|
||||
def get_exercise(exercise_id: int, session: dict) -> dict:
|
||||
"""
|
||||
Get exercise by ID with all enriched data.
|
||||
|
||||
Args:
|
||||
exercise_id: Exercise ID
|
||||
session: Auth session from require_auth()
|
||||
|
||||
Returns:
|
||||
dict: Exercise object with skills, variants, media, catalog assignments
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if not found, 403 if forbidden
|
||||
"""
|
||||
```
|
||||
|
||||
**JSDoc (für komplexe Komponenten):**
|
||||
```jsx
|
||||
/**
|
||||
* ExerciseCard - Grid item für Übungsdarstellung
|
||||
*
|
||||
* @param {Object} exercise - Exercise object from API
|
||||
* @param {Function} onClick - Handler für Detail-Klick
|
||||
* @param {Function} onEdit - Handler für Edit-Klick
|
||||
* @param {Function} onDelete - Handler für Delete-Klick
|
||||
* @param {boolean} compact - Kompakte Darstellung (optional)
|
||||
*/
|
||||
export function ExerciseCard({ exercise, onClick, onEdit, onDelete, compact = false }) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Inline-Kommentare:**
|
||||
- Nur für nicht-offensichtliche Logik
|
||||
- Warum, nicht Was
|
||||
- Beispiel: `// Prerequisite muss zur gleichen Übung gehören`
|
||||
|
||||
---
|
||||
|
||||
### 7.2 Markdown-Docs
|
||||
|
||||
**Struktur:**
|
||||
- `functional/` - Fachliche Specs (WAS soll gebaut werden)
|
||||
- `technical/` - Technische Specs (WIE wird es gebaut)
|
||||
- `working/` - Temporäre Analysen, Notizen (kann veraltet sein)
|
||||
- `audit/` - Reviews, Matrizen, Checklisten
|
||||
|
||||
**Update-Regel:**
|
||||
- Nach jedem Feature: Doku aktualisieren
|
||||
- Version im Header erhöhen
|
||||
- Änderungslog im Dokument
|
||||
|
||||
---
|
||||
|
||||
## 8. Entscheidungs-Log
|
||||
|
||||
| Datum | Entscheidung | Rationale | Impact |
|
||||
|-------|--------------|-----------|--------|
|
||||
| 2026-04-24 | M:N statt 1:1 für Katalog-Zuordnungen | Flexibilität, keine Duplikate, bessere Filter | Alle Übungen müssen migriert werden (Migration 008) |
|
||||
| 2026-04-24 | Varianten für Progression statt eigene Serie-Tabelle | Einfacher, weniger Joins, direkte Beziehung | Variants-Tabelle erweitern (Migration 014) |
|
||||
| 2026-04-24 | Lokale Medien + Embeds (nicht Cloud) | Kostenkontrolle, Datenschutz, keine Vendor-Lock-in | Storage-Management nötig, Backup-Strategie |
|
||||
| 2026-04-24 | Eigene Routes statt Modals für Edit/Create | Deep-Linking, Browser-Navigation, Mobile-UX | Mehr Routes, aber bessere UX |
|
||||
| 2026-04-24 | MediaWiki API statt Export | Live-Sync möglich, aktuellere Daten | API-Client implementieren, Semantic MediaWiki verstehen |
|
||||
| 2026-04-24 | JSONB für Equipment, nicht eigene Tabelle | Flexibel, selten gefiltert, keine Overhead | Keine Referentielle Integrität, Full-Text-Suche schwieriger |
|
||||
|
||||
---
|
||||
|
||||
## 9. Offene Punkte / TODOs
|
||||
|
||||
**Für Review:**
|
||||
- [ ] Sichtbarkeits-Matrix OK? (private/club/official)
|
||||
- [ ] Freigabe-Workflow ausreichend einfach?
|
||||
- [ ] Medien-Limits OK? (50MB, 10 Files)
|
||||
- [ ] JSONB vs. Tabellen für Equipment richtig entschieden?
|
||||
|
||||
**Für Phase 2:**
|
||||
- [ ] Explizite Freigabe-Requests (content_change_requests)
|
||||
- [ ] Saved Searches
|
||||
- [ ] Advanced Filters (Kombination mehrerer Kriterien)
|
||||
- [ ] Batch-Operations (Multi-Select + Delete/Archive)
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0
|
||||
**Letzte Änderung:** 2026-04-24
|
||||
**Status:** DRAFT - Awaiting Review
|
||||
910
.claude/docs/technical/EXERCISES_DATABASE_FINAL.md
Normal file
910
.claude/docs/technical/EXERCISES_DATABASE_FINAL.md
Normal file
|
|
@ -0,0 +1,910 @@
|
|||
# Database Schema Final - Exercises System
|
||||
|
||||
**Version:** 1.2
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** REVIEWED - Pending Implementation
|
||||
**Autor:** Claude Code
|
||||
**Änderungen v1.2:** Skill-Level auf benannte Stufen (einsteiger–experte), intensity auf niedrig/mittel/hoch, ai_suggested Felder, summary_ai_generated
|
||||
**Änderungen v1.1:** age_groups JSONB entfernt, Legacy-DROP ergänzt, Migration 017 (Exercise Blocks)
|
||||
|
||||
---
|
||||
|
||||
## 1. Übersicht
|
||||
|
||||
**Neue Migrationen:**
|
||||
- **Migration 014:** Variant Progression System + Search Vector + Legacy-Cleanup
|
||||
- **Migration 015:** Semantic Matching (OPTIONAL - Phase 2)
|
||||
- **Migration 016:** Saved Searches
|
||||
- **Migration 017:** Exercise Blocks + Template Blocks
|
||||
|
||||
**Basis:** Migrationen 001-013 (bereits deployed)
|
||||
|
||||
---
|
||||
|
||||
## 2. Migration 014: Variant Progression + Search
|
||||
|
||||
### 2.1 Vollständige Migration
|
||||
|
||||
```sql
|
||||
-- Migration 014: Variant Progression System + Search Vector
|
||||
-- Autor: Claude Code
|
||||
-- Datum: 2026-04-24
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
|
||||
-- ============================================================================
|
||||
-- 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(training_style_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 $$;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Migration 015: Semantic Matching (Optional Phase 2)
|
||||
|
||||
**Diese Migration ist optional für MVP Phase 1**
|
||||
|
||||
```sql
|
||||
-- Migration 015: Semantic Exercise Matching (OPTIONAL - Phase 2)
|
||||
-- Nutzt pgvector extension für Similarity-Matching
|
||||
-- Autor: Claude Code
|
||||
-- Datum: 2026-04-24
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
|
||||
-- ============================================================================
|
||||
-- SEMANTIC EMBEDDINGS (Optional)
|
||||
-- ============================================================================
|
||||
|
||||
-- Benötigt pgvector extension
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
-- Erweitere exercises Tabelle
|
||||
ALTER TABLE exercises
|
||||
ADD COLUMN IF NOT EXISTS embedding vector(1536); -- OpenAI ada-002 embedding size
|
||||
|
||||
-- Index für Ähnlichkeitssuche
|
||||
CREATE INDEX IF NOT EXISTS idx_exercises_embedding
|
||||
ON exercises USING ivfflat (embedding vector_cosine_ops)
|
||||
WITH (lists = 100);
|
||||
|
||||
-- Funktion für Similarity-Search
|
||||
CREATE OR REPLACE FUNCTION find_similar_exercises(
|
||||
query_embedding vector(1536),
|
||||
limit_count INT DEFAULT 10
|
||||
)
|
||||
RETURNS TABLE (
|
||||
exercise_id INT,
|
||||
title VARCHAR,
|
||||
similarity FLOAT
|
||||
) AS $func$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
1 - (embedding <=> query_embedding) AS similarity
|
||||
FROM exercises
|
||||
WHERE embedding IS NOT NULL
|
||||
ORDER BY embedding <=> query_embedding
|
||||
LIMIT limit_count;
|
||||
END;
|
||||
$func$ LANGUAGE plpgsql;
|
||||
|
||||
RAISE NOTICE 'Migration 015 completed successfully (Semantic Matching - OPTIONAL)';
|
||||
|
||||
END $$;
|
||||
```
|
||||
|
||||
**Hinweis:** Embeddings werden über separaten Background-Job befüllt, nicht beim INSERT.
|
||||
|
||||
---
|
||||
|
||||
## 4. Migration 016: Saved Searches
|
||||
|
||||
```sql
|
||||
-- Migration 016: Saved Exercise Searches
|
||||
-- Autor: Claude Code
|
||||
-- Datum: 2026-04-24
|
||||
|
||||
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
|
||||
CREATE OR REPLACE FUNCTION update_saved_searches_timestamp()
|
||||
RETURNS trigger AS $func$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$func$ LANGUAGE plpgsql;
|
||||
|
||||
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_saved_searches_timestamp();
|
||||
|
||||
RAISE NOTICE 'Migration 016 completed successfully';
|
||||
|
||||
END $$;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Migration 017: Exercise Blocks + Template Blocks
|
||||
|
||||
```sql
|
||||
-- Migration 017: Exercise Blocks + Template Blocks
|
||||
-- Autor: Claude Code
|
||||
-- Datum: 2026-04-24
|
||||
-- Zweck: Gruppierung verschiedener Übungen in Blöcken (User-Anforderung #4)
|
||||
-- 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 $$;
|
||||
```
|
||||
|
||||
### 5.0 Placeholder Criteria Schema
|
||||
|
||||
Das `placeholder_criteria` JSONB-Feld in `exercise_block_items` erlaubt folgende Schlüssel (alle optional, werden als AND-Filter kombiniert):
|
||||
|
||||
```json
|
||||
{
|
||||
"focus_area_id": 1, // INT: Fokusbereich-ID
|
||||
"training_style_id": 2, // INT: Stil-ID
|
||||
"target_group_id": 5, // INT: Zielgruppen-ID
|
||||
"skill_ids": [3, 7], // INT[]: Mindestens eine dieser Fähigkeiten
|
||||
"max_duration": 15, // INT: Maximale Dauer in Minuten
|
||||
"min_duration": 5, // INT: Minimale Dauer in Minuten
|
||||
"difficulty": "easier", // "easier" | "same" | "harder"
|
||||
"visibility": "club" // "private" | "club" | "official"
|
||||
}
|
||||
```
|
||||
|
||||
**Validierung:** Backend prüft, dass alle vorhandenen Schlüssel bekannte Felder sind und die Werte dem erwarteten Typ entsprechen.
|
||||
|
||||
---
|
||||
|
||||
## 6. Vollständige Tabellenstruktur (Final State)
|
||||
|
||||
### 5.1 exercises (Kern-Tabelle)
|
||||
|
||||
```sql
|
||||
CREATE TABLE exercises (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
-- Basis-Info
|
||||
title VARCHAR(300) NOT NULL,
|
||||
summary TEXT,
|
||||
goal TEXT NOT NULL,
|
||||
execution TEXT NOT NULL,
|
||||
preparation TEXT,
|
||||
trainer_notes TEXT,
|
||||
|
||||
-- Dauer & Gruppengröße
|
||||
duration_min INT,
|
||||
duration_max INT,
|
||||
group_size_min INT,
|
||||
group_size_max INT,
|
||||
|
||||
-- JSONB-Felder
|
||||
equipment JSONB DEFAULT '[]'::jsonb,
|
||||
-- HINWEIS: age_groups werden via exercise_age_groups M:N Tabelle verwaltet (nicht JSONB)
|
||||
|
||||
-- Suche
|
||||
search_vector tsvector, -- NEU in Migration 014
|
||||
|
||||
-- Semantic Matching (optional)
|
||||
embedding vector(1536), -- NEU in Migration 015 (optional)
|
||||
|
||||
-- Sichtbarkeit & Status
|
||||
visibility VARCHAR(20) DEFAULT 'private' CHECK (visibility IN ('private', 'club', 'official')),
|
||||
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'in_review', 'approved', 'archived')),
|
||||
|
||||
-- Ownership
|
||||
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
|
||||
|
||||
-- Import-Tracking
|
||||
import_source VARCHAR(50), -- 'mediawiki', 'csv', etc.
|
||||
import_id VARCHAR(100), -- Original-ID aus Quellsystem
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### 5.2 exercise_variants
|
||||
|
||||
```sql
|
||||
CREATE TABLE exercise_variants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||
|
||||
-- Variant-Details
|
||||
variant_name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
execution_changes TEXT,
|
||||
|
||||
-- Dauer (Override)
|
||||
duration_min INT,
|
||||
duration_max INT,
|
||||
|
||||
-- Equipment-Änderungen
|
||||
equipment_changes JSONB DEFAULT '[]'::jsonb,
|
||||
|
||||
-- Schwierigkeit
|
||||
difficulty_adjustment VARCHAR(20) CHECK (difficulty_adjustment IN ('easier', 'same', 'harder')),
|
||||
|
||||
-- Progression (NEU in Migration 014)
|
||||
progression_level INT DEFAULT 1 CHECK (progression_level BETWEEN 1 AND 10),
|
||||
sequence_order INT,
|
||||
prerequisite_variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### 5.3 exercise_media
|
||||
|
||||
```sql
|
||||
CREATE TABLE exercise_media (
|
||||
id SERIAL PRIMARY KEY,
|
||||
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||
|
||||
-- Media-Type
|
||||
media_type VARCHAR(20) CHECK (media_type IN ('image', 'video', 'document', 'sketch')),
|
||||
|
||||
-- Lokale Datei (exklusiv mit embed_url)
|
||||
file_path VARCHAR(500),
|
||||
file_size INT,
|
||||
mime_type VARCHAR(100),
|
||||
original_filename VARCHAR(300),
|
||||
|
||||
-- Embed (exklusiv mit file_path)
|
||||
embed_url TEXT,
|
||||
embed_platform VARCHAR(50), -- 'youtube', 'vimeo', 'instagram', 'tiktok'
|
||||
|
||||
-- Metadata
|
||||
title VARCHAR(200),
|
||||
description TEXT,
|
||||
sort_order INT DEFAULT 1,
|
||||
is_primary BOOLEAN DEFAULT FALSE,
|
||||
context VARCHAR(50) DEFAULT 'ablauf' CHECK (context IN ('ablauf', 'detail', 'trainer_hint')),
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
-- Constraint: Entweder file_path ODER embed_url
|
||||
CHECK (
|
||||
(file_path IS NOT NULL AND embed_url IS NULL) OR
|
||||
(file_path IS NULL AND embed_url IS NOT NULL)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### 5.4 M:N Relation Tables
|
||||
|
||||
**exercise_focus_areas:**
|
||||
```sql
|
||||
CREATE TABLE exercise_focus_areas (
|
||||
id SERIAL PRIMARY KEY,
|
||||
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||
focus_area_id INT NOT NULL REFERENCES focus_areas(id) ON DELETE RESTRICT,
|
||||
is_primary BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(exercise_id, focus_area_id)
|
||||
);
|
||||
```
|
||||
|
||||
**exercise_training_styles:**
|
||||
```sql
|
||||
CREATE TABLE exercise_training_styles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||
training_style_id INT NOT NULL REFERENCES training_styles(id) ON DELETE RESTRICT,
|
||||
is_primary BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(exercise_id, training_style_id)
|
||||
);
|
||||
```
|
||||
|
||||
**exercise_target_groups:**
|
||||
```sql
|
||||
CREATE TABLE exercise_target_groups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||
target_group_id INT NOT NULL REFERENCES target_groups(id) ON DELETE RESTRICT,
|
||||
is_primary BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(exercise_id, target_group_id)
|
||||
);
|
||||
```
|
||||
|
||||
**exercise_age_groups:**
|
||||
```sql
|
||||
CREATE TABLE exercise_age_groups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||
age_group_name VARCHAR(50) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(exercise_id, age_group_name)
|
||||
);
|
||||
```
|
||||
|
||||
**exercise_skills:**
|
||||
```sql
|
||||
CREATE TABLE exercise_skills (
|
||||
id SERIAL PRIMARY KEY,
|
||||
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||
skill_id INT NOT NULL REFERENCES skills(id) ON DELETE RESTRICT,
|
||||
is_primary BOOLEAN DEFAULT FALSE,
|
||||
|
||||
-- Kompetenzmodell: benannte Stufen (nicht numerisch 1-10)
|
||||
-- NULL = nicht definiert / nicht relevant
|
||||
required_level VARCHAR(20) CHECK (required_level IN ('einsteiger', 'grundlagen', 'aufbau', 'fortgeschritten', 'experte')),
|
||||
target_level VARCHAR(20) CHECK (target_level IN ('einsteiger', 'grundlagen', 'aufbau', 'fortgeschritten', 'experte')),
|
||||
|
||||
-- Trainingsintensität: Wie stark wird diese Fähigkeit in der Übung trainiert
|
||||
intensity VARCHAR(10) CHECK (intensity IN ('niedrig', 'mittel', 'hoch')),
|
||||
|
||||
-- KI-generierte Zuordnung (false = manuell bestätigt)
|
||||
ai_suggested BOOLEAN DEFAULT false,
|
||||
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(exercise_id, skill_id)
|
||||
);
|
||||
```
|
||||
|
||||
**Skill-Level-Definitionen (Kompetenzmodell):**
|
||||
|
||||
| Stufe | Wert | Beschreibung | Beispiel Distanzgefühl |
|
||||
|-------|------|--------------|----------------------|
|
||||
| 1 | `einsteiger` | Erste Berührung mit der Fähigkeit, kein Vorwissen nötig | Versteht das Konzept Distanz |
|
||||
| 2 | `grundlagen` | Grundprinzipien bekannt, in einfachen Situationen anwendbar | Hält in ruhigen Übungen Distanz |
|
||||
| 3 | `aufbau` | Sicher in Standard-Situationen, braucht noch Korrektur | Distanzkontrolle in Partnerübungen |
|
||||
| 4 | `fortgeschritten` | Zuverlässig auch unter Druck, wenig Fehler | Stabile Distanz im Sparring |
|
||||
| 5 | `experte` | Automatisiert, intuitiv, auch in komplexen Situationen | Feines Distanzgefühl im Wettkampf |
|
||||
|
||||
**Für Migration 014 ergänzen:**
|
||||
```sql
|
||||
-- In Migration 014 nach den Variant-Columns ergänzen:
|
||||
-- Skill-Level auf VARCHAR umstellen (bestehende INT-Werte migrieren)
|
||||
ALTER TABLE exercise_skills
|
||||
ALTER COLUMN required_level TYPE VARCHAR(20) USING
|
||||
CASE required_level
|
||||
WHEN 0 THEN NULL
|
||||
WHEN 1 THEN 'einsteiger'
|
||||
WHEN 2 THEN 'grundlagen'
|
||||
WHEN 3 THEN 'aufbau'
|
||||
WHEN 4 THEN 'fortgeschritten'
|
||||
WHEN 5 THEN 'experte'
|
||||
ELSE NULL
|
||||
END,
|
||||
ALTER COLUMN target_level TYPE VARCHAR(20) USING
|
||||
CASE target_level
|
||||
WHEN 0 THEN NULL
|
||||
WHEN 1 THEN 'einsteiger'
|
||||
WHEN 2 THEN 'grundlagen'
|
||||
WHEN 3 THEN 'aufbau'
|
||||
WHEN 4 THEN 'fortgeschritten'
|
||||
WHEN 5 THEN 'experte'
|
||||
ELSE NULL
|
||||
END,
|
||||
ALTER COLUMN intensity TYPE VARCHAR(10) USING
|
||||
CASE
|
||||
WHEN intensity <= 3 THEN 'niedrig'
|
||||
WHEN intensity <= 7 THEN 'mittel'
|
||||
ELSE 'hoch'
|
||||
END;
|
||||
|
||||
-- Neue Constraints hinzufügen
|
||||
ALTER TABLE exercise_skills
|
||||
ADD CONSTRAINT ck_required_level CHECK (required_level IN ('einsteiger', 'grundlagen', 'aufbau', 'fortgeschritten', 'experte')),
|
||||
ADD CONSTRAINT ck_target_level CHECK (target_level IN ('einsteiger', 'grundlagen', 'aufbau', 'fortgeschritten', 'experte')),
|
||||
ADD CONSTRAINT ck_intensity CHECK (intensity IN ('niedrig', 'mittel', 'hoch'));
|
||||
|
||||
-- KI-Tracking Feld
|
||||
ALTER TABLE exercises
|
||||
ADD COLUMN IF NOT EXISTS summary_ai_generated BOOLEAN DEFAULT false;
|
||||
|
||||
ALTER TABLE exercise_skills
|
||||
ADD COLUMN IF NOT EXISTS ai_suggested BOOLEAN DEFAULT false;
|
||||
```
|
||||
|
||||
**exercise_training_characters:**
|
||||
```sql
|
||||
CREATE TABLE exercise_training_characters (
|
||||
id SERIAL PRIMARY KEY,
|
||||
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||
training_character_id INT NOT NULL REFERENCES training_characters(id) ON DELETE RESTRICT,
|
||||
is_primary BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(exercise_id, training_character_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 6.5 Exercise Blocks (Migration 017)
|
||||
|
||||
```sql
|
||||
CREATE TABLE exercise_blocks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
goal TEXT,
|
||||
is_template BOOLEAN DEFAULT false,
|
||||
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')),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE exercise_block_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
block_id INT NOT NULL REFERENCES exercise_blocks(id) ON DELETE CASCADE,
|
||||
exercise_id INT REFERENCES exercises(id) ON DELETE RESTRICT,
|
||||
variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL,
|
||||
sequence_order INT NOT NULL,
|
||||
is_placeholder BOOLEAN DEFAULT false,
|
||||
placeholder_criteria JSONB,
|
||||
placeholder_label VARCHAR(100),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(block_id, sequence_order),
|
||||
CHECK (
|
||||
(is_placeholder = false AND exercise_id IS NOT NULL) OR
|
||||
(is_placeholder = true AND exercise_id IS NULL)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### 6.6 Saved Searches (Migration 016)
|
||||
|
||||
```sql
|
||||
CREATE TABLE 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()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Indizes (Komplett-Übersicht)
|
||||
|
||||
```sql
|
||||
-- Volltext-Suche
|
||||
CREATE INDEX idx_exercises_search ON exercises USING gin(search_vector);
|
||||
|
||||
-- Semantic Matching (optional)
|
||||
CREATE INDEX idx_exercises_embedding ON exercises USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
|
||||
|
||||
-- Häufige Filter
|
||||
CREATE INDEX idx_exercises_visibility ON exercises(visibility);
|
||||
CREATE INDEX idx_exercises_status ON exercises(status);
|
||||
CREATE INDEX idx_exercises_created_at ON exercises(created_at DESC);
|
||||
CREATE INDEX idx_exercises_club ON exercises(club_id);
|
||||
CREATE INDEX idx_exercises_creator ON exercises(created_by);
|
||||
|
||||
-- M:N Relations
|
||||
CREATE INDEX idx_exercise_focus_areas_exercise ON exercise_focus_areas(exercise_id);
|
||||
CREATE INDEX idx_exercise_focus_areas_focus ON exercise_focus_areas(focus_area_id);
|
||||
|
||||
CREATE INDEX idx_exercise_styles_exercise ON exercise_training_styles(exercise_id);
|
||||
CREATE INDEX idx_exercise_styles_style ON exercise_training_styles(training_style_id);
|
||||
|
||||
CREATE INDEX idx_exercise_target_groups_exercise ON exercise_target_groups(exercise_id);
|
||||
CREATE INDEX idx_exercise_target_groups_group ON exercise_target_groups(target_group_id);
|
||||
|
||||
CREATE INDEX idx_exercise_skills_exercise ON exercise_skills(exercise_id);
|
||||
CREATE INDEX idx_exercise_skills_skill ON exercise_skills(skill_id);
|
||||
|
||||
CREATE INDEX idx_exercise_characters_exercise ON exercise_training_characters(exercise_id);
|
||||
CREATE INDEX idx_exercise_characters_character ON exercise_training_characters(training_character_id);
|
||||
|
||||
-- Variants
|
||||
CREATE INDEX idx_exercise_variants_exercise ON exercise_variants(exercise_id);
|
||||
CREATE INDEX idx_exercise_variants_prerequisite ON exercise_variants(prerequisite_variant_id);
|
||||
|
||||
-- Media
|
||||
CREATE INDEX idx_exercise_media_exercise ON exercise_media(exercise_id);
|
||||
CREATE INDEX idx_exercise_media_primary ON exercise_media(is_primary) WHERE is_primary = true;
|
||||
CREATE INDEX idx_exercise_media_context ON exercise_media(context);
|
||||
|
||||
-- Saved Searches
|
||||
CREATE INDEX idx_saved_searches_profile ON saved_exercise_searches(profile_id);
|
||||
|
||||
-- Exercise Blocks
|
||||
CREATE INDEX idx_exercise_blocks_club ON exercise_blocks(club_id);
|
||||
CREATE INDEX idx_exercise_blocks_creator ON exercise_blocks(created_by);
|
||||
CREATE INDEX idx_exercise_blocks_visibility ON exercise_blocks(visibility);
|
||||
CREATE INDEX idx_exercise_blocks_template ON exercise_blocks(is_template) WHERE is_template = true;
|
||||
|
||||
CREATE INDEX idx_exercise_block_items_block ON exercise_block_items(block_id);
|
||||
CREATE INDEX idx_exercise_block_items_exercise ON exercise_block_items(exercise_id);
|
||||
CREATE INDEX idx_exercise_block_items_placeholder ON exercise_block_items(is_placeholder) WHERE is_placeholder = true;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Constraints & Business Rules
|
||||
|
||||
### 7.1 Data Integrity
|
||||
|
||||
**Exercises:**
|
||||
- `title` mindestens 3 Zeichen
|
||||
- `goal` mindestens 10 Zeichen
|
||||
- `execution` mindestens 10 Zeichen
|
||||
- `duration_min <= duration_max`
|
||||
- `group_size_min <= group_size_max`
|
||||
|
||||
**Variants:**
|
||||
- `variant_name` mindestens 3 Zeichen
|
||||
- `prerequisite_variant_id` muss zum gleichen Exercise gehören
|
||||
|
||||
**Media:**
|
||||
- Max. 50 MB per file (enforced in Backend)
|
||||
- Max. 10 media items per exercise (enforced in Backend)
|
||||
- Entweder `file_path` ODER `embed_url` (enforced in CHECK)
|
||||
|
||||
### 7.2 Cascading Delete
|
||||
|
||||
**ON DELETE CASCADE:**
|
||||
- exercise → variants
|
||||
- exercise → media
|
||||
- exercise → M:N relations (focus_areas, styles, etc.)
|
||||
- profile → saved searches
|
||||
|
||||
**ON DELETE RESTRICT:**
|
||||
- focus_area → exercise_focus_areas (verhindert Löschen genutzter Katalog-Einträge)
|
||||
- skill → exercise_skills
|
||||
- target_group → exercise_target_groups
|
||||
|
||||
**ON DELETE SET NULL:**
|
||||
- profile (creator) → exercises
|
||||
- club → exercises
|
||||
- variant (prerequisite) → variants
|
||||
|
||||
---
|
||||
|
||||
## 8. Trigger-Funktionen
|
||||
|
||||
### 8.1 Search Vector Auto-Update
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION update_exercises_search_vector()
|
||||
RETURNS trigger AS $$
|
||||
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;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER exercises_search_update
|
||||
BEFORE INSERT OR UPDATE ON exercises
|
||||
FOR EACH ROW EXECUTE FUNCTION update_exercises_search_vector();
|
||||
```
|
||||
|
||||
### 8.2 Updated_At Timestamp
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION update_timestamp()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger für alle relevanten Tabellen
|
||||
CREATE TRIGGER exercises_update_timestamp
|
||||
BEFORE UPDATE ON exercises
|
||||
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
|
||||
|
||||
CREATE TRIGGER exercise_variants_update_timestamp
|
||||
BEFORE UPDATE ON exercise_variants
|
||||
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
|
||||
|
||||
CREATE TRIGGER exercise_media_update_timestamp
|
||||
BEFORE UPDATE ON exercise_media
|
||||
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
|
||||
|
||||
CREATE TRIGGER saved_searches_update_timestamp
|
||||
BEFORE UPDATE ON saved_exercise_searches
|
||||
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Migrations-Tracking
|
||||
|
||||
**Tabelle: schema_migrations**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
migration_id VARCHAR(50) PRIMARY KEY,
|
||||
description TEXT,
|
||||
applied_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**Nach jeder Migration:**
|
||||
```sql
|
||||
INSERT INTO schema_migrations (migration_id, description) VALUES
|
||||
('014', 'Variant Progression System + Search Vector + Legacy-Cleanup'),
|
||||
('015', 'Semantic Exercise Matching (OPTIONAL)'),
|
||||
('016', 'Saved Exercise Searches'),
|
||||
('017', 'Exercise Blocks + Template Blocks');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Datenbank-Größenabschätzung
|
||||
|
||||
**Annahmen:**
|
||||
- 5.000 Übungen
|
||||
- Durchschnittlich 2 Varianten pro Übung
|
||||
- Durchschnittlich 3 Media-Items pro Übung
|
||||
- Durchschnittlich 5 M:N Zuordnungen pro Übung
|
||||
|
||||
**Geschätzte Zeilen:**
|
||||
```
|
||||
exercises: 5.000
|
||||
exercise_variants: 10.000
|
||||
exercise_media: 15.000
|
||||
exercise_focus_areas: 10.000
|
||||
exercise_training_styles: 10.000
|
||||
exercise_target_groups: 10.000
|
||||
exercise_skills: 25.000
|
||||
exercise_blocks: 1.000
|
||||
exercise_block_items: 5.000
|
||||
─────────────────────────────────────
|
||||
TOTAL: 101.000 Zeilen
|
||||
```
|
||||
|
||||
**Speicherbedarf (ohne Media-Files):**
|
||||
- exercises Tabelle: ~50 MB (mit search_vector + embedding)
|
||||
- Alle M:N Tabellen: ~10 MB
|
||||
- Variants + Media: ~5 MB
|
||||
- **Total DB:** ~65 MB
|
||||
|
||||
**Media-Files (lokal gespeichert):**
|
||||
- 15.000 Items × 5 MB Durchschnitt = ~75 GB
|
||||
|
||||
---
|
||||
|
||||
## 11. Performance-Benchmarks
|
||||
|
||||
**Target Query Performance:**
|
||||
- List Exercises (ohne Filter): < 50ms
|
||||
- List Exercises (mit 3 Filtern): < 100ms
|
||||
- Volltext-Suche: < 150ms
|
||||
- Exercise Detail (enriched): < 80ms
|
||||
- Semantic Similarity: < 200ms (optional)
|
||||
|
||||
**Optimierungen:**
|
||||
- GIN-Index für tsvector → 10x schnellere Suche
|
||||
- IVFFlat-Index für Embeddings → 50x schnellere Similarity-Search
|
||||
- Partial Index für `is_primary` Media → schnellere Primary-Lookups
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0
|
||||
**Letzte Änderung:** 2026-04-24
|
||||
**Status:** DRAFT - Awaiting Review
|
||||
681
.claude/docs/technical/EXERCISES_FRONTEND_ROUTING.md
Normal file
681
.claude/docs/technical/EXERCISES_FRONTEND_ROUTING.md
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
# Frontend Routing & Navigation Specification
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** DRAFT - Awaiting Review
|
||||
**Autor:** Claude Code
|
||||
|
||||
---
|
||||
|
||||
## 1. Route-Struktur
|
||||
|
||||
### 1.1 Route-Übersicht
|
||||
|
||||
```
|
||||
/exercises → ExercisesListPage (Grid + Filter)
|
||||
/exercises/new → ExerciseFormPage (Create)
|
||||
/exercises/{id} → ExerciseDetailPage (Accordion-Layout)
|
||||
/exercises/{id}/edit → ExerciseFormPage (Edit)
|
||||
/exercises/{id}/variants/new → VariantFormPage (Create)
|
||||
/exercises/{id}/variants/{vid}/edit → VariantFormPage (Edit)
|
||||
|
||||
/exercise-blocks → ExerciseBlocksListPage (Meine Blocks)
|
||||
/exercise-blocks/new → ExerciseBlockFormPage (Create)
|
||||
/exercise-blocks/{id} → ExerciseBlockDetailPage (mit BlockEditor)
|
||||
/exercise-blocks/{id}/edit → ExerciseBlockFormPage (Edit)
|
||||
|
||||
/admin/exercises/catalog → AdminCatalogPage (Fokusbereiche, Stile, Zielgruppen, Altersgruppen)
|
||||
/admin/exercises/skills → AdminSkillsPage (Fähigkeiten-Matrix)
|
||||
/admin/exercises/methods → AdminMethodsPage (Methoden-Katalog)
|
||||
```
|
||||
|
||||
**URL-Parameter-Konventionen:**
|
||||
- IDs immer numerisch: `/exercises/42` (nicht `/exercises/uuid`)
|
||||
- Query-Parameter für Filter: `/exercises?focus_area=1&visibility=club`
|
||||
- Pagination: `/exercises?limit=50&offset=100`
|
||||
- Sortierung: `/exercises?sort=created_at&order=desc`
|
||||
|
||||
---
|
||||
|
||||
## 2. Navigation-Patterns
|
||||
|
||||
### 2.1 Haupt-Navigation (Desktop + Tablet)
|
||||
|
||||
**Primäre Nav (Top-Level):**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Shinkan │ Übungen │ Planung │ Admin │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Übungen-Submenu (Dropdown):**
|
||||
```
|
||||
Übungen
|
||||
├─ Alle Übungen → /exercises
|
||||
├─ Neue Übung → /exercises/new
|
||||
├─ Meine Blocks → /exercise-blocks
|
||||
├─ Neuer Block → /exercise-blocks/new
|
||||
└─ Katalog verwalten → /admin/exercises/catalog (nur Admin)
|
||||
```
|
||||
|
||||
### 2.2 Mobile Navigation (Hamburger-Menu)
|
||||
|
||||
```
|
||||
☰ Menu
|
||||
├─ Übungen
|
||||
│ ├─ Alle anzeigen
|
||||
│ ├─ Neue Übung
|
||||
│ └─ Filter & Suche
|
||||
├─ Planung
|
||||
└─ Admin
|
||||
└─ Katalog
|
||||
```
|
||||
|
||||
### 2.3 Breadcrumbs (Detail/Edit-Seiten)
|
||||
|
||||
**Pattern:**
|
||||
```
|
||||
Home → Übungen → [Übungsname] → Bearbeiten
|
||||
Home → Übungen → [Übungsname] → Variante anlegen
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
- Breadcrumbs dynamisch aus Route-Parametern generiert
|
||||
- Letztes Element (aktuelle Seite) nicht klickbar
|
||||
- Max. 4 Ebenen, dann `...` für gekürzte Pfade
|
||||
|
||||
---
|
||||
|
||||
## 3. State Management
|
||||
|
||||
### 3.1 URL-State (Query-Parameter)
|
||||
|
||||
**Filter-State in URL persistieren:**
|
||||
```javascript
|
||||
// ExercisesListPage.jsx
|
||||
const [filters, setFilters] = useState({
|
||||
focus_area: parseInt(searchParams.get('focus_area')) || null,
|
||||
visibility: searchParams.get('visibility') || null,
|
||||
status: searchParams.get('status') || null,
|
||||
search: searchParams.get('search') || '',
|
||||
limit: parseInt(searchParams.get('limit')) || 50,
|
||||
offset: parseInt(searchParams.get('offset')) || 0,
|
||||
})
|
||||
|
||||
// Bei Filter-Änderung → URL aktualisieren
|
||||
const updateFilters = (newFilters) => {
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(newFilters).forEach(([key, val]) => {
|
||||
if (val) params.set(key, val)
|
||||
})
|
||||
navigate(`/exercises?${params.toString()}`)
|
||||
}
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
- Sharable URLs (Filter-Zustand übertragbar)
|
||||
- Browser-Back funktioniert korrekt
|
||||
- Keine verlorenen Filter beim Reload
|
||||
|
||||
### 3.2 Local State (React useState)
|
||||
|
||||
**Form-State (nicht in URL):**
|
||||
```javascript
|
||||
// ExerciseFormPage.jsx
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
summary: '',
|
||||
goal: '',
|
||||
execution: '',
|
||||
// ... weitere Felder
|
||||
})
|
||||
```
|
||||
|
||||
**UI-State (nicht in URL):**
|
||||
```javascript
|
||||
// ExerciseDetailPage.jsx
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
basics: true,
|
||||
execution: false,
|
||||
variants: false,
|
||||
media: false,
|
||||
})
|
||||
```
|
||||
|
||||
### 3.3 Context (Global State)
|
||||
|
||||
**Katalog-Daten (einmalig laden, global verfügbar):**
|
||||
```javascript
|
||||
// CatalogContext.jsx
|
||||
const CatalogContext = createContext()
|
||||
|
||||
export function CatalogProvider({ children }) {
|
||||
const [focusAreas, setFocusAreas] = useState([])
|
||||
const [trainingStyles, setTrainingStyles] = useState([])
|
||||
const [targetGroups, setTargetGroups] = useState([])
|
||||
const [ageGroups, setAgeGroups] = useState([])
|
||||
const [skills, setSkills] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
// Kataloge einmalig beim App-Start laden
|
||||
Promise.all([
|
||||
api.getFocusAreas(),
|
||||
api.getTrainingStyles(),
|
||||
api.getTargetGroups(),
|
||||
api.getAgeGroups(),
|
||||
api.getSkills(),
|
||||
]).then(([fa, ts, tg, ag, sk]) => {
|
||||
setFocusAreas(fa)
|
||||
setTrainingStyles(ts)
|
||||
setTargetGroups(tg)
|
||||
setAgeGroups(ag)
|
||||
setSkills(sk)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<CatalogContext.Provider value={{
|
||||
focusAreas, trainingStyles, targetGroups, ageGroups, skills
|
||||
}}>
|
||||
{children}
|
||||
</CatalogContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useCatalog = () => useContext(CatalogContext)
|
||||
```
|
||||
|
||||
**Usage in Komponenten:**
|
||||
```javascript
|
||||
// ExerciseFormPage.jsx
|
||||
const { focusAreas, trainingStyles } = useCatalog()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Deep Linking
|
||||
|
||||
### 4.1 Detail-Seite mit Accordion-State
|
||||
|
||||
**Problem:** Link teilen → Empfänger soll direkt zur "Varianten"-Sektion springen
|
||||
|
||||
**Lösung:** Hash-Fragment in URL:
|
||||
```
|
||||
/exercises/42#variants
|
||||
/exercises/42#media
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
```javascript
|
||||
// ExerciseDetailPage.jsx
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.replace('#', '')
|
||||
if (hash && expandedSections[hash] !== undefined) {
|
||||
setExpandedSections(prev => ({ ...prev, [hash]: true }))
|
||||
|
||||
// Scroll zur Sektion
|
||||
setTimeout(() => {
|
||||
document.getElementById(hash)?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, 100)
|
||||
}
|
||||
}, [])
|
||||
```
|
||||
|
||||
### 4.2 Externe Verknüpfung aus Trainingsplan
|
||||
|
||||
**Szenario:** Trainingsplan-Editor soll Übung verlinken
|
||||
|
||||
**URL-Format:**
|
||||
```
|
||||
/exercises/42?from=training_plan&plan_id=7
|
||||
```
|
||||
|
||||
**Navigation-Logik:**
|
||||
```javascript
|
||||
// ExerciseDetailPage.jsx
|
||||
const searchParams = useSearchParams()
|
||||
const returnUrl = searchParams.get('from') === 'training_plan'
|
||||
? `/training-plans/${searchParams.get('plan_id')}`
|
||||
: '/exercises'
|
||||
|
||||
// "Zurück"-Button zeigt entsprechendes Ziel
|
||||
<button onClick={() => navigate(returnUrl)}>
|
||||
← {returnUrl.includes('training') ? 'Zum Trainingsplan' : 'Zur Übersicht'}
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Navigation Guards
|
||||
|
||||
### 5.1 Unsaved Changes Warning
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
// ExerciseFormPage.jsx
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||||
|
||||
// Bei Form-Änderung
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
setHasUnsavedChanges(true)
|
||||
}
|
||||
|
||||
// Browser-Warning bei Reload/Close
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e) => {
|
||||
if (hasUnsavedChanges) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [hasUnsavedChanges])
|
||||
|
||||
// React-Router Warning bei Navigation
|
||||
const blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) =>
|
||||
hasUnsavedChanges &&
|
||||
currentLocation.pathname !== nextLocation.pathname
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (blocker.state === 'blocked') {
|
||||
const confirmed = window.confirm(
|
||||
'Du hast ungespeicherte Änderungen. Seite wirklich verlassen?'
|
||||
)
|
||||
if (confirmed) {
|
||||
blocker.proceed()
|
||||
} else {
|
||||
blocker.reset()
|
||||
}
|
||||
}
|
||||
}, [blocker])
|
||||
```
|
||||
|
||||
### 5.2 Permission-Check (Admin-Routes)
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
// ProtectedRoute.jsx
|
||||
function ProtectedRoute({ children, requiredRole = 'admin' }) {
|
||||
const { profile } = useAuth()
|
||||
|
||||
if (!profile) {
|
||||
return <Navigate to="/login" />
|
||||
}
|
||||
|
||||
if (profile.role !== requiredRole) {
|
||||
return <Navigate to="/exercises" replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
// App.jsx
|
||||
<Route path="/admin/exercises/*" element={
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<AdminExerciseRoutes />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Performance-Optimierung
|
||||
|
||||
### 6.1 Lazy Loading von Routen
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
// App.jsx
|
||||
const ExercisesListPage = lazy(() => import('./pages/ExercisesListPage'))
|
||||
const ExerciseDetailPage = lazy(() => import('./pages/ExerciseDetailPage'))
|
||||
const ExerciseFormPage = lazy(() => import('./pages/ExerciseFormPage'))
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<Routes>
|
||||
<Route path="/exercises" element={<ExercisesListPage />} />
|
||||
<Route path="/exercises/:id" element={<ExerciseDetailPage />} />
|
||||
<Route path="/exercises/:id/edit" element={<ExerciseFormPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Vorteil:** Initial Bundle kleiner, Routen nur bei Bedarf nachgeladen
|
||||
|
||||
### 6.2 Prefetching von Detail-Daten
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
// ExercisesListPage.jsx - Hover-Prefetch
|
||||
const handleCardHover = (exerciseId) => {
|
||||
// Prefetch Detail-Daten beim Hover
|
||||
queryClient.prefetchQuery(['exercise', exerciseId], () =>
|
||||
api.getExercise(exerciseId)
|
||||
)
|
||||
}
|
||||
|
||||
<ExerciseCard
|
||||
exercise={exercise}
|
||||
onMouseEnter={() => handleCardHover(exercise.id)}
|
||||
onClick={() => navigate(`/exercises/${exercise.id}`)}
|
||||
/>
|
||||
```
|
||||
|
||||
**Vorteil:** Detail-Seite lädt sofort, da Daten bereits im Cache
|
||||
|
||||
---
|
||||
|
||||
## 7. Error-Handling
|
||||
|
||||
### 7.1 404 - Exercise Not Found
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
// ExerciseDetailPage.jsx
|
||||
const { data: exercise, error, isLoading } = useQuery(
|
||||
['exercise', exerciseId],
|
||||
() => api.getExercise(exerciseId)
|
||||
)
|
||||
|
||||
if (error?.response?.status === 404) {
|
||||
return (
|
||||
<NotFoundPage
|
||||
title="Übung nicht gefunden"
|
||||
message="Diese Übung existiert nicht oder wurde gelöscht."
|
||||
backLink="/exercises"
|
||||
backLabel="Zur Übersicht"
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 403 - No Permission
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
if (error?.response?.status === 403) {
|
||||
return (
|
||||
<ForbiddenPage
|
||||
title="Keine Berechtigung"
|
||||
message="Du hast keinen Zugriff auf diese Übung."
|
||||
backLink="/exercises"
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 Network Error
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
if (error?.message === 'Network Error') {
|
||||
return (
|
||||
<ErrorPage
|
||||
title="Verbindungsfehler"
|
||||
message="Server nicht erreichbar. Bitte prüfe deine Internetverbindung."
|
||||
retryAction={() => refetch()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Mobile-Spezifische Navigation
|
||||
|
||||
### 8.1 Bottom Navigation (Mobile)
|
||||
|
||||
**Pattern:**
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ │
|
||||
│ (Content Area) │
|
||||
│ │
|
||||
├─────────────────────────────────────┤
|
||||
│ 🏠 Home │ 📋 Übungen │ 📅 Plan │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
```javascript
|
||||
// MobileBottomNav.jsx
|
||||
<nav className="mobile-bottom-nav">
|
||||
<NavLink to="/" className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
<Icon name="home" />
|
||||
<span>Home</span>
|
||||
</NavLink>
|
||||
<NavLink to="/exercises">
|
||||
<Icon name="list" />
|
||||
<span>Übungen</span>
|
||||
</NavLink>
|
||||
<NavLink to="/training-plans">
|
||||
<Icon name="calendar" />
|
||||
<span>Pläne</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
```
|
||||
|
||||
**CSS:**
|
||||
```css
|
||||
.mobile-bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.mobile-bottom-nav {
|
||||
display: none; /* Desktop nutzt Top-Nav */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 Swipe-Navigation (Detail-Seite)
|
||||
|
||||
**Pattern:** Swipe left/right zwischen Übungen (gleicher Filter)
|
||||
|
||||
**Implementation:**
|
||||
```javascript
|
||||
// ExerciseDetailPage.jsx
|
||||
const { exercises } = useExercisesList() // aus Context/Query
|
||||
const currentIndex = exercises.findIndex(e => e.id === parseInt(exerciseId))
|
||||
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedLeft: () => {
|
||||
const nextExercise = exercises[currentIndex + 1]
|
||||
if (nextExercise) navigate(`/exercises/${nextExercise.id}`)
|
||||
},
|
||||
onSwipedRight: () => {
|
||||
const prevExercise = exercises[currentIndex - 1]
|
||||
if (prevExercise) navigate(`/exercises/${prevExercise.id}`)
|
||||
},
|
||||
preventDefaultTouchmoveEvent: true,
|
||||
trackMouse: false,
|
||||
})
|
||||
|
||||
<div {...swipeHandlers}>
|
||||
{/* Exercise Detail Content */}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Accessibility
|
||||
|
||||
### 9.1 Skip Links
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
// App.jsx
|
||||
<a href="#main-content" className="skip-link">
|
||||
Zum Hauptinhalt springen
|
||||
</a>
|
||||
|
||||
<main id="main-content">
|
||||
{/* Content */}
|
||||
</main>
|
||||
```
|
||||
|
||||
**CSS:**
|
||||
```css
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 8px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Focus Management
|
||||
|
||||
**Pattern:** Bei Navigation → Focus auf Hauptüberschrift setzen
|
||||
|
||||
```javascript
|
||||
// ExerciseDetailPage.jsx
|
||||
const titleRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
titleRef.current?.focus()
|
||||
}, [exerciseId])
|
||||
|
||||
<h1 ref={titleRef} tabIndex={-1}>
|
||||
{exercise.title}
|
||||
</h1>
|
||||
```
|
||||
|
||||
### 9.3 ARIA Live-Region für Filter
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
// ExercisesListPage.jsx
|
||||
<div aria-live="polite" aria-atomic="true" className="sr-only">
|
||||
{exercises.length} Übungen gefunden
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing-Strategie
|
||||
|
||||
### 10.1 Route-Tests (Playwright)
|
||||
|
||||
```javascript
|
||||
// exercises.routing.spec.js
|
||||
test('Navigation zu Detail-Seite funktioniert', async ({ page }) => {
|
||||
await page.goto('/exercises')
|
||||
|
||||
const firstCard = page.locator('.exercise-card').first()
|
||||
await firstCard.click()
|
||||
|
||||
await expect(page).toHaveURL(/\/exercises\/\d+/)
|
||||
await expect(page.locator('h1')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Filter-State bleibt in URL erhalten', async ({ page }) => {
|
||||
await page.goto('/exercises?focus_area=1')
|
||||
|
||||
await page.reload()
|
||||
|
||||
const url = new URL(page.url())
|
||||
expect(url.searchParams.get('focus_area')).toBe('1')
|
||||
})
|
||||
```
|
||||
|
||||
### 10.2 Navigation-Guard-Tests
|
||||
|
||||
```javascript
|
||||
test('Unsaved Changes Warning', async ({ page }) => {
|
||||
await page.goto('/exercises/new')
|
||||
|
||||
await page.fill('[name="title"]', 'Test Übung')
|
||||
|
||||
page.on('dialog', dialog => {
|
||||
expect(dialog.message()).toContain('ungespeicherte')
|
||||
dialog.accept()
|
||||
})
|
||||
|
||||
await page.goto('/exercises')
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Route-Konfiguration (React Router)
|
||||
|
||||
### 11.1 Vollständige Route-Definitionen
|
||||
|
||||
```javascript
|
||||
// App.jsx
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<CatalogProvider>
|
||||
<Routes>
|
||||
{/* Exercises Routes */}
|
||||
<Route path="/exercises">
|
||||
<Route index element={<ExercisesListPage />} />
|
||||
<Route path="new" element={<ExerciseFormPage />} />
|
||||
<Route path=":id" element={<ExerciseDetailPage />} />
|
||||
<Route path=":id/edit" element={<ExerciseFormPage />} />
|
||||
<Route path=":id/variants/new" element={<VariantFormPage />} />
|
||||
<Route path=":id/variants/:variantId/edit" element={<VariantFormPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Exercise Blocks Routes */}
|
||||
<Route path="/exercise-blocks">
|
||||
<Route index element={<ExerciseBlocksListPage />} />
|
||||
<Route path="new" element={<ExerciseBlockFormPage />} />
|
||||
<Route path=":id" element={<ExerciseBlockDetailPage />} />
|
||||
<Route path=":id/edit" element={<ExerciseBlockFormPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin Routes */}
|
||||
<Route path="/admin/exercises" element={
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<Outlet />
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
<Route path="catalog" element={<AdminCatalogPage />} />
|
||||
<Route path="skills" element={<AdminSkillsPage />} />
|
||||
<Route path="methods" element={<AdminMethodsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Redirects */}
|
||||
<Route path="/" element={<Navigate to="/exercises" replace />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</CatalogProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.1
|
||||
**Letzte Änderung:** 2026-04-24
|
||||
**Status:** REVIEWED - Pending Implementation
|
||||
**Review-Änderungen:** Exercise Blocks Routes + Navigation hinzugefügt
|
||||
370
.claude/docs/technical/KI_FEATURES_SPEC.md
Normal file
370
.claude/docs/technical/KI_FEATURES_SPEC.md
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
# KI-Funktionen Specification – Exercises
|
||||
|
||||
**Version:** 1.1
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** DRAFT
|
||||
**Autor:** Claude Code
|
||||
**Änderungen v1.1:** Prompts sind nicht hardcoded – sie werden aus der DB geladen (AI_PROMPT_SYSTEM_SPEC.md)
|
||||
**Verwandte Specs:** AI_PROMPT_SYSTEM_SPEC.md (Prompt-DB + Platzhalter), SKILLS_MATRIX_SPEC.md (Fähigkeitsmatrix)
|
||||
|
||||
---
|
||||
|
||||
## 1. Übersicht
|
||||
|
||||
Zwei KI-gestützte Assistenzfunktionen beim Anlegen und Bearbeiten von Übungen:
|
||||
|
||||
| Funktion | Ziel |
|
||||
|---------|------|
|
||||
| **KI-Zusammenfassung** | Generiert `summary` aus `goal` + `execution` (für Listen + Trainingspläne) |
|
||||
| **KI-Skill-Empfehlung** | Schlägt passende Fähigkeiten + Stufen vor (reduziert manuelle Zuordungsarbeit) |
|
||||
|
||||
**Abhängigkeit:** OpenRouter API (`OPENROUTER_API_KEY` in `.env`).
|
||||
Ist der Key nicht gesetzt, sind alle KI-Funktionen deaktiviert – Formular funktioniert ohne KI normal.
|
||||
|
||||
---
|
||||
|
||||
## 2. UX-Prinzip: Manual First, Auto als Fallback
|
||||
|
||||
```
|
||||
Trainer gibt goal + execution ein
|
||||
│
|
||||
├─► [KI-Vorschlag]-Button sichtbar
|
||||
│ │
|
||||
│ ▼ (Klick)
|
||||
│ KI generiert sofort Vorschlag
|
||||
│ → Trainer sieht Vorschlag inline
|
||||
│ → Kann akzeptieren / bearbeiten / ablehnen
|
||||
│
|
||||
└─► Kein Klick bis zum Speichern?
|
||||
│
|
||||
▼ (automatisch beim POST/PUT)
|
||||
KI läuft im Hintergrund
|
||||
→ summary + skill_suggestions werden gesetzt
|
||||
→ Trainer kann danach noch bearbeiten
|
||||
```
|
||||
|
||||
**Regel:** Manuell generierte Werte (wo Trainer aktiv bestätigt hat) werden
|
||||
beim Auto-Fallback NICHT überschrieben. Auto-generierte Werte werden als
|
||||
`ai_generated: true` markiert und können jederzeit neu generiert oder überschrieben werden.
|
||||
|
||||
---
|
||||
|
||||
## 3. KI-Zusammenfassung (`summary`)
|
||||
|
||||
### 3.1 Was wird generiert
|
||||
|
||||
Aus `title` + `goal` + `execution` generiert die KI eine **kurze Zusammenfassung**
|
||||
(2-4 Sätze, max. 200 Zeichen) für:
|
||||
- Übungslisten (Karten-Ansicht)
|
||||
- Trainingsplan-Darstellung (kompakte Anzeige)
|
||||
- Export-Berichte
|
||||
|
||||
### 3.2 Prompt-Logik (Backend)
|
||||
|
||||
**Prompt ist NICHT hardcoded** – er wird aus der `ai_prompts` Tabelle geladen
|
||||
(Slug: `exercise_summary`). Admins können das Template anpassen.
|
||||
|
||||
```python
|
||||
# Aufruf via zentralem AI-Service (siehe AI_PROMPT_SYSTEM_SPEC.md)
|
||||
result = await ai_service.run_ai_prompt(
|
||||
slug="exercise_summary",
|
||||
context_data={"exercise": exercise_data},
|
||||
db=db,
|
||||
openrouter_key=settings.openrouter_api_key
|
||||
)
|
||||
```
|
||||
|
||||
**Default-Template** (in Migration 020 als `default_template` gespeichert):
|
||||
Siehe `AI_PROMPT_SYSTEM_SPEC.md` §2.1 – Prompt `exercise_summary`.
|
||||
|
||||
### 3.3 DB-Feld
|
||||
|
||||
Kein neues Feld nötig – `exercises.summary` nimmt den generierten Text auf.
|
||||
Zusätzliches Tracking-Feld: `summary_ai_generated BOOLEAN DEFAULT false`
|
||||
|
||||
---
|
||||
|
||||
## 4. KI-Skill-Empfehlung
|
||||
|
||||
### 4.1 Was wird empfohlen
|
||||
|
||||
Aus `title` + `goal` + `execution` + Katalog aller verfügbaren Skills empfiehlt die KI:
|
||||
|
||||
- **Welche Skills** relevant sind (aus dem bestehenden Skill-Katalog)
|
||||
- **Erforderliche Stufe** (`required_level`) – was der Trainierende mitbringen muss
|
||||
- **Ziel-Stufe** (`target_level`) – was durch regelmäßige Ausführung erreicht wird
|
||||
- **Intensität** (`intensity`) – wie stark diese Fähigkeit trainiert wird
|
||||
|
||||
### 4.2 Prompt-Logik (Backend)
|
||||
|
||||
**Prompt ist NICHT hardcoded** – Slug: `exercise_skill_suggestions`.
|
||||
Der `{{skills_catalog}}`-Platzhalter wird vom Backend automatisch mit der aktuellen
|
||||
Skill-Liste aus der DB aufgelöst.
|
||||
|
||||
Admins können den Prompt anpassen (z.B. die Anweisung zur Auswahl verschärfen oder
|
||||
fachliche Hinweise ergänzen). Siehe `AI_PROMPT_SYSTEM_SPEC.md`.
|
||||
|
||||
### 4.3 Bestätigungsflow (UX)
|
||||
|
||||
```
|
||||
KI gibt Vorschläge
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ KI-Vorschläge (3 Fähigkeiten) │
|
||||
│ ─────────────────────────────────────── │
|
||||
│ ✓ Distanzgefühl │
|
||||
│ Voraussetzung: Grundlagen │
|
||||
│ Ziel: Aufbau · Intensität: Hoch │
|
||||
│ [✓ Übernehmen] [Stufe ändern ▼] [✕] │
|
||||
│ ─────────────────────────────────────── │
|
||||
│ ✓ Reaktionsschnelligkeit │
|
||||
│ Voraussetzung: Einsteiger │
|
||||
│ Ziel: Grundlagen · Intensität: Mittel │
|
||||
│ [✓ Übernehmen] [Stufe ändern ▼] [✕] │
|
||||
│ ─────────────────────────────────────── │
|
||||
│ ◦ Gleichgewicht (optional) │
|
||||
│ Voraussetzung: Grundlagen │
|
||||
│ Ziel: Grundlagen · Intensität: Niedrig│
|
||||
│ [✓ Übernehmen] [Stufe ändern ▼] [✕] │
|
||||
│ ─────────────────────────────────────── │
|
||||
│ [Alle übernehmen] [Verwerfen] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Jeder Vorschlag kann **einzeln** übernommen, angepasst oder abgelehnt werden
|
||||
- „Alle übernehmen" nimmt alle aktuell angezeigten Vorschläge
|
||||
- Stufen können per Dropdown direkt angepasst werden bevor sie übernommen werden
|
||||
- Abgelehnte Vorschläge werden nicht gespeichert
|
||||
|
||||
---
|
||||
|
||||
## 5. API-Endpoints
|
||||
|
||||
### 5.1 Endpoints Overview
|
||||
|
||||
| Method | Endpoint | Beschreibung |
|
||||
|--------|----------|--------------|
|
||||
| POST | `/exercises/ai/suggest` | Vorschläge für neue Übung (vor dem Speichern) |
|
||||
| POST | `/exercises/{id}/ai/regenerate` | Neu generieren für bestehende Übung |
|
||||
|
||||
---
|
||||
|
||||
### 5.2 `POST /exercises/ai/suggest`
|
||||
|
||||
Liefert KI-Vorschläge auf Basis von Eingabe-Text, **bevor** die Übung gespeichert wurde.
|
||||
Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"title": "Maai - Distanzübung",
|
||||
"goal": "Distanzgefühl entwickeln durch Partnerübungen...",
|
||||
"execution": "1. Partnerwahl\n2. Ausgangsstellung..."
|
||||
}
|
||||
```
|
||||
|
||||
**Required Fields:** mindestens `goal` ODER `execution` (je länger, desto besser)
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"text": "Partnerübung zur Entwicklung des Distanzgefühls. Trainiert werden räumliche Wahrnehmung und reaktives Verhalten in der Kumite-Distanz.",
|
||||
"ai_generated": true,
|
||||
"model": "anthropic/claude-sonnet-4"
|
||||
},
|
||||
"skills": [
|
||||
{
|
||||
"skill_id": 10,
|
||||
"skill_name": "Distanzgefühl",
|
||||
"skill_category": "Kumite",
|
||||
"required_level": "grundlagen",
|
||||
"target_level": "aufbau",
|
||||
"intensity": "hoch",
|
||||
"is_primary": true,
|
||||
"confidence": 0.92
|
||||
},
|
||||
{
|
||||
"skill_id": 15,
|
||||
"skill_name": "Reaktionsschnelligkeit",
|
||||
"skill_category": "Athletik",
|
||||
"required_level": "einsteiger",
|
||||
"target_level": "grundlagen",
|
||||
"intensity": "mittel",
|
||||
"is_primary": false,
|
||||
"confidence": 0.74
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `400` - Zu wenig Text (goal und execution leer)
|
||||
- `503` - KI nicht verfügbar (OPENROUTER_API_KEY fehlt)
|
||||
|
||||
---
|
||||
|
||||
### 5.3 `POST /exercises/{id}/ai/regenerate`
|
||||
|
||||
Generiert summary und/oder skill-Empfehlungen für eine bestehende Übung neu.
|
||||
Verwendet die aktuellen gespeicherten Felder als Input.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"regenerate": ["summary", "skills"]
|
||||
}
|
||||
```
|
||||
|
||||
**Werte:** `"summary"` | `"skills"` (oder beide)
|
||||
|
||||
**Response:** `200 OK` (gleiche Struktur wie `/ai/suggest`)
|
||||
|
||||
**Hinweis:** Regenerieren überschreibt NICHT Felder, die manuell gesetzt wurden
|
||||
(`summary_ai_generated = false`). Es wird nur der Vorschlag zurückgegeben,
|
||||
Trainer muss aktiv übernehmen.
|
||||
|
||||
---
|
||||
|
||||
## 6. Datenbank
|
||||
|
||||
### 6.1 Neues Feld: `summary_ai_generated`
|
||||
|
||||
```sql
|
||||
-- Als Teil von Migration 014 ergänzen:
|
||||
ALTER TABLE exercises
|
||||
ADD COLUMN IF NOT EXISTS summary_ai_generated BOOLEAN DEFAULT false;
|
||||
```
|
||||
|
||||
**Semantik:**
|
||||
- `false` (default) = Zusammenfassung manuell geschrieben oder noch leer
|
||||
- `true` = Zuletzt von KI generiert (kann überschrieben werden)
|
||||
|
||||
Beim manuellen Überschreiben durch den Trainer: `summary_ai_generated = false` setzen.
|
||||
|
||||
### 6.2 Kein Feld für Skill-Empfehlungen
|
||||
|
||||
Skill-Vorschläge der KI werden **nicht** persistent gespeichert – sie erscheinen
|
||||
nur im Formular-UI und werden erst bei expliziter Bestätigung als reguläre
|
||||
`exercise_skills`-Einträge gespeichert.
|
||||
|
||||
---
|
||||
|
||||
## 7. Integration in Speicher-Flow
|
||||
|
||||
### 7.1 Auto-Fallback beim Speichern (POST/PUT exercises)
|
||||
|
||||
```python
|
||||
# In backend/routers/exercises.py
|
||||
async def create_exercise(data, session):
|
||||
# 1. Übung normal speichern
|
||||
exercise_id = db.insert_exercise(data)
|
||||
|
||||
# 2. Auto-KI nur wenn:
|
||||
# - OPENROUTER_API_KEY gesetzt
|
||||
# - summary noch leer
|
||||
# - goal oder execution vorhanden
|
||||
if settings.openrouter_api_key and not data.summary and (data.goal or data.execution):
|
||||
# Background Task (non-blocking)
|
||||
background_tasks.add_task(
|
||||
auto_generate_summary,
|
||||
exercise_id=exercise_id,
|
||||
title=data.title,
|
||||
goal=data.goal,
|
||||
execution=data.execution
|
||||
)
|
||||
|
||||
return exercise_id
|
||||
```
|
||||
|
||||
### 7.2 Skill-Auto-Suggestion
|
||||
|
||||
Skill-Empfehlungen werden beim Auto-Fallback **NICHT** automatisch gespeichert
|
||||
(wegen Bestätigungs-Pflicht). Sie werden nur beim manuellen Trigger angeboten.
|
||||
|
||||
---
|
||||
|
||||
## 8. Frontend-Integration
|
||||
|
||||
### 8.1 ExerciseFormPage – KI-Buttons
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Ziel der Übung * │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ Distanzgefühl entwickeln... │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Durchführung * │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ 1. Partnerwahl... │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ Zusammenfassung (für Listen) │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ [Leer] │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
│ [✨ KI-Vorschlag] ← Button │
|
||||
│ │
|
||||
│ ─────────────────────────────────── │
|
||||
│ Fähigkeiten │
|
||||
│ [Keine zugeordnet] │
|
||||
│ [+ Manuell hinzufügen] │
|
||||
│ [✨ KI-Empfehlungen] ← Button │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.2 KI-Vorschlag für Summary (Inline)
|
||||
|
||||
Beim Klick auf „✨ KI-Vorschlag":
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ ✨ KI-Vorschlag │
|
||||
│ ─────────────────────────────────── │
|
||||
│ "Partnerübung zur Entwicklung des │
|
||||
│ Distanzgefühls. Trainiert räumliche │
|
||||
│ Wahrnehmung und reaktives Verhalten."│
|
||||
│ ─────────────────────────────────── │
|
||||
│ [✓ Übernehmen] [✕ Verwerfen] │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Nach „Übernehmen": Text landet im Summary-Feld, Button zeigt „↺ Neu generieren".
|
||||
|
||||
### 8.3 Anzeige KI-generierter Inhalte
|
||||
|
||||
Felder die KI-generiert sind, werden mit einem kleinen Badge markiert:
|
||||
```
|
||||
Zusammenfassung ✨ KI-generiert [bearbeiten]
|
||||
```
|
||||
|
||||
Nach manuellem Bearbeiten verschwindet der Badge.
|
||||
|
||||
---
|
||||
|
||||
## 9. Konfiguration
|
||||
|
||||
```env
|
||||
OPENROUTER_API_KEY=sk-or-...
|
||||
OPENROUTER_MODEL=anthropic/claude-sonnet-4 # Für Zusammenfassung
|
||||
OPENROUTER_MODEL_FAST=anthropic/claude-haiku-4-5 # Für Skill-Empfehlungen (billiger)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Fehlerbehandlung
|
||||
|
||||
| Szenario | Verhalten |
|
||||
|----------|-----------|
|
||||
| OPENROUTER_API_KEY nicht gesetzt | KI-Buttons nicht anzeigen, kein Auto-Fallback |
|
||||
| API-Timeout (> 10s) | Fehlermeldung inline: "KI momentan nicht verfügbar. Bitte manuell ausfüllen." |
|
||||
| API-Fehler (5xx) | Gleiche Meldung, kein Absturz |
|
||||
| Unbekannte Skills in Empfehlung | Backend filtert Skills die nicht im Katalog sind heraus |
|
||||
| Summary zu lang generiert | Backend kürzt auf 200 Zeichen |
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** DRAFT
|
||||
425
.claude/docs/technical/MEDIAWIKI_IMPORT_SPEC.md
Normal file
425
.claude/docs/technical/MEDIAWIKI_IMPORT_SPEC.md
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
# MediaWiki Import Specification
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** DRAFT - Ready for Review
|
||||
**Autor:** Claude Code
|
||||
|
||||
---
|
||||
|
||||
## 1. Übersicht
|
||||
|
||||
**Ziel:** Übungen aus dem Jinkendo MediaWiki-System direkt via API importieren
|
||||
(NICHT über Export/XML-Dump – siehe Architektur-Entscheidung in EXERCISES_ARCHITECTURE.md §8).
|
||||
|
||||
**Importrichtung:** Einseitig (MediaWiki → Shinkan). Kein bidirektionaler Sync.
|
||||
|
||||
**Import-Reihenfolge:** Skills → Methods → Exercises (Abhängigkeiten zuerst)
|
||||
|
||||
**Import-Tracking:** Jeder Import wird geloggt, Duplikate werden via `wiki_page_title`
|
||||
erkannt und nicht doppelt angelegt.
|
||||
|
||||
---
|
||||
|
||||
## 2. MediaWiki API-Zugriff
|
||||
|
||||
### 2.1 API-Endpoint
|
||||
|
||||
```
|
||||
GET https://karatetrainer.net/api.php
|
||||
```
|
||||
|
||||
**Benötigte Parameter:**
|
||||
- `action=query` – Seiteninhalte abfragen
|
||||
- `action=parse` – Einzelseite parsen (mit Semantic MediaWiki Properties)
|
||||
- `prop=text|properties|categories` – Relevante Felder
|
||||
- `format=json`
|
||||
|
||||
### 2.2 Authentifizierung
|
||||
|
||||
Aktuell: Kein Auth (wenn Wiki öffentlich zugänglich).
|
||||
Bei privatem Wiki: `lgtoken` via `/api.php?action=login`.
|
||||
|
||||
### 2.3 Relevante API-Calls
|
||||
|
||||
**Alle Übungen einer Kategorie auflisten:**
|
||||
```
|
||||
GET /api.php?action=query&list=categorymembers&cmtitle=Kategorie:Übungen&cmlimit=500&format=json
|
||||
```
|
||||
|
||||
**Einzelne Übung abrufen (mit SMW Properties):**
|
||||
```
|
||||
GET /api.php?action=parse&page=Übungsname&prop=text|properties&format=json
|
||||
```
|
||||
|
||||
**SMW-Properties einer Seite:**
|
||||
```
|
||||
GET /api.php?action=browsebysubject&subject=Übungsname&format=json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Field-Mapping (MediaWiki → Exercises)
|
||||
|
||||
**Echte Property-Namen von karatetrainer.net** (via `discover_properties()` ermittelt, 2026-04-24)
|
||||
|
||||
### 3.1 Kern-Felder
|
||||
|
||||
| Wiki-Property (SMW) | Exercises-Feld | Transformation |
|
||||
|---------------------|---------------|----------------|
|
||||
| `Übungsbezeichnung` | `title` | Bevorzugt über Seitentitel |
|
||||
| `Summary` | `summary` | Direkt |
|
||||
| `Ziel` | `goal` | Direkt |
|
||||
| `Durchführung` | `execution` | Wikitext → Plaintext |
|
||||
| `Hinweise` | `trainer_notes` | Wikitext → Plaintext |
|
||||
| `Plandauer` | `duration_min` = `duration_max` | Zahl in Minuten z.B. `"10"` |
|
||||
| `Gruppengröße` | `group_size_min` | Zahl z.B. `"2"` |
|
||||
| `Hilfsmittel` | `equipment` (JSONB) | Array von Strings |
|
||||
|
||||
### 3.2 Katalog-Mapping
|
||||
|
||||
| Wiki-Property | Exercises-Tabelle | Transformation |
|
||||
|---------------|------------------|----------------|
|
||||
| `Übungstyp` | `exercise_focus_areas` | z.B. `"Karate"` → focus_area Name → ID |
|
||||
| `Zielgruppe` | `exercise_target_groups` | Name → ID Lookup |
|
||||
| `Altersgruppe` | `exercise_age_groups` | Name → ID Lookup |
|
||||
| `Trainingsmethode` | exercise method ref | Wiki-Seitenname → Label (Unterstriche → Leerzeichen) |
|
||||
| `PrimaryCapability` | `exercise_skills` (Name) | Skill-Name → ID Lookup |
|
||||
| `CapabilityLevel` | `exercise_skills` (target_level) | Integer → Named Level (1=einsteiger…5=experte) |
|
||||
| `Schlüsselworte` | — | Keywords (für zukünftige Tag-Funktion) |
|
||||
|
||||
### 3.3 Skill-Level-Mapping (CapabilityLevel → Stufen)
|
||||
|
||||
| Wiki-Wert | Shinkan-Stufe |
|
||||
|-----------|---------------|
|
||||
| 1 | einsteiger |
|
||||
| 2 | grundlagen |
|
||||
| 3 | aufbau |
|
||||
| 4 | fortgeschritten |
|
||||
| 5 | experte |
|
||||
|
||||
### 3.4 Kategorien (karatetrainer.net)
|
||||
|
||||
| Import-Typ | Wiki-Kategorie |
|
||||
|------------|---------------|
|
||||
| exercises | `Übungen` |
|
||||
| skills | `Fähigkeitsbeschreibung` |
|
||||
| methods | `Methodenbeschreibung` |
|
||||
|
||||
**Unbekannte Katalog-Werte:** Warnmeldung im Import-Log, Übung wird ohne
|
||||
diese Zuordnung importiert (kein Abbruch).
|
||||
|
||||
### 3.3 Import-Tracking-Felder
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| `import_source` | `'mediawiki'` |
|
||||
| `import_id` | Wiki-Seitenname (URL-kodiert) |
|
||||
| `visibility` | `'private'` (Trainer prüft vor Veröffentlichung) |
|
||||
| `status` | `'draft'` |
|
||||
|
||||
### 3.4 Wikitext-Bereinigung
|
||||
|
||||
Vor dem Speichern werden folgende Wikitext-Elemente in Plaintext umgewandelt:
|
||||
- `[[Link|Text]]` → `Text`
|
||||
- `[[Link]]` → `Link`
|
||||
- `{{Template}}` → (entfernt)
|
||||
- `'''Bold'''` → `Bold`
|
||||
- `''Italic''` → `Italic`
|
||||
- `==Überschrift==` → `\nÜberschrift\n`
|
||||
- Wikitables → (entfernt, zu komplex für automatischen Import)
|
||||
|
||||
---
|
||||
|
||||
## 4. Tracking-Tabellen
|
||||
|
||||
```sql
|
||||
-- Migration: wiki_import_tracking
|
||||
-- (In eigene Migration auslagern: 018_wiki_import_tracking.sql)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wiki_import_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
import_type VARCHAR(50) NOT NULL, -- 'exercise', 'skill', 'method'
|
||||
import_status VARCHAR(20) CHECK (import_status IN ('running', 'completed', 'failed')),
|
||||
items_total INT DEFAULT 0,
|
||||
items_imported INT DEFAULT 0,
|
||||
items_skipped INT DEFAULT 0,
|
||||
items_failed INT DEFAULT 0,
|
||||
error_log JSONB DEFAULT '[]'::jsonb, -- Array von {item, error} Objekten
|
||||
imported_by INT REFERENCES profiles(id),
|
||||
started_at TIMESTAMP DEFAULT NOW(),
|
||||
finished_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wiki_import_references (
|
||||
id SERIAL PRIMARY KEY,
|
||||
wiki_page_title VARCHAR(500) NOT NULL,
|
||||
wiki_page_id INT, -- MediaWiki interne ID
|
||||
content_type VARCHAR(50) NOT NULL, -- 'exercise', 'skill', 'method'
|
||||
local_id INT NOT NULL, -- ID in der lokalen DB
|
||||
last_imported TIMESTAMP DEFAULT NOW(),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(wiki_page_title, content_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wiki_refs_title ON wiki_import_references(wiki_page_title);
|
||||
CREATE INDEX IF NOT EXISTS idx_wiki_refs_type ON wiki_import_references(content_type);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API-Endpoints
|
||||
|
||||
### 5.1 Endpoints Overview
|
||||
|
||||
| Method | Endpoint | Beschreibung |
|
||||
|--------|----------|--------------|
|
||||
| GET | `/import/mediawiki/preview` | Vorschau: Was wird importiert? |
|
||||
| POST | `/import/mediawiki/execute` | Import ausführen |
|
||||
| GET | `/import/mediawiki/status/{log_id}` | Import-Status abrufen |
|
||||
| GET | `/import/mediawiki/logs` | Alle Import-Logs |
|
||||
| DELETE | `/import/mediawiki/references/{id}` | Referenz löschen (für Re-Import) |
|
||||
|
||||
### 5.2 `GET /import/mediawiki/preview`
|
||||
|
||||
**Query Parameters:**
|
||||
- `category` (string, required) – Wiki-Kategorie z.B. `Übungen`
|
||||
- `limit` (int, optional, default: 10) – Maximale Anzahl zum Voranschauen
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"category": "Übungen",
|
||||
"total_found": 47,
|
||||
"preview": [
|
||||
{
|
||||
"wiki_page_title": "Maai - Distanzübung",
|
||||
"wiki_page_id": 1234,
|
||||
"already_imported": false,
|
||||
"last_imported_at": null,
|
||||
"mapped_fields": {
|
||||
"title": "Maai - Distanzübung",
|
||||
"summary": "Distanzgefühl entwickeln...",
|
||||
"goal": "Ziel...",
|
||||
"focus_area": "Karate",
|
||||
"focus_area_found": true
|
||||
},
|
||||
"warnings": [],
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"wiki_page_title": "Kumite Grundtechniken",
|
||||
"already_imported": true,
|
||||
"last_imported_at": "2026-04-10T10:00:00Z",
|
||||
"mapped_fields": {...},
|
||||
"warnings": ["Feld 'Zielgruppe' hat unbekannten Wert 'Fortgeschrittene' - wird ignoriert"],
|
||||
"errors": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.3 `POST /import/mediawiki/execute`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"category": "Übungen",
|
||||
"reimport_existing": false, // true: Bereits importierte Übungen überschreiben
|
||||
"dry_run": false, // true: Nur simulieren, nichts speichern
|
||||
"limit": null // null: alle importieren
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `202 Accepted` (Import läuft asynchron)
|
||||
```json
|
||||
{
|
||||
"log_id": 5,
|
||||
"status": "running",
|
||||
"message": "Import gestartet. Verwende GET /import/mediawiki/status/5 für Status."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.4 `GET /import/mediawiki/status/{log_id}`
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"id": 5,
|
||||
"import_type": "exercise",
|
||||
"import_status": "completed",
|
||||
"items_total": 47,
|
||||
"items_imported": 44,
|
||||
"items_skipped": 2,
|
||||
"items_failed": 1,
|
||||
"error_log": [
|
||||
{
|
||||
"item": "Unvollständige Übung",
|
||||
"error": "Pflichtfeld 'Durchführung' fehlt in Wiki-Seite"
|
||||
}
|
||||
],
|
||||
"imported_by": 1,
|
||||
"started_at": "2026-04-24T10:00:00Z",
|
||||
"finished_at": "2026-04-24T10:02:30Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.5 `GET /import/mediawiki/logs`
|
||||
|
||||
**Response:** `200 OK` – Array von Import-Log Objekten (ohne error_log Details)
|
||||
|
||||
---
|
||||
|
||||
## 6. Backend-Implementation
|
||||
|
||||
### 6.1 Router-Datei
|
||||
|
||||
`backend/routers/import_wiki.py`
|
||||
|
||||
### 6.2 Import-Prozess (Pseudocode)
|
||||
|
||||
```python
|
||||
async def execute_wiki_import(category: str, reimport: bool, dry_run: bool):
|
||||
# 1. Alle Seiten der Kategorie via MediaWiki API abrufen
|
||||
pages = wiki_api.get_category_members(category)
|
||||
|
||||
for page_title in pages:
|
||||
# 2. Duplikat-Check
|
||||
existing_ref = db.get_wiki_ref(page_title, 'exercise')
|
||||
if existing_ref and not reimport:
|
||||
log.skip(page_title, "bereits importiert")
|
||||
continue
|
||||
|
||||
# 3. Seite parsen
|
||||
wiki_data = wiki_api.parse_page(page_title)
|
||||
|
||||
# 4. Field-Mapping
|
||||
exercise_data = map_wiki_to_exercise(wiki_data)
|
||||
|
||||
# 5. Validierung
|
||||
errors = validate_exercise(exercise_data)
|
||||
if errors:
|
||||
log.fail(page_title, errors)
|
||||
continue
|
||||
|
||||
# 6. Katalog-IDs auflösen
|
||||
exercise_data = resolve_catalog_ids(exercise_data)
|
||||
|
||||
if not dry_run:
|
||||
# 7. Speichern
|
||||
exercise_id = db.upsert_exercise(exercise_data)
|
||||
|
||||
# 8. Import-Referenz aktualisieren
|
||||
db.upsert_wiki_ref(page_title, 'exercise', exercise_id)
|
||||
|
||||
log.success(page_title)
|
||||
```
|
||||
|
||||
### 6.3 Wikitext Parser
|
||||
|
||||
```python
|
||||
import re
|
||||
|
||||
def wikitext_to_plaintext(wikitext: str) -> str:
|
||||
"""Konvertiert Wikitext in lesbares Plaintext."""
|
||||
text = wikitext
|
||||
|
||||
# Links: [[Link|Text]] → Text, [[Link]] → Link
|
||||
text = re.sub(r'\[\[([^|]+)\|([^\]]+)\]\]', r'\2', text)
|
||||
text = re.sub(r'\[\[([^\]]+)\]\]', r'\1', text)
|
||||
|
||||
# Templates entfernen
|
||||
text = re.sub(r'\{\{[^}]+\}\}', '', text)
|
||||
|
||||
# Formatierung
|
||||
text = re.sub(r"'''(.+?)'''", r'\1', text)
|
||||
text = re.sub(r"''(.+?)''", r'\1', text)
|
||||
|
||||
# Überschriften
|
||||
text = re.sub(r'={2,6}\s*(.+?)\s*={2,6}', r'\n\1\n', text)
|
||||
|
||||
# Mehrfache Leerzeilen normalisieren
|
||||
text = re.sub(r'\n{3,}', '\n\n', text)
|
||||
|
||||
return text.strip()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Frontend-Import-UI
|
||||
|
||||
### 7.1 Route
|
||||
|
||||
```
|
||||
/admin/import/mediawiki → AdminWikiImportPage
|
||||
```
|
||||
|
||||
### 7.2 Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ MediaWiki Import │
|
||||
│ ─────────────────────────────────────── │
|
||||
│ Wiki-Kategorie: [Übungen ] │
|
||||
│ [+ Vorschau anzeigen] │
|
||||
│ ─────────────────────────────────────── │
|
||||
│ Vorschau (10 von 47): │
|
||||
│ │
|
||||
│ ✅ Maai - Distanzübung (neu) │
|
||||
│ ✅ Kizami-Zuki (neu) │
|
||||
│ ⚠️ Kumite XY (bereits importiert) │
|
||||
│ ❌ Defekte Seite (Pflichtfeld fehlt) │
|
||||
│ ... │
|
||||
│ ─────────────────────────────────────── │
|
||||
│ ☐ Bereits importierte überschreiben │
|
||||
│ ☐ Nur simulieren (dry run) │
|
||||
│ ─────────────────────────────────────── │
|
||||
│ [47 Übungen importieren] │
|
||||
│ ─────────────────────────────────────── │
|
||||
│ Letzter Import: 44/47 · 2026-04-10 │
|
||||
│ [Import-Logs anzeigen] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Fehlerbehandlung
|
||||
|
||||
| Fehler | Verhalten |
|
||||
|--------|-----------|
|
||||
| Wiki-Seite nicht erreichbar | Import-Prozess abbricht, Log-Eintrag |
|
||||
| Pflichtfeld (`goal`, `execution`) fehlt | Item wird übersprungen, Warnung im Log |
|
||||
| Unbekannter Katalog-Wert (Fokusbereich etc.) | Item wird ohne diese Zuordnung importiert |
|
||||
| Duplikat-Titel im selben Club | Item wird übersprungen (außer reimport=true) |
|
||||
| Wiki-API Timeout | Retry 3x, dann fail mit Log-Eintrag |
|
||||
|
||||
---
|
||||
|
||||
## 9. Sicherheit
|
||||
|
||||
- Nur Super-Admin darf Import ausführen (`require_role('superadmin')`)
|
||||
- MediaWiki-URL ist konfigurierbar via `.env` (kein Hardcoding)
|
||||
- Rate-Limiting: max. 1 Import parallel, max. 10/Tag
|
||||
- Importierte Übungen starten immer als `visibility='private'`, `status='draft'`
|
||||
|
||||
---
|
||||
|
||||
## 10. Konfiguration (`.env`)
|
||||
|
||||
```
|
||||
MEDIAWIKI_API_URL=https://karatetrainer.net/api.php
|
||||
MEDIAWIKI_USER=import-bot # optional, für privates Wiki
|
||||
MEDIAWIKI_PASSWORD=... # optional
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** DRAFT - Ready for Review
|
||||
778
.claude/docs/technical/MEDIA_UPLOAD_SPEC.md
Normal file
778
.claude/docs/technical/MEDIA_UPLOAD_SPEC.md
Normal file
|
|
@ -0,0 +1,778 @@
|
|||
# Media Upload & Embed Specification
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** DRAFT - Awaiting Review
|
||||
**Autor:** Claude Code
|
||||
|
||||
---
|
||||
|
||||
## 1. Upload-Strategie
|
||||
|
||||
### 1.1 Hybrid-Ansatz
|
||||
|
||||
**Zwei Medienquellen:**
|
||||
1. **Lokale Uploads:** Datei-Upload auf Server (Images, Videos, PDFs, Sketches)
|
||||
2. **Embeds:** Einbetten von YouTube, Instagram, Vimeo, TikTok
|
||||
|
||||
**Vorteile:**
|
||||
- Lokale Kontrolle über kritische Inhalte
|
||||
- Keine Abhängigkeit von Drittanbietern für Kern-Content
|
||||
- Externe Plattformen für ergänzende Videos
|
||||
|
||||
**Limitierungen:**
|
||||
- Max. 50 MB pro Datei
|
||||
- Max. 10 Media-Items pro Übung (kombiniert Local + Embeds)
|
||||
- Erlaubte Formate: JPEG, PNG, GIF, MP4, PDF
|
||||
|
||||
---
|
||||
|
||||
## 2. Upload-Flow (Lokale Dateien)
|
||||
|
||||
### 2.1 Frontend-Komponente: MediaUploader
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface MediaUploaderProps {
|
||||
exerciseId: number
|
||||
existingMedia: Media[]
|
||||
onUploadComplete: (media: Media) => void
|
||||
maxFiles?: number // default: 10
|
||||
maxSizeMB?: number // default: 50
|
||||
}
|
||||
```
|
||||
|
||||
**UI-Struktur:**
|
||||
```jsx
|
||||
<MediaUploader>
|
||||
<div className="upload-zone">
|
||||
{/* Drag & Drop Area */}
|
||||
<input type="file" multiple accept="image/*,video/mp4,application/pdf" />
|
||||
|
||||
{/* Preview Grid */}
|
||||
<div className="media-grid">
|
||||
{uploadQueue.map(file => (
|
||||
<div key={file.name} className="upload-preview">
|
||||
<img src={file.preview} />
|
||||
<ProgressBar percent={file.uploadProgress} />
|
||||
<button onClick={() => removeFromQueue(file)}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Metadata Form (per File) */}
|
||||
<div className="upload-metadata">
|
||||
<input name="title" placeholder="Titel (optional)" />
|
||||
<textarea name="description" placeholder="Beschreibung" />
|
||||
<select name="context">
|
||||
<option value="ablauf">Ablauf</option>
|
||||
<option value="detail">Detail</option>
|
||||
<option value="trainer_hint">Trainer-Hinweis</option>
|
||||
</select>
|
||||
<label>
|
||||
<input type="checkbox" name="is_primary" />
|
||||
Als Hauptbild markieren
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button onClick={handleUpload}>
|
||||
{uploadQueue.length} Dateien hochladen
|
||||
</button>
|
||||
</div>
|
||||
</MediaUploader>
|
||||
```
|
||||
|
||||
### 2.2 Client-seitige Validierung
|
||||
|
||||
**Vor Upload prüfen:**
|
||||
```javascript
|
||||
// MediaUploader.jsx
|
||||
const validateFile = (file) => {
|
||||
const errors = []
|
||||
|
||||
// Größe
|
||||
const maxSize = 50 * 1024 * 1024 // 50 MB
|
||||
if (file.size > maxSize) {
|
||||
errors.push(`${file.name} ist zu groß (max. 50 MB)`)
|
||||
}
|
||||
|
||||
// MIME-Type
|
||||
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'video/mp4', 'application/pdf']
|
||||
if (!allowed.includes(file.type)) {
|
||||
errors.push(`${file.name} hat ungültiges Format`)
|
||||
}
|
||||
|
||||
// Video-spezifisch: Auflösung prüfen
|
||||
if (file.type === 'video/mp4') {
|
||||
const video = document.createElement('video')
|
||||
video.src = URL.createObjectURL(file)
|
||||
video.onloadedmetadata = () => {
|
||||
if (video.videoWidth > 1920 || video.videoHeight > 1080) {
|
||||
errors.push(`${file.name} Auflösung zu hoch (max. 1920x1080)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
const handleFilesSelected = (files) => {
|
||||
const validFiles = []
|
||||
const errors = []
|
||||
|
||||
files.forEach(file => {
|
||||
const fileErrors = validateFile(file)
|
||||
if (fileErrors.length === 0) {
|
||||
validFiles.push(file)
|
||||
} else {
|
||||
errors.push(...fileErrors)
|
||||
}
|
||||
})
|
||||
|
||||
if (errors.length > 0) {
|
||||
setValidationErrors(errors)
|
||||
}
|
||||
|
||||
setUploadQueue(prev => [...prev, ...validFiles])
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Upload mit Progress
|
||||
|
||||
**multipart/form-data Upload:**
|
||||
```javascript
|
||||
const uploadFile = async (file, metadata) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('media_type', detectMediaType(file))
|
||||
formData.append('title', metadata.title || '')
|
||||
formData.append('description', metadata.description || '')
|
||||
formData.append('context', metadata.context || 'ablauf')
|
||||
formData.append('is_primary', metadata.is_primary || false)
|
||||
|
||||
return axios.post(
|
||||
`/api/exercises/${exerciseId}/media`,
|
||||
formData,
|
||||
{
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const percent = (progressEvent.loaded / progressEvent.total) * 100
|
||||
setUploadProgress(file.name, percent)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const detectMediaType = (file) => {
|
||||
if (file.type.startsWith('image/')) return 'image'
|
||||
if (file.type === 'video/mp4') return 'video'
|
||||
if (file.type === 'application/pdf') return 'document'
|
||||
return 'sketch' // Fallback
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Backend-Verarbeitung
|
||||
|
||||
**Endpoint:** `POST /api/exercises/{exercise_id}/media`
|
||||
|
||||
```python
|
||||
from fastapi import UploadFile, File, Form
|
||||
import magic # python-magic für MIME-Detection
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
@router.post("/exercises/{exercise_id}/media")
|
||||
async def upload_media(
|
||||
exercise_id: int,
|
||||
file: UploadFile = File(...),
|
||||
media_type: str = Form(...),
|
||||
title: str = Form(default=""),
|
||||
description: str = Form(default=""),
|
||||
context: str = Form(default="ablauf"),
|
||||
is_primary: bool = Form(default=False),
|
||||
session: dict = Depends(require_auth)
|
||||
):
|
||||
profile_id = session['profile_id']
|
||||
|
||||
# 1. Berechtigung prüfen
|
||||
check_exercise_permission(exercise_id, profile_id)
|
||||
|
||||
# 2. Datei-Validierung
|
||||
max_size = 50 * 1024 * 1024 # 50 MB
|
||||
file_content = await file.read()
|
||||
if len(file_content) > max_size:
|
||||
raise HTTPException(413, "Datei zu groß (max. 50 MB)")
|
||||
|
||||
# 3. MIME-Type verifizieren (nicht nur Extension!)
|
||||
mime = magic.from_buffer(file_content, mime=True)
|
||||
allowed_mimes = ['image/jpeg', 'image/png', 'image/gif', 'video/mp4', 'application/pdf']
|
||||
if mime not in allowed_mimes:
|
||||
raise HTTPException(400, f"Ungültiger Dateityp: {mime}")
|
||||
|
||||
# 4. Eindeutigen Dateinamen generieren
|
||||
file_hash = hashlib.sha256(file_content).hexdigest()[:12]
|
||||
file_ext = os.path.splitext(file.filename)[1]
|
||||
safe_filename = f"{file_hash}_{exercise_id}{file_ext}"
|
||||
|
||||
# 5. Speichern
|
||||
file_path = f"/app/media/exercises/{safe_filename}"
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(file_content)
|
||||
|
||||
# 6. DB-Eintrag
|
||||
cur = get_db_cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO exercise_media (
|
||||
exercise_id, media_type, file_path, file_size, mime_type,
|
||||
original_filename, title, description, context, is_primary, sort_order
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
COALESCE((SELECT MAX(sort_order) + 1 FROM exercise_media WHERE exercise_id = %s), 1)
|
||||
)
|
||||
RETURNING id, sort_order, created_at
|
||||
""", (
|
||||
exercise_id, media_type, file_path, len(file_content), mime,
|
||||
file.filename, title, description, context, is_primary, exercise_id
|
||||
))
|
||||
result = cur.fetchone()
|
||||
|
||||
return {
|
||||
"id": result['id'],
|
||||
"exercise_id": exercise_id,
|
||||
"media_type": media_type,
|
||||
"file_path": file_path,
|
||||
"file_size": len(file_content),
|
||||
"mime_type": mime,
|
||||
"original_filename": file.filename,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"context": context,
|
||||
"is_primary": is_primary,
|
||||
"sort_order": result['sort_order'],
|
||||
"created_at": result['created_at'].isoformat(),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Embed-Flow (YouTube, Instagram, Vimeo)
|
||||
|
||||
### 3.1 Frontend-Komponente: EmbedInput
|
||||
|
||||
**UI:**
|
||||
```jsx
|
||||
<div className="embed-input">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="YouTube, Instagram oder Vimeo URL einfügen"
|
||||
value={embedUrl}
|
||||
onChange={(e) => setEmbedUrl(e.target.value)}
|
||||
onBlur={handleEmbedParse}
|
||||
/>
|
||||
|
||||
{preview && (
|
||||
<div className="embed-preview">
|
||||
<iframe src={preview.embedUrl} />
|
||||
<div className="embed-metadata">
|
||||
<strong>{preview.platform}</strong> • {preview.title}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="embed-metadata-form">
|
||||
<input name="title" placeholder="Titel (optional)" />
|
||||
<textarea name="description" />
|
||||
<select name="context">
|
||||
<option value="ablauf">Ablauf</option>
|
||||
<option value="detail">Detail</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button onClick={handleEmbedSave}>Embed hinzufügen</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3.2 URL-Parsing (Client-Side)
|
||||
|
||||
**Unterstützte Formate:**
|
||||
```javascript
|
||||
const parseEmbedUrl = (url) => {
|
||||
// YouTube
|
||||
if (url.includes('youtube.com/watch?v=')) {
|
||||
const videoId = new URL(url).searchParams.get('v')
|
||||
return {
|
||||
platform: 'youtube',
|
||||
videoId,
|
||||
embedUrl: `https://www.youtube-nocookie.com/embed/${videoId}`,
|
||||
originalUrl: url,
|
||||
}
|
||||
}
|
||||
|
||||
if (url.includes('youtu.be/')) {
|
||||
const videoId = url.split('youtu.be/')[1].split('?')[0]
|
||||
return {
|
||||
platform: 'youtube',
|
||||
videoId,
|
||||
embedUrl: `https://www.youtube-nocookie.com/embed/${videoId}`,
|
||||
originalUrl: url,
|
||||
}
|
||||
}
|
||||
|
||||
// Vimeo
|
||||
if (url.includes('vimeo.com/')) {
|
||||
const videoId = url.split('vimeo.com/')[1].split('?')[0]
|
||||
return {
|
||||
platform: 'vimeo',
|
||||
videoId,
|
||||
embedUrl: `https://player.vimeo.com/video/${videoId}`,
|
||||
originalUrl: url,
|
||||
}
|
||||
}
|
||||
|
||||
// Instagram (Reel oder Post)
|
||||
if (url.includes('instagram.com/reel/') || url.includes('instagram.com/p/')) {
|
||||
const postId = url.match(/\/(reel|p)\/([^\/\?]+)/)[2]
|
||||
return {
|
||||
platform: 'instagram',
|
||||
postId,
|
||||
embedUrl: `https://www.instagram.com/p/${postId}/embed`,
|
||||
originalUrl: url,
|
||||
}
|
||||
}
|
||||
|
||||
// TikTok
|
||||
if (url.includes('tiktok.com/')) {
|
||||
const videoId = url.match(/video\/(\d+)/)?.[1]
|
||||
return {
|
||||
platform: 'tiktok',
|
||||
videoId,
|
||||
embedUrl: `https://www.tiktok.com/embed/v2/${videoId}`,
|
||||
originalUrl: url,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const handleEmbedParse = async () => {
|
||||
const parsed = parseEmbedUrl(embedUrl)
|
||||
|
||||
if (!parsed) {
|
||||
setError('Ungültige URL. Unterstützt: YouTube, Vimeo, Instagram, TikTok')
|
||||
return
|
||||
}
|
||||
|
||||
// Optional: Metadata via oEmbed API holen
|
||||
const metadata = await fetchOembedData(parsed)
|
||||
|
||||
setPreview({ ...parsed, ...metadata })
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 oEmbed Metadata (Optional)
|
||||
|
||||
**YouTube oEmbed:**
|
||||
```javascript
|
||||
const fetchYoutubeMetadata = async (videoId) => {
|
||||
const response = await fetch(
|
||||
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`
|
||||
)
|
||||
const data = await response.json()
|
||||
return {
|
||||
title: data.title,
|
||||
author: data.author_name,
|
||||
thumbnail: data.thumbnail_url,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Vimeo oEmbed:**
|
||||
```javascript
|
||||
const fetchVimeoMetadata = async (videoId) => {
|
||||
const response = await fetch(
|
||||
`https://vimeo.com/api/oembed.json?url=https://vimeo.com/${videoId}`
|
||||
)
|
||||
const data = await response.json()
|
||||
return {
|
||||
title: data.title,
|
||||
author: data.author_name,
|
||||
thumbnail: data.thumbnail_url,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Backend-Speicherung (Embed)
|
||||
|
||||
**Endpoint:** `POST /api/exercises/{exercise_id}/media` (gleicher Endpoint)
|
||||
|
||||
```python
|
||||
@router.post("/exercises/{exercise_id}/media")
|
||||
async def add_media(
|
||||
exercise_id: int,
|
||||
# Entweder file ODER embed_url (nicht beides)
|
||||
file: UploadFile = File(default=None),
|
||||
embed_url: str = Form(default=None),
|
||||
media_type: str = Form(...),
|
||||
title: str = Form(default=""),
|
||||
description: str = Form(default=""),
|
||||
context: str = Form(default="ablauf"),
|
||||
session: dict = Depends(require_auth)
|
||||
):
|
||||
if file and embed_url:
|
||||
raise HTTPException(400, "Entweder file oder embed_url, nicht beides")
|
||||
|
||||
if not file and not embed_url:
|
||||
raise HTTPException(400, "file oder embed_url erforderlich")
|
||||
|
||||
if embed_url:
|
||||
# Embed-URL validieren
|
||||
platform = detect_platform(embed_url)
|
||||
if not platform:
|
||||
raise HTTPException(400, "Ungültige Embed-URL")
|
||||
|
||||
# DB-Eintrag (ohne file_path)
|
||||
cur.execute("""
|
||||
INSERT INTO exercise_media (
|
||||
exercise_id, media_type, embed_url, embed_platform,
|
||||
title, description, context, is_primary, sort_order
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s,
|
||||
COALESCE((SELECT MAX(sort_order) + 1 FROM exercise_media WHERE exercise_id = %s), 1)
|
||||
)
|
||||
RETURNING id, sort_order, created_at
|
||||
""", (
|
||||
exercise_id, media_type, embed_url, platform,
|
||||
title, description, context, False, exercise_id
|
||||
))
|
||||
# ... return result
|
||||
|
||||
# ... else: file upload (siehe 2.4)
|
||||
|
||||
def detect_platform(url):
|
||||
if 'youtube.com' in url or 'youtu.be' in url:
|
||||
return 'youtube'
|
||||
if 'vimeo.com' in url:
|
||||
return 'vimeo'
|
||||
if 'instagram.com' in url:
|
||||
return 'instagram'
|
||||
if 'tiktok.com' in url:
|
||||
return 'tiktok'
|
||||
return None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Media-Galerie (Anzeige)
|
||||
|
||||
### 4.1 Komponente: MediaGallery
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface MediaGalleryProps {
|
||||
media: Media[]
|
||||
primaryMediaId?: number
|
||||
onMediaClick?: (media: Media) => void
|
||||
layout?: 'grid' | 'list' | 'carousel'
|
||||
showControls?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
**Render-Logik:**
|
||||
```jsx
|
||||
<div className="media-gallery">
|
||||
{/* Primary Media (groß oben) */}
|
||||
{primaryMedia && (
|
||||
<div className="primary-media">
|
||||
{renderMedia(primaryMedia, 'large')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid mit restlichen Medien */}
|
||||
<div className="media-grid">
|
||||
{secondaryMedia.map(media => (
|
||||
<div key={media.id} className="media-item">
|
||||
{renderMedia(media, 'thumbnail')}
|
||||
<div className="media-overlay">
|
||||
<span className="media-title">{media.title}</span>
|
||||
<span className="media-context">{media.context}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
const renderMedia = (media, size) => {
|
||||
// Lokale Datei
|
||||
if (media.file_path) {
|
||||
if (media.media_type === 'image') {
|
||||
return <img src={`/media/${media.file_path}`} alt={media.title} />
|
||||
}
|
||||
if (media.media_type === 'video') {
|
||||
return <video src={`/media/${media.file_path}`} controls />
|
||||
}
|
||||
if (media.media_type === 'document') {
|
||||
return <a href={`/media/${media.file_path}`} target="_blank">📄 {media.title}</a>
|
||||
}
|
||||
}
|
||||
|
||||
// Embed
|
||||
if (media.embed_url) {
|
||||
return (
|
||||
<iframe
|
||||
src={media.embed_url}
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Lightbox (Vollbild-Ansicht)
|
||||
|
||||
**Pattern:**
|
||||
```jsx
|
||||
const [lightboxMedia, setLightboxMedia] = useState(null)
|
||||
|
||||
<MediaGallery
|
||||
media={exercise.media}
|
||||
onMediaClick={(media) => setLightboxMedia(media)}
|
||||
/>
|
||||
|
||||
{lightboxMedia && (
|
||||
<Lightbox
|
||||
media={lightboxMedia}
|
||||
onClose={() => setLightboxMedia(null)}
|
||||
onPrev={() => setLightboxMedia(getPrevMedia(lightboxMedia))}
|
||||
onNext={() => setLightboxMedia(getNextMedia(lightboxMedia))}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Drag & Drop Reordering
|
||||
|
||||
### 5.1 Frontend-Komponente
|
||||
|
||||
**Nutzt DragDropList aus UI_COMPONENTS_SPEC.md:**
|
||||
```jsx
|
||||
<DragDropList
|
||||
items={media}
|
||||
onReorder={handleMediaReorder}
|
||||
renderItem={(media) => (
|
||||
<div className="media-reorder-item">
|
||||
<DragHandle />
|
||||
<img src={getThumbnail(media)} />
|
||||
<span>{media.title || media.original_filename}</span>
|
||||
{media.is_primary && <Badge>Hauptbild</Badge>}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
const handleMediaReorder = async (reorderedMedia) => {
|
||||
const mediaIds = reorderedMedia.map(m => m.id)
|
||||
|
||||
try {
|
||||
await api.reorderExerciseMedia(exerciseId, mediaIds)
|
||||
setMedia(reorderedMedia)
|
||||
} catch (error) {
|
||||
console.error('Reorder failed:', error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Backend-Endpoint
|
||||
|
||||
**Endpoint:** `PUT /api/exercises/{exercise_id}/media/reorder`
|
||||
|
||||
```python
|
||||
@router.put("/exercises/{exercise_id}/media/reorder")
|
||||
def reorder_media(
|
||||
exercise_id: int,
|
||||
request: dict, # {"media_ids": [3, 1, 2]}
|
||||
session: dict = Depends(require_auth)
|
||||
):
|
||||
profile_id = session['profile_id']
|
||||
check_exercise_permission(exercise_id, profile_id)
|
||||
|
||||
media_ids = request.get('media_ids', [])
|
||||
|
||||
# Validierung: Alle IDs gehören zur Exercise
|
||||
cur = get_db_cursor()
|
||||
cur.execute("""
|
||||
SELECT id FROM exercise_media WHERE exercise_id = %s
|
||||
""", (exercise_id,))
|
||||
existing_ids = {row['id'] for row in cur.fetchall()}
|
||||
|
||||
if set(media_ids) != existing_ids:
|
||||
raise HTTPException(400, "media_ids inkonsistent")
|
||||
|
||||
# Reorder
|
||||
for idx, media_id in enumerate(media_ids, start=1):
|
||||
cur.execute("""
|
||||
UPDATE exercise_media SET sort_order = %s WHERE id = %s
|
||||
""", (idx, media_id))
|
||||
|
||||
return {"ok": True, "reordered": len(media_ids)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Security & Performance
|
||||
|
||||
### 6.1 Security
|
||||
|
||||
**File Upload:**
|
||||
- MIME-Type-Validierung mit `python-magic` (nicht nur Extension)
|
||||
- Max. Dateigröße serverseitig prüfen
|
||||
- Virus-Scan optional (ClamAV) bei Prod-System
|
||||
- Keine Ausführungsrechte auf Upload-Ordner
|
||||
|
||||
**Embed:**
|
||||
- Nur Whitelist-Domains (youtube.com, vimeo.com, instagram.com, tiktok.com)
|
||||
- Keine User-Content-Domains (pastebin, file-sharing)
|
||||
|
||||
### 6.2 Performance
|
||||
|
||||
**Thumbnails generieren (Videos):**
|
||||
```python
|
||||
import ffmpeg
|
||||
|
||||
def generate_video_thumbnail(video_path, output_path):
|
||||
(
|
||||
ffmpeg
|
||||
.input(video_path, ss='00:00:01') # 1 Sekunde ins Video
|
||||
.filter('scale', 320, -1)
|
||||
.output(output_path, vframes=1)
|
||||
.run()
|
||||
)
|
||||
```
|
||||
|
||||
**Responsive Images (automatisch generieren):**
|
||||
```python
|
||||
from PIL import Image
|
||||
|
||||
def create_responsive_sizes(image_path):
|
||||
img = Image.open(image_path)
|
||||
sizes = {
|
||||
'thumbnail': (320, 240),
|
||||
'medium': (800, 600),
|
||||
'large': (1920, 1080),
|
||||
}
|
||||
|
||||
for size_name, (width, height) in sizes.items():
|
||||
img_resized = img.copy()
|
||||
img_resized.thumbnail((width, height), Image.LANCZOS)
|
||||
output_path = image_path.replace('.jpg', f'_{size_name}.jpg')
|
||||
img_resized.save(output_path, quality=85)
|
||||
```
|
||||
|
||||
**Frontend nutzt `srcset`:**
|
||||
```jsx
|
||||
<img
|
||||
src={`/media/${media.file_path}`}
|
||||
srcSet={`
|
||||
/media/${media.file_path.replace('.jpg', '_thumbnail.jpg')} 320w,
|
||||
/media/${media.file_path.replace('.jpg', '_medium.jpg')} 800w,
|
||||
/media/${media.file_path} 1920w
|
||||
`}
|
||||
sizes="(max-width: 600px) 320px, (max-width: 1200px) 800px, 1920px"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Error-Handling
|
||||
|
||||
### 7.1 Upload-Fehler
|
||||
|
||||
**Frontend:**
|
||||
```javascript
|
||||
try {
|
||||
await uploadFile(file, metadata)
|
||||
} catch (error) {
|
||||
if (error.response?.status === 413) {
|
||||
setError('Datei zu groß (max. 50 MB)')
|
||||
} else if (error.response?.status === 400) {
|
||||
setError(error.response.data.detail)
|
||||
} else {
|
||||
setError('Upload fehlgeschlagen. Bitte erneut versuchen.')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Backend:**
|
||||
```python
|
||||
# Detaillierte Fehlermeldungen
|
||||
if len(file_content) > max_size:
|
||||
raise HTTPException(413, f"Datei zu groß: {len(file_content) / (1024*1024):.1f} MB (max. 50 MB)")
|
||||
|
||||
if mime not in allowed_mimes:
|
||||
raise HTTPException(400, f"Dateityp {mime} nicht erlaubt. Erlaubt: {', '.join(allowed_mimes)}")
|
||||
```
|
||||
|
||||
### 7.2 Embed-Fehler
|
||||
|
||||
**Ungültige URL:**
|
||||
```javascript
|
||||
if (!parseEmbedUrl(url)) {
|
||||
setError('URL nicht erkannt. Unterstützte Plattformen: YouTube, Vimeo, Instagram, TikTok')
|
||||
}
|
||||
```
|
||||
|
||||
**Embed lädt nicht:**
|
||||
```javascript
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
onError={() => {
|
||||
setEmbedError('Video konnte nicht geladen werden. Ist das Video öffentlich?')
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### 8.1 Upload-Tests
|
||||
|
||||
```javascript
|
||||
// upload.spec.js
|
||||
test('Upload von JPEG-Datei funktioniert', async ({ page }) => {
|
||||
await page.goto('/exercises/1/edit')
|
||||
|
||||
const fileInput = page.locator('input[type="file"]')
|
||||
await fileInput.setInputFiles('test-files/image.jpg')
|
||||
|
||||
await page.click('button:has-text("Hochladen")')
|
||||
|
||||
await expect(page.locator('.media-gallery img')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Upload > 50 MB wird abgelehnt', async ({ page }) => {
|
||||
await page.goto('/exercises/1/edit')
|
||||
|
||||
await page.setInputFiles('input[type="file"]', 'test-files/large-video.mp4')
|
||||
|
||||
await expect(page.locator('.error:has-text("zu groß")')).toBeVisible()
|
||||
})
|
||||
```
|
||||
|
||||
### 8.2 Embed-Tests
|
||||
|
||||
```javascript
|
||||
test('YouTube URL wird korrekt geparst', async ({ page }) => {
|
||||
await page.goto('/exercises/1/edit')
|
||||
|
||||
await page.fill('input[placeholder*="YouTube"]', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ')
|
||||
await page.blur('input[placeholder*="YouTube"]')
|
||||
|
||||
await expect(page.locator('iframe[src*="youtube-nocookie.com"]')).toBeVisible()
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0
|
||||
**Letzte Änderung:** 2026-04-24
|
||||
**Status:** DRAFT - Awaiting Review
|
||||
760
.claude/docs/technical/SEARCH_FILTER_SPEC.md
Normal file
760
.claude/docs/technical/SEARCH_FILTER_SPEC.md
Normal file
|
|
@ -0,0 +1,760 @@
|
|||
# Search & Filter Specification
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** DRAFT - Awaiting Review
|
||||
**Autor:** Claude Code
|
||||
|
||||
---
|
||||
|
||||
## 1. Suchstrategie
|
||||
|
||||
### 1.1 Multi-Layer Search
|
||||
|
||||
**Drei Suchebenen:**
|
||||
1. **Keyword-Search:** Titel, Summary, Execution (PostgreSQL tsvector)
|
||||
2. **Katalog-Filter:** Focus Area, Style, Target Group, Age Group, Skill
|
||||
3. **Meta-Filter:** Visibility, Status, Creator, Club
|
||||
|
||||
**Kombination:** Alle Filter UND Keyword-Search kombinierbar
|
||||
|
||||
---
|
||||
|
||||
## 2. Volltext-Suche (PostgreSQL)
|
||||
|
||||
### 2.1 tsvector Implementation
|
||||
|
||||
**Migration:** Erweitert `exercises` Tabelle
|
||||
```sql
|
||||
-- Migration 014 (Teil)
|
||||
ALTER TABLE exercises ADD COLUMN search_vector tsvector;
|
||||
|
||||
-- Index für Volltext-Suche
|
||||
CREATE INDEX idx_exercises_search ON exercises USING gin(search_vector);
|
||||
|
||||
-- Trigger für automatische Updates
|
||||
CREATE OR REPLACE FUNCTION update_exercises_search_vector()
|
||||
RETURNS trigger AS $$
|
||||
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
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER exercises_search_update
|
||||
BEFORE INSERT OR UPDATE ON exercises
|
||||
FOR EACH ROW EXECUTE FUNCTION update_exercises_search_vector();
|
||||
```
|
||||
|
||||
**Gewichtung:**
|
||||
- A (höchste): Titel
|
||||
- B: Summary
|
||||
- C: Execution
|
||||
- D (niedrigste): Trainer Notes
|
||||
|
||||
### 2.2 Backend-Query
|
||||
|
||||
**Endpoint:** `GET /api/exercises?search=<keyword>`
|
||||
|
||||
```python
|
||||
@router.get("/exercises")
|
||||
def list_exercises(
|
||||
search: str = None,
|
||||
focus_area: int = None,
|
||||
visibility: str = None,
|
||||
status: str = None,
|
||||
skill_id: int = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
session: dict = Depends(require_auth)
|
||||
):
|
||||
profile_id = session['profile_id']
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
# Volltext-Suche
|
||||
if search:
|
||||
conditions.append("""
|
||||
search_vector @@ plainto_tsquery('german', %s)
|
||||
""")
|
||||
params.append(search)
|
||||
|
||||
# Katalog-Filter
|
||||
if focus_area:
|
||||
conditions.append("""
|
||||
EXISTS (
|
||||
SELECT 1 FROM exercise_focus_areas
|
||||
WHERE exercise_id = exercises.id AND focus_area_id = %s
|
||||
)
|
||||
""")
|
||||
params.append(focus_area)
|
||||
|
||||
# Meta-Filter
|
||||
if visibility:
|
||||
conditions.append("visibility = %s")
|
||||
params.append(visibility)
|
||||
|
||||
if status:
|
||||
conditions.append("status = %s")
|
||||
params.append(status)
|
||||
|
||||
# Skill-Filter
|
||||
if skill_id:
|
||||
conditions.append("""
|
||||
EXISTS (
|
||||
SELECT 1 FROM exercise_skills
|
||||
WHERE exercise_id = exercises.id AND skill_id = %s
|
||||
)
|
||||
""")
|
||||
params.append(skill_id)
|
||||
|
||||
# WHERE-Clause zusammenbauen
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
|
||||
# Query mit Ranking (bei Volltext-Suche)
|
||||
if search:
|
||||
query = f"""
|
||||
SELECT exercises.*,
|
||||
ts_rank(search_vector, plainto_tsquery('german', %s)) AS rank
|
||||
FROM exercises
|
||||
WHERE {where_clause}
|
||||
ORDER BY rank DESC, created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
params = [search] + params + [limit, offset]
|
||||
else:
|
||||
query = f"""
|
||||
SELECT exercises.* FROM exercises
|
||||
WHERE {where_clause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
params = params + [limit, offset]
|
||||
|
||||
cur = get_db_cursor()
|
||||
cur.execute(query, params)
|
||||
return [r2d(row) for row in cur.fetchall()]
|
||||
```
|
||||
|
||||
### 2.3 Highlighting (Optional)
|
||||
|
||||
**Snippet mit Treffer-Markierung:**
|
||||
```python
|
||||
def get_search_snippet(exercise, search_term):
|
||||
"""Generiert Snippet mit <mark>-Tags um Treffer"""
|
||||
cur = get_db_cursor()
|
||||
cur.execute("""
|
||||
SELECT ts_headline('german', execution,
|
||||
plainto_tsquery('german', %s),
|
||||
'StartSel=<mark>, StopSel=</mark>, MaxWords=50, MinWords=30'
|
||||
) AS snippet
|
||||
FROM exercises WHERE id = %s
|
||||
""", (search_term, exercise['id']))
|
||||
|
||||
result = cur.fetchone()
|
||||
return result['snippet'] if result else exercise['summary']
|
||||
```
|
||||
|
||||
**Response mit Snippet:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Maai - Distanzübung",
|
||||
"summary": "...",
|
||||
"snippet": "...Partner hält <mark>Pratzen</mark>, Ausführung wie Haupt..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend-Filter-UI
|
||||
|
||||
### 3.1 Komponente: SearchFilterBar
|
||||
|
||||
**Layout:**
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ 🔍 [Suche...] [Filter ▼] [×] │
|
||||
├────────────────────────────────────────────────────────┤
|
||||
│ Aktive Filter: │
|
||||
│ [Karate ×] [Breitensport ×] [Freigegeben ×] │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Code:**
|
||||
```jsx
|
||||
function SearchFilterBar({ onFiltersChange, initialFilters = {} }) {
|
||||
const [search, setSearch] = useState(initialFilters.search || '')
|
||||
const [showFilterPanel, setShowFilterPanel] = useState(false)
|
||||
const [filters, setFilters] = useState(initialFilters)
|
||||
|
||||
const { focusAreas, trainingStyles, targetGroups } = useCatalog()
|
||||
|
||||
const handleSearchChange = debounce((value) => {
|
||||
const newFilters = { ...filters, search: value }
|
||||
setFilters(newFilters)
|
||||
onFiltersChange(newFilters)
|
||||
}, 300)
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
const newFilters = { ...filters, [key]: value }
|
||||
setFilters(newFilters)
|
||||
onFiltersChange(newFilters)
|
||||
}
|
||||
|
||||
const clearFilter = (key) => {
|
||||
const newFilters = { ...filters }
|
||||
delete newFilters[key]
|
||||
setFilters(newFilters)
|
||||
onFiltersChange(newFilters)
|
||||
}
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setFilters({})
|
||||
setSearch('')
|
||||
onFiltersChange({})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="search-filter-bar">
|
||||
{/* Suchfeld */}
|
||||
<div className="search-input">
|
||||
<SearchIcon />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Übungen durchsuchen..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
handleSearchChange(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter-Toggle */}
|
||||
<button
|
||||
className="btn-filter"
|
||||
onClick={() => setShowFilterPanel(!showFilterPanel)}
|
||||
>
|
||||
Filter {Object.keys(filters).length > 0 && `(${Object.keys(filters).length})`}
|
||||
<ChevronIcon direction={showFilterPanel ? 'up' : 'down'} />
|
||||
</button>
|
||||
|
||||
{/* Clear-Button */}
|
||||
{Object.keys(filters).length > 0 && (
|
||||
<button className="btn-clear" onClick={clearAllFilters}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Aktive Filter (Chips) */}
|
||||
{Object.keys(filters).length > 0 && (
|
||||
<div className="active-filters">
|
||||
<span>Aktive Filter:</span>
|
||||
{filters.focus_area && (
|
||||
<Chip
|
||||
label={focusAreas.find(f => f.id === filters.focus_area)?.name}
|
||||
onRemove={() => clearFilter('focus_area')}
|
||||
/>
|
||||
)}
|
||||
{filters.visibility && (
|
||||
<Chip
|
||||
label={filters.visibility}
|
||||
onRemove={() => clearFilter('visibility')}
|
||||
/>
|
||||
)}
|
||||
{filters.status && (
|
||||
<Chip
|
||||
label={filters.status}
|
||||
onRemove={() => clearFilter('status')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter-Panel (aufklappbar) */}
|
||||
{showFilterPanel && (
|
||||
<div className="filter-panel">
|
||||
<div className="filter-section">
|
||||
<label>Fokusbereich</label>
|
||||
<select
|
||||
value={filters.focus_area || ''}
|
||||
onChange={(e) => handleFilterChange('focus_area', parseInt(e.target.value) || null)}
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{focusAreas.map(fa => (
|
||||
<option key={fa.id} value={fa.id}>{fa.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-section">
|
||||
<label>Stil</label>
|
||||
<select
|
||||
value={filters.training_style || ''}
|
||||
onChange={(e) => handleFilterChange('training_style', parseInt(e.target.value) || null)}
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{trainingStyles.map(ts => (
|
||||
<option key={ts.id} value={ts.id}>{ts.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-section">
|
||||
<label>Sichtbarkeit</label>
|
||||
<select
|
||||
value={filters.visibility || ''}
|
||||
onChange={(e) => handleFilterChange('visibility', e.target.value || null)}
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
<option value="private">Privat</option>
|
||||
<option value="club">Verein</option>
|
||||
<option value="official">Offiziell</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-section">
|
||||
<label>Status</label>
|
||||
<select
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value || null)}
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
<option value="draft">Entwurf</option>
|
||||
<option value="in_review">In Prüfung</option>
|
||||
<option value="approved">Freigegeben</option>
|
||||
<option value="archived">Archiviert</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Debouncing (Performance)
|
||||
|
||||
**Verhindert API-Call bei jedem Tastendruck:**
|
||||
```javascript
|
||||
function debounce(func, wait) {
|
||||
let timeout
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout)
|
||||
func(...args)
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(later, wait)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Nur nach 300ms Pause wird gesucht → weniger Server-Last**
|
||||
|
||||
---
|
||||
|
||||
## 4. Erweiterte Filter
|
||||
|
||||
### 4.1 Multi-Select-Filter
|
||||
|
||||
**Beispiel: Mehrere Zielgruppen gleichzeitig:**
|
||||
```jsx
|
||||
<MultiSelect
|
||||
options={targetGroups}
|
||||
selected={filters.target_groups || []}
|
||||
onChange={(selected) => handleFilterChange('target_groups', selected)}
|
||||
label="Zielgruppen"
|
||||
placeholder="Wähle eine oder mehrere..."
|
||||
/>
|
||||
```
|
||||
|
||||
**Backend-Query:**
|
||||
```python
|
||||
if target_groups: # List von IDs
|
||||
conditions.append("""
|
||||
EXISTS (
|
||||
SELECT 1 FROM exercise_target_groups
|
||||
WHERE exercise_id = exercises.id
|
||||
AND target_group_id = ANY(%s)
|
||||
)
|
||||
""")
|
||||
params.append(target_groups)
|
||||
```
|
||||
|
||||
### 4.2 Range-Filter (Dauer, Gruppengröße)
|
||||
|
||||
**UI:**
|
||||
```jsx
|
||||
<div className="filter-range">
|
||||
<label>Dauer (Minuten)</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Min"
|
||||
value={filters.duration_min || ''}
|
||||
onChange={(e) => handleFilterChange('duration_min', parseInt(e.target.value) || null)}
|
||||
/>
|
||||
<span>bis</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max"
|
||||
value={filters.duration_max || ''}
|
||||
onChange={(e) => handleFilterChange('duration_max', parseInt(e.target.value) || null)}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Backend-Query:**
|
||||
```python
|
||||
if duration_min:
|
||||
conditions.append("duration_max >= %s")
|
||||
params.append(duration_min)
|
||||
|
||||
if duration_max:
|
||||
conditions.append("duration_min <= %s")
|
||||
params.append(duration_max)
|
||||
```
|
||||
|
||||
### 4.3 Equipment-Filter (JSONB)
|
||||
|
||||
**UI:**
|
||||
```jsx
|
||||
<div className="filter-equipment">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.no_equipment || false}
|
||||
onChange={(e) => handleFilterChange('no_equipment', e.target.checked)}
|
||||
/>
|
||||
Ohne Hilfsmittel
|
||||
</label>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Backend-Query:**
|
||||
```python
|
||||
if no_equipment:
|
||||
conditions.append("""
|
||||
(equipment IS NULL OR equipment = '[]'::jsonb)
|
||||
""")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Faceted Search (Treffer-Zählung pro Filter)
|
||||
|
||||
### 5.1 Backend-Aggregation
|
||||
|
||||
**Zeigt Anzahl Treffer pro Filter-Option:**
|
||||
```python
|
||||
@router.get("/exercises/facets")
|
||||
def get_facets(search: str = None, session: dict = Depends(require_auth)):
|
||||
base_condition = "1=1"
|
||||
params = []
|
||||
|
||||
if search:
|
||||
base_condition = "search_vector @@ plainto_tsquery('german', %s)"
|
||||
params = [search]
|
||||
|
||||
cur = get_db_cursor()
|
||||
|
||||
# Focus Area Facets
|
||||
cur.execute(f"""
|
||||
SELECT fa.id, fa.name, COUNT(DISTINCT e.id) as count
|
||||
FROM focus_areas fa
|
||||
LEFT JOIN exercise_focus_areas efa ON fa.id = efa.focus_area_id
|
||||
LEFT JOIN exercises e ON efa.exercise_id = e.id
|
||||
WHERE {base_condition}
|
||||
GROUP BY fa.id, fa.name
|
||||
ORDER BY count DESC
|
||||
""", params)
|
||||
focus_areas = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
# Visibility Facets
|
||||
cur.execute(f"""
|
||||
SELECT visibility, COUNT(*) as count
|
||||
FROM exercises
|
||||
WHERE {base_condition}
|
||||
GROUP BY visibility
|
||||
""", params)
|
||||
visibility = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
return {
|
||||
"focus_areas": focus_areas,
|
||||
"visibility": visibility,
|
||||
# ... weitere Facets
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Frontend-Anzeige
|
||||
|
||||
**Zeigt Treffer-Anzahl neben Filter:**
|
||||
```jsx
|
||||
<select>
|
||||
<option value="">Alle</option>
|
||||
{facets.focus_areas.map(fa => (
|
||||
<option key={fa.id} value={fa.id}>
|
||||
{fa.name} ({fa.count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Saved Searches / Presets
|
||||
|
||||
### 6.1 Favoriten-Filter speichern
|
||||
|
||||
**UI:**
|
||||
```jsx
|
||||
<button onClick={handleSaveFilter}>
|
||||
⭐ Filter speichern
|
||||
</button>
|
||||
|
||||
{savedFilters.map(sf => (
|
||||
<button
|
||||
key={sf.id}
|
||||
onClick={() => loadFilter(sf)}
|
||||
className="saved-filter"
|
||||
>
|
||||
{sf.name}
|
||||
</button>
|
||||
))}
|
||||
```
|
||||
|
||||
**Backend-Tabelle:**
|
||||
```sql
|
||||
CREATE TABLE saved_exercise_searches (
|
||||
id SERIAL PRIMARY KEY,
|
||||
profile_id INT REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
filters JSONB NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**API:**
|
||||
```python
|
||||
@router.post("/exercises/saved-searches")
|
||||
def save_search(
|
||||
name: str,
|
||||
filters: dict,
|
||||
session: dict = Depends(require_auth)
|
||||
):
|
||||
profile_id = session['profile_id']
|
||||
|
||||
cur = get_db_cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO saved_exercise_searches (profile_id, name, filters)
|
||||
VALUES (%s, %s, %s)
|
||||
RETURNING id, created_at
|
||||
""", (profile_id, name, json.dumps(filters)))
|
||||
|
||||
result = cur.fetchone()
|
||||
return {
|
||||
"id": result['id'],
|
||||
"name": name,
|
||||
"filters": filters,
|
||||
"created_at": result['created_at'].isoformat(),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Sortierung
|
||||
|
||||
### 7.1 Sortier-Optionen
|
||||
|
||||
**UI:**
|
||||
```jsx
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
>
|
||||
<option value="relevance">Relevanz</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
<option value="oldest">Älteste zuerst</option>
|
||||
<option value="title_asc">Titel A-Z</option>
|
||||
<option value="title_desc">Titel Z-A</option>
|
||||
<option value="duration">Dauer (kurz → lang)</option>
|
||||
</select>
|
||||
```
|
||||
|
||||
### 7.2 Backend-Sortierung
|
||||
|
||||
**Query-Parameter:** `?sort=newest`
|
||||
|
||||
```python
|
||||
SORT_OPTIONS = {
|
||||
'relevance': 'rank DESC', # nur bei Volltext-Suche
|
||||
'newest': 'created_at DESC',
|
||||
'oldest': 'created_at ASC',
|
||||
'title_asc': 'title ASC',
|
||||
'title_desc': 'title DESC',
|
||||
'duration': 'duration_min ASC',
|
||||
}
|
||||
|
||||
@router.get("/exercises")
|
||||
def list_exercises(sort: str = 'newest', ...):
|
||||
order_by = SORT_OPTIONS.get(sort, 'created_at DESC')
|
||||
|
||||
# Bei Volltext-Suche IMMER nach Relevanz sortieren
|
||||
if search and sort != 'relevance':
|
||||
order_by = f"rank DESC, {order_by}"
|
||||
|
||||
query = f"""
|
||||
SELECT ... FROM exercises
|
||||
WHERE {where_clause}
|
||||
ORDER BY {order_by}
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Performance-Optimierung
|
||||
|
||||
### 8.1 Indizes
|
||||
|
||||
**Wichtige Indizes:**
|
||||
```sql
|
||||
-- Volltext-Suche
|
||||
CREATE INDEX idx_exercises_search ON exercises USING gin(search_vector);
|
||||
|
||||
-- Häufige Filter
|
||||
CREATE INDEX idx_exercises_visibility ON exercises(visibility);
|
||||
CREATE INDEX idx_exercises_status ON exercises(status);
|
||||
CREATE INDEX idx_exercises_created_at ON exercises(created_at DESC);
|
||||
|
||||
-- M:N Relations
|
||||
CREATE INDEX idx_exercise_focus_areas_focus ON exercise_focus_areas(focus_area_id);
|
||||
CREATE INDEX idx_exercise_skills_skill ON exercise_skills(skill_id);
|
||||
```
|
||||
|
||||
### 8.2 Query-Caching (Backend)
|
||||
|
||||
**Cachen häufiger Abfragen:**
|
||||
```python
|
||||
from functools import lru_cache
|
||||
import hashlib
|
||||
|
||||
@lru_cache(maxsize=100)
|
||||
def get_exercises_cached(filters_hash: str):
|
||||
# filters_hash = SHA256 von JSON(filters)
|
||||
# Cached für 60 Sekunden
|
||||
pass
|
||||
|
||||
def list_exercises(filters: dict):
|
||||
filters_hash = hashlib.sha256(
|
||||
json.dumps(filters, sort_keys=True).encode()
|
||||
).hexdigest()
|
||||
|
||||
return get_exercises_cached(filters_hash)
|
||||
```
|
||||
|
||||
### 8.3 Client-Side Caching (React Query)
|
||||
|
||||
**Nutzt React Query für intelligentes Caching:**
|
||||
```jsx
|
||||
const { data: exercises, isLoading } = useQuery(
|
||||
['exercises', filters],
|
||||
() => api.listExercises(filters),
|
||||
{
|
||||
staleTime: 5 * 60 * 1000, // 5 Minuten fresh
|
||||
cacheTime: 10 * 60 * 1000, // 10 Minuten im Cache
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Mobile-Optimierung
|
||||
|
||||
### 9.1 Filter-Drawer (Mobile)
|
||||
|
||||
**Statt Dropdown → Fullscreen-Drawer:**
|
||||
```jsx
|
||||
{/* Desktop: Inline-Panel */}
|
||||
<div className="filter-panel desktop-only">
|
||||
{/* Filter-Felder */}
|
||||
</div>
|
||||
|
||||
{/* Mobile: Drawer */}
|
||||
<Drawer
|
||||
isOpen={showFilterDrawer}
|
||||
onClose={() => setShowFilterDrawer(false)}
|
||||
position="bottom"
|
||||
>
|
||||
<div className="filter-drawer">
|
||||
<h3>Filter</h3>
|
||||
{/* Filter-Felder */}
|
||||
<button onClick={applyFilters}>
|
||||
{resultsCount} Ergebnisse anzeigen
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
```
|
||||
|
||||
### 9.2 Sticky Search-Bar
|
||||
|
||||
**Bleibt beim Scrollen oben:**
|
||||
```css
|
||||
.search-filter-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing
|
||||
|
||||
### 10.1 Volltext-Suche-Tests
|
||||
|
||||
```javascript
|
||||
test('Volltext-Suche findet Übung im Titel', async ({ page }) => {
|
||||
await page.goto('/exercises')
|
||||
|
||||
await page.fill('input[type="search"]', 'Maai')
|
||||
|
||||
await expect(page.locator('.exercise-card:has-text("Maai")')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Volltext-Suche findet Übung in Execution', async ({ page }) => {
|
||||
await page.goto('/exercises')
|
||||
|
||||
await page.fill('input[type="search"]', 'Pratzen')
|
||||
|
||||
await expect(page.locator('.exercise-card')).toHaveCount(1)
|
||||
})
|
||||
```
|
||||
|
||||
### 10.2 Filter-Kombination-Tests
|
||||
|
||||
```javascript
|
||||
test('Filter kombinierbar: Focus Area + Visibility', async ({ page }) => {
|
||||
await page.goto('/exercises')
|
||||
|
||||
await page.selectOption('select[name="focus_area"]', '1') // Karate
|
||||
await page.selectOption('select[name="visibility"]', 'club')
|
||||
|
||||
const cards = page.locator('.exercise-card')
|
||||
await expect(cards).toHaveCount(3)
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0
|
||||
**Letzte Änderung:** 2026-04-24
|
||||
**Status:** DRAFT - Awaiting Review
|
||||
454
.claude/docs/technical/SKILLS_MATRIX_SPEC.md
Normal file
454
.claude/docs/technical/SKILLS_MATRIX_SPEC.md
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
# Fähigkeitsmatrix / Reifegradmodell – Specification
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** DRAFT
|
||||
**Autor:** Claude Code
|
||||
**Basis:** shinkan_anforderungsdokument_entwurf.md §8 + DOMAIN_MODEL.md
|
||||
|
||||
---
|
||||
|
||||
## 1. Konzept
|
||||
|
||||
### 1.1 Was ist die Fähigkeitsmatrix?
|
||||
|
||||
Die Fähigkeitsmatrix verbindet **globale Fähigkeiten** (Skills) mit **kontextspezifischen
|
||||
Reifegradmodellen**. Sie beantwortet pro Kontext die Frage:
|
||||
|
||||
> „Was muss ein Schüler auf Stufe X dieser Fähigkeit können, und welche Übungen trainieren ihn von Stufe A auf Stufe B?"
|
||||
|
||||
**Drei Ebenen:**
|
||||
|
||||
```
|
||||
1. Globale Fähigkeit (skill)
|
||||
"Distanzgefühl" – gilt überall, für alle Stile und Zielgruppen
|
||||
|
||||
2. Reifegradmodell (maturity_model)
|
||||
"Karate / Shotokan / Breitensport" – definiert WIE VIELE Stufen es gibt
|
||||
und wie die Stufen heißen
|
||||
|
||||
3. Modell-Fähigkeitsstufe (model_skill_level)
|
||||
"Stufe 3 von Distanzgefühl im Shotokan-Breitensport-Modell" –
|
||||
beschreibt KONKRET was auf Stufe 3 erwartet wird
|
||||
```
|
||||
|
||||
### 1.2 Warum kontextabhängig?
|
||||
|
||||
Dieselbe Fähigkeit (z.B. „Distanzgefühl") hat in verschiedenen Kontexten
|
||||
unterschiedliche Bedeutungen:
|
||||
|
||||
| Kontext | Stufe 3 Bedeutung |
|
||||
|---------|-------------------|
|
||||
| Karate / Shotokan / Breitensport | Distanzkontrolle in einfachen Partnerübungen |
|
||||
| Karate / Shotokan / Leistungssport | Stabile Distanz im freien Kumite |
|
||||
| Selbstverteidigung / Erwachsene | Sicherheitsabstand in Alltagssituationen einschätzen |
|
||||
|
||||
Ein Modell hat **variable Stufenanzahl** (3-7, nicht fest 5):
|
||||
|
||||
```
|
||||
Shotokan Breitensport → 5 Stufen (Einsteiger bis Experte)
|
||||
Kinder-Karate → 4 Stufen (Kinder-gerecht vereinfacht)
|
||||
Leistungssport → 7 Stufen (feiner granuliert)
|
||||
```
|
||||
|
||||
### 1.3 Abgrenzung zu exercise_skills
|
||||
|
||||
```
|
||||
exercise_skills: Übung trainiert Fähigkeit mit Intensität und
|
||||
suggested required/target Stufe
|
||||
→ kontextunabhängig (gilt für alle Modelle)
|
||||
|
||||
model_skill_levels: Für dieses Modell bedeutet "Stufe 3" von Fähigkeit X:
|
||||
"[konkrete Beschreibung]"
|
||||
→ kontextspezifisch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Datenmodell
|
||||
|
||||
### 2.1 Entity-Übersicht
|
||||
|
||||
```
|
||||
focus_areas (global, existiert)
|
||||
└─ style_directions (existiert, früher training_styles)
|
||||
└─ target_groups (M:N, existiert)
|
||||
|
||||
maturity_models (NEU)
|
||||
├─ focus_area_id FK (optional, NULL = gilt für alle Fokusbereiche)
|
||||
├─ style_direction_id FK (optional)
|
||||
├─ target_group_id FK (optional)
|
||||
└─ level_count (INT) – wie viele Stufen hat das Modell?
|
||||
|
||||
model_levels (NEU) – Stufen-Definitionen
|
||||
├─ maturity_model_id FK
|
||||
├─ level_number (1 bis level_count)
|
||||
├─ name (z.B. "Einsteiger", "Grundlagen", ...)
|
||||
└─ description
|
||||
|
||||
model_skill_levels (NEU) – Was bedeutet Stufe X für Fähigkeit Y im Modell Z?
|
||||
├─ maturity_model_id FK
|
||||
├─ skill_id FK
|
||||
├─ level_number (muss in maturity_model.level_count liegen)
|
||||
├─ description (Pflicht-Text: was soll der Lernende können?)
|
||||
├─ observable_criteria (Beobachtungskriterien für Trainer)
|
||||
└─ example_exercises JSONB (Empfohlene Übungs-Typen, keine FK)
|
||||
```
|
||||
|
||||
### 2.2 Vollständige Migration (019_maturity_models.sql)
|
||||
|
||||
```sql
|
||||
-- Migration 019: Fähigkeitsmatrix / Reifegradmodelle
|
||||
-- Autor: Claude Code
|
||||
-- Datum: 2026-04-24
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
|
||||
-- ============================================================================
|
||||
-- MATURITY MODELS (Reifegradmodelle)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS maturity_models (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
|
||||
-- Kontext-Bindung (alle optional – NULL bedeutet "gilt allgemein")
|
||||
-- Je mehr gesetzt, desto spezifischer das Modell
|
||||
focus_area_id INT REFERENCES focus_areas(id) ON DELETE RESTRICT,
|
||||
style_direction_id INT REFERENCES style_directions(id) ON DELETE RESTRICT,
|
||||
target_group_id INT REFERENCES target_groups(id) ON DELETE RESTRICT,
|
||||
|
||||
-- Stufenanzahl (flexibel, nicht fest auf 5)
|
||||
level_count INT NOT NULL DEFAULT 5 CHECK (level_count BETWEEN 3 AND 10),
|
||||
|
||||
-- Sichtbarkeit & Freigabe
|
||||
-- 'draft': in Bearbeitung | 'active': in Nutzung | 'archived': nicht mehr aktiv
|
||||
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')),
|
||||
|
||||
-- Versionierung
|
||||
version VARCHAR(20) DEFAULT '1.0',
|
||||
|
||||
-- Ownership
|
||||
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
|
||||
|
||||
-- Import-Tracking (für Semantic MediaWiki Import)
|
||||
import_source VARCHAR(50),
|
||||
import_id VARCHAR(200),
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
-- Constraint: Name + Kontext eindeutig
|
||||
UNIQUE(name, focus_area_id, style_direction_id, target_group_id)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- MODEL LEVELS (Stufendefinitionen pro Modell)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_levels (
|
||||
id SERIAL PRIMARY KEY,
|
||||
maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE,
|
||||
level_number INT NOT NULL CHECK (level_number >= 1),
|
||||
name VARCHAR(100) NOT NULL, -- z.B. "Einsteiger", "Grundlagen", "Aufbau"
|
||||
description TEXT, -- Was zeichnet diese Stufe generell aus?
|
||||
sort_order INT NOT NULL,
|
||||
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
UNIQUE(maturity_model_id, level_number)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- MODEL SKILL LEVELS
|
||||
-- Was bedeutet "Stufe X der Fähigkeit Y im Modell Z"?
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_skill_levels (
|
||||
id SERIAL PRIMARY KEY,
|
||||
maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE,
|
||||
skill_id INT NOT NULL REFERENCES skills(id) ON DELETE RESTRICT,
|
||||
level_number INT NOT NULL CHECK (level_number >= 1),
|
||||
|
||||
-- Was wird auf dieser Stufe erwartet? (Pflichtfeld)
|
||||
description TEXT NOT NULL,
|
||||
|
||||
-- Konkrete Beobachtungskriterien für Trainer
|
||||
-- z.B. "Kann Distanz in ruhigen Partnerübungen halten (3/3 Versuchen)"
|
||||
observable_criteria TEXT,
|
||||
|
||||
-- Empfohlene Übungs-Typen (keine FK, nur Hinweise)
|
||||
-- z.B. {"types": ["Grundübung", "Partnerübung"], "max_duration_min": 15}
|
||||
example_exercise_hints JSONB,
|
||||
|
||||
-- KI-generiert?
|
||||
ai_generated BOOLEAN DEFAULT false,
|
||||
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
UNIQUE(maturity_model_id, skill_id, level_number),
|
||||
|
||||
-- level_number muss im Bereich des Modells liegen
|
||||
-- (wird per Trigger oder Backend-Validierung geprüft)
|
||||
CONSTRAINT ck_level_positive CHECK (level_number > 0)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- SKILL CATALOG: Felder für Matrix-Nutzung (falls noch nicht vorhanden)
|
||||
-- ============================================================================
|
||||
|
||||
-- Fähigkeiten können einem bestimmten Fokusbereich "zugehören" (primär)
|
||||
-- bleiben aber global nutzbar (secondary assignments über M:N)
|
||||
ALTER TABLE skills
|
||||
ADD COLUMN IF NOT EXISTS primary_focus_area_id INT REFERENCES focus_areas(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS is_cross_domain BOOLEAN DEFAULT false; -- gilt für alle Fokusbereiche
|
||||
|
||||
-- ============================================================================
|
||||
-- INDEXES
|
||||
-- ============================================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_maturity_models_focus ON maturity_models(focus_area_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_maturity_models_style ON maturity_models(style_direction_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_maturity_models_target ON maturity_models(target_group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_maturity_models_status ON maturity_models(status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_model_levels_model ON model_levels(maturity_model_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_model_skill_levels_model ON model_skill_levels(maturity_model_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_skill_levels_skill ON model_skill_levels(skill_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_skill_levels_combo ON model_skill_levels(maturity_model_id, skill_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- TRIGGERS
|
||||
-- ============================================================================
|
||||
|
||||
DROP TRIGGER IF EXISTS maturity_models_update ON maturity_models;
|
||||
CREATE TRIGGER maturity_models_update
|
||||
BEFORE UPDATE ON maturity_models
|
||||
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
|
||||
|
||||
DROP TRIGGER IF EXISTS model_skill_levels_update ON model_skill_levels;
|
||||
CREATE TRIGGER model_skill_levels_update
|
||||
BEFORE UPDATE ON model_skill_levels
|
||||
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
|
||||
|
||||
RAISE NOTICE 'Migration 019 completed successfully (Maturity Models / Fähigkeitsmatrix)';
|
||||
|
||||
END $$;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Beispiel-Daten
|
||||
|
||||
### 3.1 Beispiel-Modell: Karate Shotokan Breitensport (5 Stufen)
|
||||
|
||||
```sql
|
||||
INSERT INTO maturity_models (name, focus_area_id, style_direction_id, target_group_id, level_count, status)
|
||||
VALUES ('Karate Shotokan Breitensport', 1, 1, 1, 5, 'active');
|
||||
|
||||
-- Stufennamen
|
||||
INSERT INTO model_levels (maturity_model_id, level_number, name, description, sort_order) VALUES
|
||||
(1, 1, 'Einsteiger', 'Erste Begegnung – keine Vorkenntnisse erforderlich', 1),
|
||||
(1, 2, 'Grundlagen', 'Grundprinzipien bekannt, in ruhigen Situationen anwendbar', 2),
|
||||
(1, 3, 'Aufbau', 'Semi-intuitiver Einsatz, mit gelegentlicher Korrektur', 3),
|
||||
(1, 4, 'Fortgeschritten', 'Intuitiver Einsatz auch unter Druck', 4),
|
||||
(1, 5, 'Experte', 'Vollständig automatisiert, stabile Leistung auf Spitzenniveau', 5);
|
||||
|
||||
-- Fähigkeitsstufen: Distanzgefühl im Shotokan-Breitensport-Modell
|
||||
INSERT INTO model_skill_levels (maturity_model_id, skill_id, level_number, description, observable_criteria)
|
||||
VALUES
|
||||
(1, 10, 1, 'Versteht das Konzept "Distanz" und kann erklären warum es wichtig ist.',
|
||||
'Kann auf Frage benennen, was Maai bedeutet.'),
|
||||
(1, 10, 2, 'Hält in langsamen, geführten Partnerübungen die Angriffsdistanz.',
|
||||
'In 3 von 3 langsamen Wiederholungen korrekte Distanz eingenommen.'),
|
||||
(1, 10, 3, 'Kontrolliert Distanz in mittlerer Geschwindigkeit bei Standardtechniken.',
|
||||
'Trainer bewertet Distanzkontrolle mit "gut" in Kihon-Ippon-Kumite.'),
|
||||
(1, 10, 4, 'Stabiler Einsatz im Jiyu-Ippon-Kumite, selten vom Partner überrascht.',
|
||||
'In freiem Jiyu-Kumite hält Schüler Linie in >70% der Aktionen.'),
|
||||
(1, 10, 5, 'Feingefühl für minimale Distanzveränderungen, taktischer Einsatz im Shiai.',
|
||||
'Wettkampferfolge durch bewusstes Distanzmanagement nachweisbar.');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API-Endpoints
|
||||
|
||||
### 4.1 Übersicht
|
||||
|
||||
| Method | Endpoint | Beschreibung |
|
||||
|--------|----------|--------------|
|
||||
| GET | `/maturity-models` | Liste aller Modelle (mit Filter) |
|
||||
| GET | `/maturity-models/{id}` | Modell-Detail mit Stufen + Skills |
|
||||
| POST | `/maturity-models` | Neues Modell erstellen (Admin) |
|
||||
| PUT | `/maturity-models/{id}` | Modell bearbeiten (Admin) |
|
||||
| DELETE | `/maturity-models/{id}` | Modell löschen (Admin) |
|
||||
| GET | `/maturity-models/{id}/skills` | Alle Fähigkeiten + Stufen dieses Modells |
|
||||
| PUT | `/maturity-models/{id}/skills/{skill_id}/levels` | Stufen-Beschreibungen setzen (Admin) |
|
||||
| GET | `/maturity-models/resolve` | Bestes Modell für Kontext finden |
|
||||
|
||||
### 4.2 `GET /maturity-models/resolve`
|
||||
|
||||
Findet das spezifischste Modell für einen gegebenen Kontext.
|
||||
Wird von der KI und vom Frontend verwendet.
|
||||
|
||||
**Query Parameters:**
|
||||
- `focus_area_id` (int, optional)
|
||||
- `style_direction_id` (int, optional)
|
||||
- `target_group_id` (int, optional)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"model": {
|
||||
"id": 1,
|
||||
"name": "Karate Shotokan Breitensport",
|
||||
"level_count": 5,
|
||||
"match_specificity": "full"
|
||||
},
|
||||
"fallback_used": false
|
||||
}
|
||||
```
|
||||
|
||||
**Auflösungslogik (Most Specific First):**
|
||||
1. Alle drei passen (focus + style + target) → `match_specificity: "full"`
|
||||
2. focus + style passen → `match_specificity: "style"`
|
||||
3. Nur focus passt → `match_specificity: "focus"`
|
||||
4. Allgemeines Modell (alle FK = NULL) → `match_specificity: "generic"`
|
||||
5. Kein Modell vorhanden → `match_specificity: null`, 404
|
||||
|
||||
---
|
||||
|
||||
## 5. Verbindung zu Übungen
|
||||
|
||||
### 5.1 Wie exercise_skills das Modell referenziert
|
||||
|
||||
`exercise_skills` speichert `required_level` und `target_level` als **Stufen-Namen**
|
||||
(einsteiger/grundlagen/aufbau/fortgeschritten/experte), die modellunabhängig sind.
|
||||
|
||||
Das Frontend kann bei Bedarf die konkrete Modell-Beschreibung nachladen:
|
||||
|
||||
```
|
||||
exercise_skills.required_level = "grundlagen"
|
||||
→ GET /maturity-models/resolve?focus_area_id=1
|
||||
→ Model: Karate Shotokan Breitensport (level_count=5)
|
||||
→ model_levels: level_number=2, name="Grundlagen"
|
||||
→ model_skill_levels für skill_id=10: "Hält in ruhigen Partnerübungen Distanz"
|
||||
```
|
||||
|
||||
### 5.2 Darstellung in der Übungsdetail-Ansicht
|
||||
|
||||
```
|
||||
Fähigkeiten
|
||||
─────────────────────────────────────────────
|
||||
★ Distanzgefühl (primär)
|
||||
Voraussetzung: Grundlagen → Ziel: Aufbau
|
||||
Intensität: Hoch
|
||||
────────────────────────────────
|
||||
Grundlagen: "Hält in ruhigen Partnerübungen
|
||||
Distanz"
|
||||
Aufbau: "Kontrolle in mittlerer
|
||||
Geschwindigkeit"
|
||||
[Kontext: Shotokan Breitensport ▼]
|
||||
─────────────────────────────────────────────
|
||||
◦ Reaktionsschnelligkeit (sekundär)
|
||||
Voraussetzung: Einsteiger → Ziel: Grundlagen
|
||||
Intensität: Mittel
|
||||
```
|
||||
|
||||
**Kontext-Dropdown:** Trainer kann Modell wechseln um kontextspezifische Beschreibungen zu sehen.
|
||||
|
||||
---
|
||||
|
||||
## 6. KI-Unterstützung für Modell-Pflege
|
||||
|
||||
Die KI kann `model_skill_levels`-Beschreibungen vorschlagen.
|
||||
|
||||
**Prompt-Placeholder:**
|
||||
- `{{skill_name}}` – Name der Fähigkeit
|
||||
- `{{model_name}}` – Name des Reifegradmodells
|
||||
- `{{level_count}}` – Anzahl der Stufen
|
||||
- `{{level_names}}` – Namen der Stufen (z.B. "Einsteiger, Grundlagen, Aufbau...")
|
||||
- `{{focus_area}}` – Fokusbereich
|
||||
- `{{target_group}}` – Zielgruppe
|
||||
|
||||
**Endpoint:** `POST /maturity-models/{id}/ai/suggest-skill-levels`
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"skill_id": 10,
|
||||
"regenerate_existing": false
|
||||
}
|
||||
|
||||
// Response
|
||||
{
|
||||
"skill": {"id": 10, "name": "Distanzgefühl"},
|
||||
"suggestions": [
|
||||
{
|
||||
"level_number": 1,
|
||||
"level_name": "Einsteiger",
|
||||
"description": "...",
|
||||
"observable_criteria": "...",
|
||||
"ai_generated": true
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Semantic MediaWiki Import
|
||||
|
||||
Die bestehende Fähigkeitsmatrix im SMW enthält bereits Stufen-Beschreibungen.
|
||||
|
||||
**Erweiterung der MEDIAWIKI_IMPORT_SPEC.md:**
|
||||
|
||||
| Wiki-Feld | Ziel-Tabelle | Transformation |
|
||||
|-----------|-------------|----------------|
|
||||
| SMW Property: `Stufe 1 Beschreibung` | `model_skill_levels.description` (level=1) | Direkt |
|
||||
| SMW Property: `Stufe N Beschreibung` | `model_skill_levels.description` (level=N) | Direkt |
|
||||
| SMW Property: `Modell` | `maturity_models.name` | Lookup/Create |
|
||||
| SMW Property: `Fokusbereich` | `maturity_models.focus_area_id` | Name → ID |
|
||||
|
||||
**Import-Endpoint:** `POST /import/mediawiki/execute` mit `import_type: "maturity_model"`
|
||||
|
||||
---
|
||||
|
||||
## 8. Admin-Workflow
|
||||
|
||||
### 8.1 Modell anlegen (Admin)
|
||||
|
||||
```
|
||||
1. Admin wählt: Fokusbereich + Stil + Zielgruppe
|
||||
2. Admin legt Stufenanzahl + Stufennamen fest
|
||||
3. Admin weist Fähigkeiten dem Modell zu (Pflicht-/Optional-Skills)
|
||||
4. Für jede Fähigkeit × Stufe: Beschreibung eingeben
|
||||
→ Optional: KI-Vorschlag generieren
|
||||
5. Modell auf "active" stellen
|
||||
```
|
||||
|
||||
### 8.2 SMW-Migration-Workflow
|
||||
|
||||
```
|
||||
1. SMW-Import: Bestehende Modelle und Beschreibungen importieren
|
||||
2. Review: Trainer prüft importierte Beschreibungen
|
||||
3. KI-Ergänzung: Für fehlende Stufen KI-Vorschläge generieren
|
||||
4. Freigabe: status → 'active'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 2026-04-24
|
||||
**Status:** DRAFT
|
||||
**Nächste Schritte:**
|
||||
- SMW-Struktur analysieren (User legt Daten ins Repo)
|
||||
- Migration 019 implementieren
|
||||
- Admin-UI für Modellpflege spezifizieren (UI_COMPONENTS_SPEC.md ergänzen)
|
||||
1036
.claude/docs/technical/UI_COMPONENTS_SPEC.md
Normal file
1036
.claude/docs/technical/UI_COMPONENTS_SPEC.md
Normal file
File diff suppressed because it is too large
Load Diff
10
.env.example
10
.env.example
|
|
@ -23,3 +23,13 @@ ENVIRONMENT=production
|
|||
|
||||
# Media Storage
|
||||
MEDIA_DIR=/app/media
|
||||
|
||||
# MediaWiki Import (Semantic MediaWiki)
|
||||
MEDIAWIKI_API_URL=https://karatetrainer.net/api.php
|
||||
MEDIAWIKI_USER=Jinkendo
|
||||
MEDIAWIKI_PASSWORD=CHANGE_ME
|
||||
# Kategorienamen im Wiki (echte Namen von karatetrainer.net)
|
||||
MEDIAWIKI_CATEGORY_EXERCISES=Übungen
|
||||
MEDIAWIKI_CATEGORY_SKILLS=Fähigkeitsbeschreibung
|
||||
MEDIAWIKI_CATEGORY_METHODS=Methodenbeschreibung
|
||||
MEDIAWIKI_CATEGORY_MODELS=Reifegradmodelle
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ def read_root():
|
|||
}
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, clubs, skills, training_planning, catalogs
|
||||
from routers import auth, profiles, exercises, clubs, skills, training_planning, catalogs, import_wiki
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
|
|
@ -79,6 +79,7 @@ app.include_router(clubs.router)
|
|||
app.include_router(skills.router)
|
||||
app.include_router(training_planning.router)
|
||||
app.include_router(catalogs.router)
|
||||
app.include_router(import_wiki.router)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
|
|
|||
40
backend/migrations/018_wiki_import_tracking.sql
Normal file
40
backend/migrations/018_wiki_import_tracking.sql
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
-- Migration 018: MediaWiki Import Tracking Tables
|
||||
-- Tracks import runs and cross-references between Wiki pages and local DB entries
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wiki_import_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
import_type VARCHAR(50) NOT NULL
|
||||
CHECK (import_type IN ('exercise', 'skill', 'method', 'maturity_model')),
|
||||
import_status VARCHAR(20) NOT NULL DEFAULT 'running'
|
||||
CHECK (import_status IN ('running', 'completed', 'failed')),
|
||||
category VARCHAR(200), -- Wiki category name used
|
||||
dry_run BOOLEAN DEFAULT false,
|
||||
reimport BOOLEAN DEFAULT false,
|
||||
items_total INT DEFAULT 0,
|
||||
items_imported INT DEFAULT 0,
|
||||
items_skipped INT DEFAULT 0,
|
||||
items_failed INT DEFAULT 0,
|
||||
error_log JSONB DEFAULT '[]'::jsonb, -- [{item, error}, ...]
|
||||
imported_by INT REFERENCES profiles(id),
|
||||
started_at TIMESTAMP DEFAULT NOW(),
|
||||
finished_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wiki_import_references (
|
||||
id SERIAL PRIMARY KEY,
|
||||
wiki_page_title VARCHAR(500) NOT NULL,
|
||||
wiki_page_id INT, -- MediaWiki internal page ID
|
||||
content_type VARCHAR(50) NOT NULL
|
||||
CHECK (content_type IN ('exercise', 'skill', 'method', 'maturity_model')),
|
||||
local_id INT NOT NULL, -- ID in the local DB table
|
||||
last_imported TIMESTAMP DEFAULT NOW(),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(wiki_page_title, content_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wiki_refs_title ON wiki_import_references(wiki_page_title);
|
||||
CREATE INDEX IF NOT EXISTS idx_wiki_refs_type ON wiki_import_references(content_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_wiki_log_status ON wiki_import_log(import_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_wiki_log_type ON wiki_import_log(import_type);
|
||||
637
backend/routers/import_wiki.py
Normal file
637
backend/routers/import_wiki.py
Normal file
|
|
@ -0,0 +1,637 @@
|
|||
"""
|
||||
MediaWiki Import Router
|
||||
|
||||
Importiert Übungen, Fähigkeiten und Methoden aus Semantic MediaWiki
|
||||
direkt via API (kein XML-Export). Nur Super-Admin darf importieren.
|
||||
|
||||
Wiki-URL: https://karatetrainer.net/api.php
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
from smw_client import SmwClient, SmwClientError
|
||||
from smw_mapper import map_wiki_to_exercise, map_wiki_to_skill, map_wiki_to_method, build_skill_assignments
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["import"])
|
||||
|
||||
# Kategorie-Namen aus .env (echte Namen von karatetrainer.net)
|
||||
CATEGORY_EXERCISES = os.getenv("MEDIAWIKI_CATEGORY_EXERCISES", "Übungen")
|
||||
CATEGORY_SKILLS = os.getenv("MEDIAWIKI_CATEGORY_SKILLS", "Fähigkeitsbeschreibung")
|
||||
CATEGORY_METHODS = os.getenv("MEDIAWIKI_CATEGORY_METHODS", "Methodenbeschreibung")
|
||||
CATEGORY_MODELS = os.getenv("MEDIAWIKI_CATEGORY_MODELS", "Reifegradmodelle")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Pydantic Models #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class ImportExecuteRequest(BaseModel):
|
||||
category: str
|
||||
import_type: str = "exercise" # exercise | skill | method
|
||||
reimport_existing: bool = False
|
||||
dry_run: bool = False
|
||||
limit: Optional[int] = None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Auth-Hilfsfunktion #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def require_admin(session: dict = Depends(require_auth)) -> dict:
|
||||
"""Nur Super-Admins dürfen importieren."""
|
||||
if session.get("role") not in ("admin", "superadmin"):
|
||||
raise HTTPException(status_code=403, detail="Nur Admins dürfen den Import ausführen")
|
||||
return session
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Preview Endpoint #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@router.get("/import/mediawiki/preview")
|
||||
async def preview_import(
|
||||
category: str = Query(default=CATEGORY_EXERCISES),
|
||||
import_type: str = Query(default="exercise"),
|
||||
limit: int = Query(default=10, ge=1, le=100),
|
||||
session: dict = Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Zeigt Vorschau: Welche Seiten würden importiert werden?
|
||||
Überprüft Duplikate und mapped Felder ohne zu speichern.
|
||||
"""
|
||||
client = SmwClient()
|
||||
try:
|
||||
members = await client.get_category_members(category, limit=limit)
|
||||
except SmwClientError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Wiki-API nicht erreichbar: {e}")
|
||||
|
||||
preview = []
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
for member in members[:limit]:
|
||||
page_title = member["title"]
|
||||
page_id = member.get("pageid")
|
||||
|
||||
# Duplikat-Check
|
||||
cur.execute(
|
||||
"SELECT id, last_imported FROM wiki_import_references WHERE wiki_page_title = %s AND content_type = %s",
|
||||
(page_title, import_type)
|
||||
)
|
||||
existing_ref = r2d(cur.fetchone())
|
||||
|
||||
# SMW Properties abrufen
|
||||
mapped_fields = {}
|
||||
warnings = []
|
||||
errors = []
|
||||
try:
|
||||
smw_props = await client.browse_subject(page_title)
|
||||
if import_type == "exercise":
|
||||
mapped = map_wiki_to_exercise(page_title, page_id, smw_props)
|
||||
elif import_type == "skill":
|
||||
mapped = map_wiki_to_skill(page_title, page_id, smw_props)
|
||||
else:
|
||||
mapped = map_wiki_to_method(page_title, page_id, smw_props)
|
||||
|
||||
warnings = mapped.pop("warnings", [])
|
||||
# Entferne interne Felder aus der Vorschau
|
||||
for k in ("wiki_page_id", "import_source", "import_id"):
|
||||
mapped.pop(k, None)
|
||||
mapped_fields = mapped
|
||||
|
||||
# Warnung wenn Pflichtfelder fehlen (nur bei Übungen)
|
||||
if import_type == "exercise":
|
||||
if not mapped.get("goal") and not mapped.get("execution"):
|
||||
errors.append("Pflichtfelder 'Ziel' und 'Durchführung' fehlen")
|
||||
|
||||
except SmwClientError as e:
|
||||
errors.append(f"Seite nicht abrufbar: {e}")
|
||||
|
||||
preview.append({
|
||||
"wiki_page_title": page_title,
|
||||
"wiki_page_id": page_id,
|
||||
"already_imported": existing_ref is not None,
|
||||
"last_imported_at": existing_ref["last_imported"].isoformat() if existing_ref else None,
|
||||
"mapped_fields": mapped_fields,
|
||||
"warnings": warnings,
|
||||
"errors": errors,
|
||||
})
|
||||
|
||||
return {
|
||||
"category": category,
|
||||
"import_type": import_type,
|
||||
"total_found": len(members),
|
||||
"preview": preview,
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Execute Import (async Background Task) #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@router.post("/import/mediawiki/execute", status_code=202)
|
||||
async def execute_import(
|
||||
body: ImportExecuteRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
session: dict = Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Startet einen MediaWiki-Import als Background-Task.
|
||||
Gibt sofort log_id zurück – Status via GET /import/mediawiki/status/{log_id}.
|
||||
"""
|
||||
profile_id = session["profile_id"]
|
||||
|
||||
# Log-Eintrag anlegen
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""INSERT INTO wiki_import_log
|
||||
(import_type, import_status, category, dry_run, reimport, imported_by)
|
||||
VALUES (%s, 'running', %s, %s, %s, %s)
|
||||
RETURNING id""",
|
||||
(body.import_type, body.category, body.dry_run, body.reimport_existing, profile_id)
|
||||
)
|
||||
log_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
|
||||
# Import asynchron starten
|
||||
background_tasks.add_task(
|
||||
_run_import,
|
||||
log_id=log_id,
|
||||
category=body.category,
|
||||
import_type=body.import_type,
|
||||
reimport=body.reimport_existing,
|
||||
dry_run=body.dry_run,
|
||||
limit=body.limit,
|
||||
)
|
||||
|
||||
return {
|
||||
"log_id": log_id,
|
||||
"status": "running",
|
||||
"message": f"Import gestartet. Status: GET /api/import/mediawiki/status/{log_id}",
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Status & Logs #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@router.get("/import/mediawiki/status/{log_id}")
|
||||
def get_import_status(
|
||||
log_id: int,
|
||||
session: dict = Depends(require_admin),
|
||||
):
|
||||
"""Status eines laufenden oder abgeschlossenen Imports."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT * FROM wiki_import_log WHERE id = %s", (log_id,))
|
||||
row = r2d(cur.fetchone())
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Import-Log nicht gefunden")
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/import/mediawiki/logs")
|
||||
def list_import_logs(
|
||||
session: dict = Depends(require_admin),
|
||||
):
|
||||
"""Alle Import-Logs (neueste zuerst, ohne error_log Details)."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""SELECT id, import_type, import_status, category, dry_run, reimport,
|
||||
items_total, items_imported, items_skipped, items_failed,
|
||||
imported_by, started_at, finished_at
|
||||
FROM wiki_import_log
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 50"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [r2d(r) for r in rows]
|
||||
|
||||
|
||||
@router.delete("/import/mediawiki/references/{ref_id}", status_code=204)
|
||||
def delete_import_reference(
|
||||
ref_id: int,
|
||||
session: dict = Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Löscht eine Import-Referenz, damit das Item bei nächstem Import
|
||||
als neu behandelt wird (Re-Import ermöglichen).
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("DELETE FROM wiki_import_references WHERE id = %s RETURNING id", (ref_id,))
|
||||
deleted = cur.fetchone()
|
||||
conn.commit()
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Referenz nicht gefunden")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Schema-Discovery (Debug-Hilfsmittel für Admins) #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@router.get("/import/mediawiki/discover/{page_title:path}")
|
||||
async def discover_properties(
|
||||
page_title: str,
|
||||
session: dict = Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Gibt alle SMW-Properties einer Wiki-Seite zurück.
|
||||
Nützlich um Property-Namen zu ermitteln und smw_mapper.py anzupassen.
|
||||
"""
|
||||
client = SmwClient()
|
||||
try:
|
||||
props = await client.discover_properties(page_title)
|
||||
except SmwClientError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Wiki-API Fehler: {e}")
|
||||
return {"page_title": page_title, "properties": props}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Background Import Worker #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def _run_import(
|
||||
log_id: int,
|
||||
category: str,
|
||||
import_type: str,
|
||||
reimport: bool,
|
||||
dry_run: bool,
|
||||
limit: Optional[int],
|
||||
):
|
||||
"""Hintergrund-Task: Importiert alle Seiten einer Kategorie."""
|
||||
client = SmwClient()
|
||||
stats = {"total": 0, "imported": 0, "skipped": 0, "failed": 0}
|
||||
errors = []
|
||||
|
||||
try:
|
||||
members = await client.get_category_members(category, limit=limit or 500)
|
||||
stats["total"] = len(members)
|
||||
_update_log(log_id, import_status="running", items_total=stats["total"])
|
||||
|
||||
for member in members:
|
||||
page_title = member["title"]
|
||||
page_id = member.get("pageid")
|
||||
|
||||
# Duplikat-Check
|
||||
if not reimport:
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT id FROM wiki_import_references WHERE wiki_page_title = %s AND content_type = %s",
|
||||
(page_title, import_type)
|
||||
)
|
||||
if cur.fetchone():
|
||||
stats["skipped"] += 1
|
||||
_update_log(log_id, **stats)
|
||||
continue
|
||||
|
||||
# SMW Properties abrufen (mit Retry)
|
||||
try:
|
||||
smw_props = await _fetch_with_retry(client, page_title)
|
||||
except SmwClientError as e:
|
||||
errors.append({"item": page_title, "error": str(e)})
|
||||
stats["failed"] += 1
|
||||
_update_log(log_id, **stats, error_log=errors)
|
||||
continue
|
||||
|
||||
# Field-Mapping
|
||||
if import_type == "exercise":
|
||||
mapped = map_wiki_to_exercise(page_title, page_id, smw_props)
|
||||
required_ok = bool(mapped.get("goal") or mapped.get("execution"))
|
||||
elif import_type == "skill":
|
||||
mapped = map_wiki_to_skill(page_title, page_id, smw_props)
|
||||
required_ok = True
|
||||
else:
|
||||
mapped = map_wiki_to_method(page_title, page_id, smw_props)
|
||||
required_ok = True
|
||||
|
||||
if not required_ok:
|
||||
errors.append({"item": page_title, "error": "Pflichtfelder fehlen (Ziel + Durchführung)"})
|
||||
stats["failed"] += 1
|
||||
_update_log(log_id, **stats, error_log=errors)
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
stats["imported"] += 1
|
||||
_update_log(log_id, **stats)
|
||||
continue
|
||||
|
||||
# Speichern
|
||||
try:
|
||||
if import_type == "exercise":
|
||||
local_id = _upsert_exercise(mapped, reimport)
|
||||
elif import_type == "skill":
|
||||
local_id = _upsert_skill(mapped, reimport)
|
||||
else:
|
||||
local_id = _upsert_method(mapped, reimport)
|
||||
|
||||
if local_id:
|
||||
_upsert_wiki_ref(page_title, page_id, import_type, local_id)
|
||||
stats["imported"] += 1
|
||||
else:
|
||||
stats["skipped"] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Fehler beim Speichern von '%s'", page_title)
|
||||
errors.append({"item": page_title, "error": str(e)})
|
||||
stats["failed"] += 1
|
||||
|
||||
_update_log(log_id, **stats, error_log=errors)
|
||||
|
||||
# Abschluss
|
||||
_update_log(
|
||||
log_id,
|
||||
import_status="completed",
|
||||
error_log=errors,
|
||||
finished=True,
|
||||
**stats,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Import-Prozess fehlgeschlagen (log_id=%s)", log_id)
|
||||
_update_log(log_id, import_status="failed", error_log=[{"item": "global", "error": str(e)}], finished=True, **stats)
|
||||
|
||||
|
||||
async def _fetch_with_retry(client: SmwClient, page_title: str, retries: int = 3) -> dict:
|
||||
"""Ruft SMW-Properties mit bis zu 3 Versuchen ab."""
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
return await client.browse_subject(page_title)
|
||||
except SmwClientError:
|
||||
if attempt == retries - 1:
|
||||
raise
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# DB-Helper #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _update_log(
|
||||
log_id: int,
|
||||
import_status: Optional[str] = None,
|
||||
items_total: int = 0,
|
||||
total: int = 0,
|
||||
imported: int = 0,
|
||||
skipped: int = 0,
|
||||
failed: int = 0,
|
||||
error_log: Optional[list] = None,
|
||||
finished: bool = False,
|
||||
):
|
||||
import json as _json
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
fields = []
|
||||
params = []
|
||||
|
||||
if import_status:
|
||||
fields.append("import_status = %s")
|
||||
params.append(import_status)
|
||||
|
||||
total_val = items_total or total
|
||||
fields += [
|
||||
"items_total = %s",
|
||||
"items_imported = %s",
|
||||
"items_skipped = %s",
|
||||
"items_failed = %s",
|
||||
]
|
||||
params += [total_val, imported, skipped, failed]
|
||||
|
||||
if error_log is not None:
|
||||
fields.append("error_log = %s")
|
||||
params.append(_json.dumps(error_log))
|
||||
|
||||
if finished:
|
||||
fields.append("finished_at = NOW()")
|
||||
|
||||
fields.append("updated_at = NOW()")
|
||||
params.append(log_id)
|
||||
|
||||
cur.execute(
|
||||
f"UPDATE wiki_import_log SET {', '.join(fields)} WHERE id = %s",
|
||||
params
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _upsert_exercise(mapped: dict, reimport: bool) -> Optional[int]:
|
||||
"""Legt Übung an oder aktualisiert sie (wenn reimport=True)."""
|
||||
import json as _json
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Existiert die Übung bereits (über import_id)?
|
||||
cur.execute(
|
||||
"SELECT id FROM exercises WHERE import_id = %s AND import_source = 'mediawiki'",
|
||||
(mapped["import_id"],)
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
|
||||
equipment = _json.dumps(mapped.get("equipment", [])) if mapped.get("equipment") else None
|
||||
|
||||
if existing and reimport:
|
||||
ex_id = existing[0]
|
||||
cur.execute(
|
||||
"""UPDATE exercises SET
|
||||
title = %s, summary = %s, goal = %s, execution = %s,
|
||||
preparation = %s, trainer_notes = %s,
|
||||
duration_min = %s, duration_max = %s,
|
||||
group_size_min = %s, group_size_max = %s,
|
||||
equipment = %s, updated_at = NOW()
|
||||
WHERE id = %s""",
|
||||
(
|
||||
mapped.get("title"), mapped.get("summary"), mapped.get("goal"),
|
||||
mapped.get("execution"), mapped.get("preparation"), mapped.get("trainer_notes"),
|
||||
mapped.get("duration_min"), mapped.get("duration_max"),
|
||||
mapped.get("group_size_min"), mapped.get("group_size_max"),
|
||||
equipment, ex_id
|
||||
)
|
||||
)
|
||||
elif existing and not reimport:
|
||||
return None # Übersprungen
|
||||
else:
|
||||
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, import_source, import_id)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING id""",
|
||||
(
|
||||
mapped.get("title"), mapped.get("summary"), mapped.get("goal"),
|
||||
mapped.get("execution"), mapped.get("preparation"), mapped.get("trainer_notes"),
|
||||
mapped.get("duration_min"), mapped.get("duration_max"),
|
||||
mapped.get("group_size_min"), mapped.get("group_size_max"),
|
||||
equipment,
|
||||
mapped.get("visibility", "private"),
|
||||
mapped.get("status", "draft"),
|
||||
"mediawiki",
|
||||
mapped.get("import_id"),
|
||||
)
|
||||
)
|
||||
ex_id = cur.fetchone()[0]
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Katalog-Zuordnungen (Focus Areas, Skills, etc.)
|
||||
_assign_exercise_catalogs(cur, conn, ex_id, mapped)
|
||||
|
||||
# Skill-Zuordnungen mit Levels
|
||||
skill_assignments = build_skill_assignments(mapped)
|
||||
_assign_exercise_skills(cur, conn, ex_id, skill_assignments)
|
||||
|
||||
return ex_id
|
||||
|
||||
|
||||
def _assign_exercise_catalogs(cur, conn, exercise_id: int, mapped: dict):
|
||||
"""Weist M:N Katalog-Zuordnungen für eine importierte Übung zu."""
|
||||
|
||||
# Focus Areas
|
||||
for idx, name in enumerate(mapped.get("focus_area_names", [])):
|
||||
cur.execute("SELECT id FROM focus_areas WHERE name ILIKE %s", (name,))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
cur.execute(
|
||||
"""INSERT INTO exercise_focus_areas (exercise_id, focus_area_id, is_primary)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (exercise_id, focus_area_id) DO NOTHING""",
|
||||
(exercise_id, row[0], idx == 0)
|
||||
)
|
||||
else:
|
||||
logger.warning("Focus Area '%s' nicht im Katalog gefunden", name)
|
||||
|
||||
# Style Directions
|
||||
for name in mapped.get("style_names", []):
|
||||
cur.execute("SELECT id FROM style_directions WHERE name ILIKE %s", (name,))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
cur.execute(
|
||||
"""INSERT INTO exercise_training_styles (exercise_id, style_direction_id)
|
||||
VALUES (%s, %s) ON CONFLICT DO NOTHING""",
|
||||
(exercise_id, row[0])
|
||||
)
|
||||
|
||||
# Target Groups
|
||||
for name in mapped.get("target_group_names", []):
|
||||
cur.execute("SELECT id FROM target_groups WHERE name ILIKE %s", (name,))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
cur.execute(
|
||||
"""INSERT INTO exercise_target_groups (exercise_id, target_group_id)
|
||||
VALUES (%s, %s) ON CONFLICT DO NOTHING""",
|
||||
(exercise_id, row[0])
|
||||
)
|
||||
|
||||
# Skills
|
||||
for name in mapped.get("skill_names", []):
|
||||
cur.execute("SELECT id FROM skills WHERE name ILIKE %s", (name,))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
cur.execute(
|
||||
"""INSERT INTO exercise_skills (exercise_id, skill_id)
|
||||
VALUES (%s, %s) ON CONFLICT DO NOTHING""",
|
||||
(exercise_id, row[0])
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _assign_exercise_skills(cur, conn, exercise_id: int, skill_assignments: list):
|
||||
"""Weist Skill-Zuordnungen mit Levels einer Übung zu."""
|
||||
for assignment in skill_assignments:
|
||||
cur.execute("SELECT id FROM skills WHERE name ILIKE %s", (assignment["skill_name"],))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
logger.warning("Skill '%s' nicht im Katalog gefunden", assignment["skill_name"])
|
||||
continue
|
||||
cur.execute(
|
||||
"""INSERT INTO exercise_skills
|
||||
(exercise_id, skill_id, target_level, required_level, intensity, is_primary)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (exercise_id, skill_id) DO UPDATE SET
|
||||
target_level = EXCLUDED.target_level,
|
||||
is_primary = EXCLUDED.is_primary""",
|
||||
(
|
||||
exercise_id, row[0],
|
||||
assignment.get("target_level"),
|
||||
assignment.get("required_level"),
|
||||
assignment.get("intensity"),
|
||||
assignment.get("is_primary", False),
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _upsert_skill(mapped: dict, reimport: bool) -> Optional[int]:
|
||||
"""Legt Skill an oder überspringt bei Duplikat."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Kategorie auflösen (optional)
|
||||
category_id = None
|
||||
if mapped.get("category_name"):
|
||||
cur.execute("SELECT id FROM skill_categories WHERE name ILIKE %s", (mapped["category_name"],))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
category_id = row[0]
|
||||
|
||||
cur.execute(
|
||||
"""INSERT INTO skills (name, description, category_id)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
description = EXCLUDED.description
|
||||
RETURNING id""",
|
||||
(mapped["name"], mapped.get("description"), category_id)
|
||||
)
|
||||
skill_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
return skill_id
|
||||
|
||||
|
||||
def _upsert_method(mapped: dict, reimport: bool) -> Optional[int]:
|
||||
"""Legt Trainingsmethode an oder aktualisiert Beschreibung."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""INSERT INTO training_methods (name, description)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
description = EXCLUDED.description
|
||||
RETURNING id""",
|
||||
(mapped["name"], mapped.get("description"))
|
||||
)
|
||||
method_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
return method_id
|
||||
|
||||
|
||||
def _upsert_wiki_ref(
|
||||
page_title: str, page_id: Optional[int], content_type: str, local_id: int
|
||||
):
|
||||
"""Legt Import-Referenz an oder aktualisiert last_imported."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""INSERT INTO wiki_import_references
|
||||
(wiki_page_title, wiki_page_id, content_type, local_id, last_imported)
|
||||
VALUES (%s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (wiki_page_title, content_type) DO UPDATE SET
|
||||
wiki_page_id = EXCLUDED.wiki_page_id,
|
||||
local_id = EXCLUDED.local_id,
|
||||
last_imported = NOW()""",
|
||||
(page_title, page_id, content_type, local_id)
|
||||
)
|
||||
conn.commit()
|
||||
215
backend/smw_client.py
Normal file
215
backend/smw_client.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"""
|
||||
Semantic MediaWiki API Client
|
||||
|
||||
Greift direkt auf die MediaWiki API zu (kein XML-Export).
|
||||
Unterstützt Login, Kategorien-Abfragen und SMW Browse-API.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
import httpx
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MEDIAWIKI_API_URL = os.getenv("MEDIAWIKI_API_URL", "")
|
||||
MEDIAWIKI_USER = os.getenv("MEDIAWIKI_USER", "")
|
||||
MEDIAWIKI_PASSWORD = os.getenv("MEDIAWIKI_PASSWORD", "")
|
||||
|
||||
|
||||
class SmwClientError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SmwClient:
|
||||
"""Stateless MediaWiki/SMW API Client mit Session-Login."""
|
||||
|
||||
def __init__(self, api_url: str = None, user: str = None, password: str = None):
|
||||
self.api_url = (api_url or MEDIAWIKI_API_URL).rstrip("/")
|
||||
self.user = user or MEDIAWIKI_USER
|
||||
self.password = password or MEDIAWIKI_PASSWORD
|
||||
self._cookies: dict = {}
|
||||
self._logged_in = False
|
||||
|
||||
if not self.api_url:
|
||||
raise SmwClientError("MEDIAWIKI_API_URL nicht konfiguriert")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Authentication #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def login(self) -> None:
|
||||
"""MediaWiki Login (zwei Schritte: Token holen → Login ausführen)."""
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
# Schritt 1: Login-Token holen
|
||||
r1 = await client.get(self.api_url, params={
|
||||
"action": "query",
|
||||
"meta": "tokens",
|
||||
"type": "login",
|
||||
"format": "json",
|
||||
})
|
||||
r1.raise_for_status()
|
||||
token = r1.json()["query"]["tokens"]["logintoken"]
|
||||
cookies = dict(r1.cookies)
|
||||
|
||||
# Schritt 2: Einloggen
|
||||
r2 = await client.post(self.api_url, params={"format": "json"}, data={
|
||||
"action": "login",
|
||||
"lgname": self.user,
|
||||
"lgpassword": self.password,
|
||||
"lgtoken": token,
|
||||
}, cookies=cookies)
|
||||
r2.raise_for_status()
|
||||
result = r2.json()
|
||||
|
||||
if result.get("login", {}).get("result") != "Success":
|
||||
reason = result.get("login", {}).get("reason", "unbekannt")
|
||||
raise SmwClientError(f"MediaWiki Login fehlgeschlagen: {reason}")
|
||||
|
||||
self._cookies = dict(r2.cookies)
|
||||
self._logged_in = True
|
||||
logger.info("SMW Login erfolgreich als '%s'", self.user)
|
||||
|
||||
async def _get(self, params: dict) -> dict:
|
||||
"""Authentifizierter GET-Request gegen die API."""
|
||||
if not self._logged_in:
|
||||
await self.login()
|
||||
params["format"] = "json"
|
||||
async with httpx.AsyncClient(timeout=30, cookies=self._cookies) as client:
|
||||
r = await client.get(self.api_url, params=params)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Kategorien #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def get_category_members(self, category: str, limit: int = 500) -> list[dict]:
|
||||
"""
|
||||
Gibt alle Seiten einer Kategorie zurück.
|
||||
Gibt Liste von {"pageid": int, "title": str} zurück.
|
||||
"""
|
||||
members = []
|
||||
cmcontinue = None
|
||||
|
||||
while True:
|
||||
params = {
|
||||
"action": "query",
|
||||
"list": "categorymembers",
|
||||
"cmtitle": f"Kategorie:{category}",
|
||||
"cmlimit": min(limit, 500),
|
||||
"cmtype": "page",
|
||||
"cmprop": "ids|title",
|
||||
}
|
||||
if cmcontinue:
|
||||
params["cmcontinue"] = cmcontinue
|
||||
|
||||
data = await self._get(params)
|
||||
members.extend(data["query"]["categorymembers"])
|
||||
|
||||
if "continue" in data and len(members) < limit:
|
||||
cmcontinue = data["continue"].get("cmcontinue")
|
||||
else:
|
||||
break
|
||||
|
||||
return members[:limit]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Seiteninhalte #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def get_page_wikitext(self, title: str) -> str:
|
||||
"""Rohen Wikitext einer Seite abrufen."""
|
||||
data = await self._get({
|
||||
"action": "query",
|
||||
"titles": title,
|
||||
"prop": "revisions",
|
||||
"rvprop": "content",
|
||||
"rvslots": "main",
|
||||
})
|
||||
pages = data["query"]["pages"]
|
||||
page = next(iter(pages.values()))
|
||||
if "missing" in page:
|
||||
raise SmwClientError(f"Seite '{title}' nicht gefunden")
|
||||
return page["revisions"][0]["slots"]["main"]["*"]
|
||||
|
||||
async def get_page_html(self, title: str) -> str:
|
||||
"""Geparsten HTML-Inhalt einer Seite abrufen."""
|
||||
data = await self._get({
|
||||
"action": "parse",
|
||||
"page": title,
|
||||
"prop": "text",
|
||||
})
|
||||
return data["parse"]["text"]["*"]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Semantic MediaWiki #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def browse_subject(self, title: str) -> dict:
|
||||
"""
|
||||
SMW Browse-API: Gibt alle Properties (Attribute) einer Seite zurück.
|
||||
Gibt dict {property_name: [value, ...]} zurück.
|
||||
"""
|
||||
data = await self._get({
|
||||
"action": "browsebysubject",
|
||||
"subject": title,
|
||||
})
|
||||
if "error" in data:
|
||||
raise SmwClientError(f"SMW Browse-Fehler für '{title}': {data['error']}")
|
||||
|
||||
# Normalisiere: {property_label: [wert1, wert2]}
|
||||
result = {}
|
||||
for prop_data in data.get("query", {}).get("data", []):
|
||||
prop_label = prop_data.get("property", "")
|
||||
if prop_label.startswith("_"): # Interne SMW-Properties überspringen
|
||||
continue
|
||||
values = []
|
||||
for item in prop_data.get("dataitem", []):
|
||||
raw = item.get("item", "")
|
||||
# SMW codiert Werte manchmal als "Wert#0##" → bereinigen
|
||||
clean = raw.split("#")[0].strip() if "#" in raw else raw.strip()
|
||||
if clean:
|
||||
values.append(clean)
|
||||
if values:
|
||||
result[prop_label] = values
|
||||
return result
|
||||
|
||||
async def ask_query(self, query: str, limit: int = 100) -> list[dict]:
|
||||
"""
|
||||
SMW Ask-API: Semantische Abfrage.
|
||||
Beispiel: query = "[[Kategorie:Übungen]]|?Fokusbereich|?Ziel"
|
||||
Gibt Liste von {title, properties} zurück.
|
||||
"""
|
||||
data = await self._get({
|
||||
"action": "ask",
|
||||
"query": f"{query}|limit={limit}",
|
||||
})
|
||||
if "error" in data:
|
||||
raise SmwClientError(f"SMW Ask-Fehler: {data['error']}")
|
||||
|
||||
results = []
|
||||
for title, props in data.get("query", {}).get("results", {}).items():
|
||||
entry = {"title": title, "properties": {}}
|
||||
for prop_name, prop_data in props.get("printouts", {}).items():
|
||||
values = []
|
||||
for item in prop_data:
|
||||
if isinstance(item, dict):
|
||||
values.append(item.get("fulltext") or item.get("raw") or str(item))
|
||||
else:
|
||||
values.append(str(item))
|
||||
entry["properties"][prop_name] = values
|
||||
results.append(entry)
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Schema-Discovery #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def discover_properties(self, sample_title: str) -> dict:
|
||||
"""
|
||||
Gibt alle SMW-Properties einer Beispielseite zurück.
|
||||
Nützlich um die Property-Namen zu ermitteln bevor der Mapper gebaut wird.
|
||||
"""
|
||||
props = await self.browse_subject(sample_title)
|
||||
logger.info("Properties von '%s': %s", sample_title, list(props.keys()))
|
||||
return props
|
||||
365
backend/smw_mapper.py
Normal file
365
backend/smw_mapper.py
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
"""
|
||||
Semantic MediaWiki → Shinkan Field Mapper
|
||||
|
||||
Wandelt SMW-Properties von karatetrainer.net in lokale DB-Felder um.
|
||||
Property-Namen wurden via discover_properties() auf echten Wiki-Seiten ermittelt.
|
||||
|
||||
Entdeckte Kategorien:
|
||||
Übungen: Kategorie: Übungen (auch "Übungen Karate", "Übungen allgemein")
|
||||
Fähigkeiten: Fähigkeitsbeschreibung
|
||||
Methoden: Methodenbeschreibung
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# CapabilityLevel Integer → benannte Stufen #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
# Mapping: SMW-Integer → Shinkan-Stufenname
|
||||
CAPABILITY_LEVEL_MAP = {
|
||||
"1": "einsteiger",
|
||||
"2": "grundlagen",
|
||||
"3": "aufbau",
|
||||
"4": "fortgeschritten",
|
||||
"5": "experte",
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# SMW Property → lokales Feld #
|
||||
# Echte Namen von karatetrainer.net (via discover_properties) #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
# Übungen (exercises)
|
||||
EXERCISE_PROPERTY_MAP = {
|
||||
# Kern-Felder
|
||||
"Übungsbezeichnung": "title_override", # Übungsname (bevorzugt ggü. Seitentitel)
|
||||
"Ziel": "goal",
|
||||
"Durchführung": "execution",
|
||||
"Summary": "summary",
|
||||
"Hinweise": "trainer_notes",
|
||||
"Plandauer": "duration_raw", # Zahl in Minuten z.B. "10"
|
||||
"Gruppengröße": "group_size_raw", # Zahl z.B. "2"
|
||||
"Hilfsmittel": "equipment_raw", # Komma-Liste / einzelner Wert
|
||||
"Schlüsselworte": "keywords_raw", # Keywords (nicht direkt in DB, für spätere Tags)
|
||||
# Katalog-Felder (Name → ID Lookup)
|
||||
"Übungstyp": "focus_area_names", # "Karate" → focus_area
|
||||
"Zielgruppe": "target_group_names",
|
||||
"Altersgruppe": "age_group_names",
|
||||
"Trainingsmethode": "method_names", # Wiki-Seitenname z.B. "Plyometrisches_Training"
|
||||
# Fähigkeiten (als Namen + Level)
|
||||
"PrimaryCapability": "skill_names", # Skill-Namen (können mehrere sein)
|
||||
"CapabilityLevel": "skill_levels_raw", # Integer-Levels ["3", "2"] → aufbau, grundlagen
|
||||
# Weitere Felder (optional)
|
||||
"Graduierung": "graduierung", # "0 - Anfänger" (zukünftige Nutzung)
|
||||
"Lernstufe": "lernstufe", # "Lernstufe_1_-_Erlernen_und_Festigen"
|
||||
}
|
||||
|
||||
# Fähigkeiten (skills) – Kategorie: Fähigkeitsbeschreibung
|
||||
SKILL_PROPERTY_MAP = {
|
||||
"Summary": "description",
|
||||
"KarateRelevanz": "karate_relevance", # Wird in description ergänzt
|
||||
"RelevanzLevel": "relevance_level", # 1-3, nicht direkt in skills DB
|
||||
}
|
||||
|
||||
# Trainingsmethoden – Kategorie: Methodenbeschreibung
|
||||
METHOD_PROPERTY_MAP = {
|
||||
"Summary": "description",
|
||||
"Kurzbezeichnung": "code", # Abkürzung z.B. "DM"
|
||||
"KarateRelevanz": "karate_relevance",
|
||||
"PrimaryCapability": "skill_names", # Verknüpfte Fähigkeiten
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Wikitext → Plaintext #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def wikitext_to_plaintext(wikitext: str) -> str:
|
||||
"""Entfernt Wikitext-Formatierungen und gibt lesbaren Plaintext zurück."""
|
||||
text = wikitext
|
||||
|
||||
# Externe Links: [https://example.com Text] → Text
|
||||
text = re.sub(r'\[https?://\S+\s+([^\]]+)\]', r'\1', text)
|
||||
|
||||
# Interne Links mit Alias: [[Link|Text]] → Text
|
||||
text = re.sub(r'\[\[([^|\]]+)\|([^\]]+)\]\]', r'\2', text)
|
||||
|
||||
# Interne Links ohne Alias: [[Link]] → Link (Unterstriche → Leerzeichen)
|
||||
text = re.sub(r'\[\[([^\]]+)\]\]', lambda m: m.group(1).replace('_', ' '), text)
|
||||
|
||||
# Templates entfernen (einzeilig)
|
||||
text = re.sub(r'\{\{[^}]+\}\}', '', text)
|
||||
|
||||
# Fettdruck und Kursiv
|
||||
text = re.sub(r"'''(.+?)'''", r'\1', text)
|
||||
text = re.sub(r"''(.+?)''", r'\1', text)
|
||||
|
||||
# HTML-Tags entfernen (inkl. <br>)
|
||||
text = re.sub(r'<br\s*/?>', '\n', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
|
||||
# Überschriften
|
||||
text = re.sub(r'={2,6}\s*(.+?)\s*={2,6}', r'\n\1\n', text)
|
||||
|
||||
# Aufzählungszeichen normalisieren
|
||||
text = re.sub(r'^[*#:;]+\s*', '- ', text, flags=re.MULTILINE)
|
||||
|
||||
# Mehrfache Leerzeilen normalisieren
|
||||
text = re.sub(r'\n{3,}', '\n\n', text)
|
||||
|
||||
return text.strip()
|
||||
|
||||
|
||||
def wiki_name_to_label(wiki_name: str) -> str:
|
||||
"""Wandelt Wiki-Seitennamen in lesbare Labels um: Plyometrisches_Training → Plyometrisches Training"""
|
||||
return wiki_name.replace('_', ' ').strip()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Parsing-Hilfsfunktionen #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def parse_duration(raw: str) -> tuple[Optional[int], Optional[int]]:
|
||||
"""
|
||||
"10" → (10, 10) (Plandauer ist immer eine einzelne Zahl in Minuten)
|
||||
"10-15" → (10, 15)
|
||||
"""
|
||||
if not raw:
|
||||
return None, None
|
||||
numbers = re.findall(r'\d+', raw)
|
||||
if not numbers:
|
||||
return None, None
|
||||
if len(numbers) == 1:
|
||||
val = int(numbers[0])
|
||||
return val, val
|
||||
return int(numbers[0]), int(numbers[1])
|
||||
|
||||
|
||||
def parse_group_size(raw: str) -> tuple[Optional[int], Optional[int]]:
|
||||
"""
|
||||
"2" → (2, 2) (Gruppengröße ist immer eine einzelne Zahl)
|
||||
"""
|
||||
if not raw:
|
||||
return None, None
|
||||
numbers = re.findall(r'\d+', raw)
|
||||
if not numbers:
|
||||
return None, None
|
||||
val = int(numbers[0])
|
||||
if len(numbers) == 1:
|
||||
return val, None # Minimum, kein Maximum angegeben
|
||||
return int(numbers[0]), int(numbers[1])
|
||||
|
||||
|
||||
def parse_equipment(raw: list[str]) -> list[str]:
|
||||
"""Normalisiert Equipment-Liste: ["Ausdruck"] oder ["Gewicht"] → bereinigt"""
|
||||
result = []
|
||||
for item in raw:
|
||||
for part in re.split(r'[,;/]', item):
|
||||
cleaned = wiki_name_to_label(part.strip())
|
||||
if cleaned:
|
||||
result.append(cleaned)
|
||||
return result
|
||||
|
||||
|
||||
def map_capability_level(level_str: str) -> str:
|
||||
"""Wandelt Integer-Level in benannte Stufe: "3" → "aufbau" """
|
||||
return CAPABILITY_LEVEL_MAP.get(level_str.strip(), "einsteiger")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Haupt-Mapping-Funktion #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def map_wiki_to_exercise(
|
||||
page_title: str,
|
||||
wiki_page_id: Optional[int],
|
||||
smw_props: dict,
|
||||
) -> dict:
|
||||
"""
|
||||
Wandelt SMW-Properties einer Wiki-Seite in ein Exercise-Dict um.
|
||||
|
||||
Args:
|
||||
page_title: Titel der Wiki-Seite (Fallback für title)
|
||||
wiki_page_id: Interne MediaWiki-Seiten-ID
|
||||
smw_props: {property_name: [value, ...]} aus SmwClient.browse_subject()
|
||||
|
||||
Returns:
|
||||
Dict mit gemappten Feldern + Katalog-Listen für ID-Lookup.
|
||||
"""
|
||||
mapped: dict = {
|
||||
"title": page_title,
|
||||
"wiki_page_id": wiki_page_id,
|
||||
# Tracking
|
||||
"import_source": "mediawiki",
|
||||
"import_id": page_title,
|
||||
# Defaults
|
||||
"visibility": "private",
|
||||
"status": "draft",
|
||||
# Katalog-Referenzen (Name → ID-Lookup erfolgt im Router)
|
||||
"focus_area_names": [],
|
||||
"target_group_names": [],
|
||||
"age_group_names": [],
|
||||
"skill_names": [],
|
||||
"skill_levels_raw": [], # Integer-Strings ["3", "2"]
|
||||
"method_names": [],
|
||||
# Equipment
|
||||
"equipment": [],
|
||||
# Warnungen für unbekannte Katalog-Werte
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
for prop_name, values in smw_props.items():
|
||||
if not values:
|
||||
continue
|
||||
|
||||
target = EXERCISE_PROPERTY_MAP.get(prop_name)
|
||||
if not target:
|
||||
continue
|
||||
|
||||
# Ersten Wert oder ganzes Array
|
||||
first_value = values[0] if isinstance(values, list) else values
|
||||
|
||||
if target == "title_override":
|
||||
mapped["title"] = wiki_name_to_label(first_value)
|
||||
|
||||
elif target in ("goal", "execution", "summary", "trainer_notes"):
|
||||
mapped[target] = wikitext_to_plaintext(first_value)
|
||||
|
||||
elif target == "duration_raw":
|
||||
dur_min, dur_max = parse_duration(first_value)
|
||||
mapped["duration_min"] = dur_min
|
||||
mapped["duration_max"] = dur_max
|
||||
|
||||
elif target == "group_size_raw":
|
||||
gs_min, gs_max = parse_group_size(first_value)
|
||||
mapped["group_size_min"] = gs_min
|
||||
mapped["group_size_max"] = gs_max
|
||||
|
||||
elif target == "equipment_raw":
|
||||
mapped["equipment"] = parse_equipment(values if isinstance(values, list) else [values])
|
||||
|
||||
elif target == "keywords_raw":
|
||||
# Keywords für spätere Tag-Implementierung speichern
|
||||
mapped["keywords"] = [wiki_name_to_label(v) for v in (values if isinstance(values, list) else [values])]
|
||||
|
||||
elif target == "focus_area_names":
|
||||
mapped["focus_area_names"] = [wiki_name_to_label(v) for v in (values if isinstance(values, list) else [values])]
|
||||
|
||||
elif target == "target_group_names":
|
||||
mapped["target_group_names"] = [wiki_name_to_label(v) for v in (values if isinstance(values, list) else [values])]
|
||||
|
||||
elif target == "age_group_names":
|
||||
mapped["age_group_names"] = [wiki_name_to_label(v) for v in (values if isinstance(values, list) else [values])]
|
||||
|
||||
elif target == "method_names":
|
||||
mapped["method_names"] = [wiki_name_to_label(v) for v in (values if isinstance(values, list) else [values])]
|
||||
|
||||
elif target == "skill_names":
|
||||
mapped["skill_names"] = [wiki_name_to_label(v) for v in (values if isinstance(values, list) else [values])]
|
||||
|
||||
elif target == "skill_levels_raw":
|
||||
mapped["skill_levels_raw"] = list(values) if isinstance(values, list) else [values]
|
||||
|
||||
return mapped
|
||||
|
||||
|
||||
def build_skill_assignments(mapped: dict) -> list[dict]:
|
||||
"""
|
||||
Erstellt Skill-Zuordnungen aus PrimaryCapability + CapabilityLevel.
|
||||
|
||||
CapabilityLevel [3, 2] korrespondiert mit PrimaryCapability [Schnellkraft, Schnelligkeitsausdauer]
|
||||
→ ergibt: [{skill: Schnellkraft, target_level: aufbau}, {skill: Schnelligkeitsausdauer, target_level: grundlagen}]
|
||||
"""
|
||||
skills = mapped.get("skill_names", [])
|
||||
levels = mapped.get("skill_levels_raw", [])
|
||||
|
||||
assignments = []
|
||||
for idx, skill_name in enumerate(skills):
|
||||
level_str = levels[idx] if idx < len(levels) else "1"
|
||||
assignments.append({
|
||||
"skill_name": skill_name,
|
||||
"target_level": map_capability_level(level_str),
|
||||
"required_level": None, # Nicht im Wiki spezifiziert
|
||||
"intensity": None, # Nicht im Wiki spezifiziert
|
||||
"is_primary": idx == 0,
|
||||
})
|
||||
return assignments
|
||||
|
||||
|
||||
def map_wiki_to_skill(
|
||||
page_title: str,
|
||||
wiki_page_id: Optional[int],
|
||||
smw_props: dict,
|
||||
) -> dict:
|
||||
"""Wandelt SMW-Properties einer Fähigkeitsbeschreibung-Seite in ein Skill-Dict um."""
|
||||
mapped = {
|
||||
"name": page_title,
|
||||
"wiki_page_id": wiki_page_id,
|
||||
"import_source": "mediawiki",
|
||||
"import_id": page_title,
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
description_parts = []
|
||||
|
||||
for prop_name, values in smw_props.items():
|
||||
if not values:
|
||||
continue
|
||||
target = SKILL_PROPERTY_MAP.get(prop_name)
|
||||
if not target:
|
||||
continue
|
||||
first_value = values[0] if isinstance(values, list) else values
|
||||
|
||||
if target == "description":
|
||||
description_parts.insert(0, wikitext_to_plaintext(first_value))
|
||||
elif target == "karate_relevance":
|
||||
rel = wikitext_to_plaintext(first_value)
|
||||
description_parts.append(f"\nKarate-Relevanz: {rel}")
|
||||
|
||||
if description_parts:
|
||||
mapped["description"] = "\n".join(description_parts).strip()
|
||||
|
||||
return mapped
|
||||
|
||||
|
||||
def map_wiki_to_method(
|
||||
page_title: str,
|
||||
wiki_page_id: Optional[int],
|
||||
smw_props: dict,
|
||||
) -> dict:
|
||||
"""Wandelt SMW-Properties einer Methodenbeschreibung-Seite in ein Method-Dict um."""
|
||||
mapped = {
|
||||
"name": page_title,
|
||||
"wiki_page_id": wiki_page_id,
|
||||
"import_source": "mediawiki",
|
||||
"import_id": page_title,
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
description_parts = []
|
||||
|
||||
for prop_name, values in smw_props.items():
|
||||
if not values:
|
||||
continue
|
||||
target = METHOD_PROPERTY_MAP.get(prop_name)
|
||||
if not target:
|
||||
continue
|
||||
first_value = values[0] if isinstance(values, list) else values
|
||||
|
||||
if target == "description":
|
||||
description_parts.insert(0, wikitext_to_plaintext(first_value))
|
||||
elif target == "code":
|
||||
mapped["code"] = first_value.strip()
|
||||
elif target == "karate_relevance":
|
||||
rel = wikitext_to_plaintext(first_value)
|
||||
description_parts.append(f"\nKarate-Relevanz: {rel}")
|
||||
|
||||
if description_parts:
|
||||
mapped["description"] = "\n".join(description_parts).strip()
|
||||
|
||||
return mapped
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.5.0"
|
||||
BUILD_DATE = "2026-04-23"
|
||||
DB_SCHEMA_VERSION = "20260423002"
|
||||
APP_VERSION = "0.6.0"
|
||||
BUILD_DATE = "2026-04-24"
|
||||
DB_SCHEMA_VERSION = "20260424001"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"auth": "1.0.0",
|
||||
|
|
@ -15,13 +15,25 @@ MODULE_VERSIONS = {
|
|||
"training_units": "0.1.0",
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.1.0",
|
||||
"import_wiki": "0.1.0",
|
||||
"import_wiki": "1.0.0",
|
||||
"admin": "1.0.0",
|
||||
"membership": "1.0.0",
|
||||
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
|
||||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.6.0",
|
||||
"date": "2026-04-24",
|
||||
"changes": [
|
||||
"Feature: MediaWiki Import (SMW Direct API)",
|
||||
"DB: Migration 018 - wiki_import_log + wiki_import_references",
|
||||
"Backend: SmwClient (login, browse, ask, categorymembers)",
|
||||
"Backend: SmwMapper (SMW Properties → exercises/skills/methods)",
|
||||
"Backend: import_wiki Router (preview, execute, status, logs, discover)",
|
||||
"Config: MEDIAWIKI_API_URL=https://karatetrainer.net/api.php",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.5.0",
|
||||
"date": "2026-04-23",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user