shinkan-jinkendo/.claude/docs/technical/EXERCISES_API_SPEC.md
Lars e4451e1362
Some checks failed
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 1s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Has been cancelled
Enhance Exercise Management and AI Integration
- Updated the exercise form to include a tabbed navigation structure, improving user experience with sections for Stammdaten, Anleitung, Einordnung, Varianten, and Medien & Mehr.
- Introduced the concept of **Freigabelevel** (visibility level) in the UI, replacing previous terminology for clarity and consistency across components.
- Implemented new AI endpoints for exercise suggestions and regeneration, allowing for dynamic content generation without direct database writes.
- Removed the legacy `is_primary` flag from exercise skills in the UI, ensuring that intensity levels (`niedrig`, `mittel`, `hoch`) are the primary focus for skill management.
- Enhanced the variant management process with improved saving mechanisms and UI updates to reflect changes more intuitively.
2026-05-22 07:52:31 +02:00

989 lines
25 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Exercises API Specification
**Version:** 1.6
**Datum:** 2026-05-20
**Status:** Teilweise implementiert (Liste mit Filtern + Varianten + Medienlimits + Progressionsgraphen siehe Code)
**Autor:** Claude Code
**Änderungen v1.6:** Freigabelevel-UI-Hinweis; `exercise_skills` ohne `is_primary` in Requests (Legacy-Feld wird ignoriert/forciert false); Permissions-Bereich an Ist-Code angeglichen; Intensität kanonisch `niedrig|mittel|hoch`
**Änderungen v1.5:** Medien-/Inline-Workflow aktualisiert (Modal-Picker, Drag&Drop UX im Frontend), Klarstellung zu `context` (legacy/optional), Hinweise zu Platzhaltern in Rich-Text-Feldern.
**Änderungen v1.4:** Endpoints **`/exercise-progression-graphs`** inkl. Kanten, **`POST …/edges/sequence`**, **`POST …/edges/delete-batch`** — Detailtabellen siehe **`TRAINING_FRAMEWORK_SPEC.md`** §3.3
**Änderungen v1.3:** `GET /exercises` erweiterte Query-Parameter (`include_variants`, Multi-Filter, `ai_search`-Platzhalter); Dokumentation angepasst
**Ä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) |
| **Progressionsgraphen** (Übung→Übung) |
| GET | `/exercise-progression-graphs` | Liste Graphen |
| GET | `/exercise-progression-graphs/{id}` | Detail; Query `include_edges` |
| POST | `/exercise-progression-graphs` | Graph anlegen |
| PUT | `/exercise-progression-graphs/{id}` | Metadaten |
| DELETE | `/exercise-progression-graphs/{id}` | Graph + Kanten |
| GET | `/exercise-progression-graphs/{id}/edges` | Kantenliste |
| POST | `/exercise-progression-graphs/{id}/edges` | Einzelkante |
| POST | `/exercise-progression-graphs/{id}/edges/sequence` | Reihe (`steps`) in einer Transaktion |
| PUT | `/exercise-progression-graphs/{id}/edges/{edge_id}` | z.B. Notiz |
| DELETE | `/exercise-progression-graphs/{id}/edges/{edge_id}` | Kante löschen |
| POST | `/exercise-progression-graphs/{id}/edges/delete-batch` | `{ edge_ids }` |
Vollständige Pfadtabelle, Auth und Feldgrenzen: **`TRAINING_FRAMEWORK_SPEC.md`** §3.
---
## Exercises
### `GET /exercises`
**Query Parameters (Auswahl):**
| Parameter | Beschreibung |
|-----------|----------------|
| `focus_area_ids[]`, `focus_area` | Fokusbereiche (ODER über Liste oder Legacy Einzel-ID) |
| `visibility_any[]`, `visibility` | Sichtbarkeit(en) |
| `status_any[]`, `status` | Status |
| `skill_ids[]`, `skill_id` | Fähigkeit(en) |
| `skill_min_level`, `skill_max_level` | Stufe 15 auf Übung↔Skill-Zuordnung |
| `style_direction_ids[]`, `style_direction_id` | Stilrichtung(en) |
| `training_type_ids[]`, `training_type_id` | Trainingsstil(e) |
| `target_group_ids[]`, `target_group_id` | Zielgruppe(n) |
| `search`, `ai_search` | Volltext (aktuell gleiche Logik; `ai_search` Platzhalter für spätere KI-Suche) |
| `include_variants` | `true`: jedes Listenelement enthält optional kompaktes **`variants`** für UI/Planung |
| `limit` | Default 50, max 100 |
| `offset` | Default 0 |
**Response:** `200 OK`
Lightweight-Liste; bei `include_variants=true` zusätzlich z.B.:
```json
{
"id": 1,
"title": "Maai - Distanzübung",
"variants": [
{ "id": 10, "variant_name": "Basis", "sequence_order": 1 }
]
}
```
**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",
"intensity": "hoch",
"required_level": "grundlagen",
"target_level": "aufbau",
"ai_suggested": false,
"is_primary": 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,
"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)
**Inline-Hinweis:** Rich-Text-Felder (`summary`, `goal`, `execution`, `preparation`, `trainer_notes` sowie `execution_changes` bei Varianten) unterstützen Platzhalter `{{exerciseMedia:id}}`, die serverseitig auf kanonisches Span-Markup normalisiert werden. Beim erstmaligen Anlegen ohne vorhandene `exercise_media` wird dies mit 400 abgelehnt.
**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, legacy UI-Feld) - `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` bleibt backendseitig kompatibel, wird in aktuellen Bearbeitungsflüssen jedoch nicht mehr aktiv zur UI-Zuordnung verwendet (Inline-Platzhalter priorisiert).
**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",
"confidence": 0.92
},
{
"skill_id": 15,
"skill_name": "Reaktionsschnelligkeit",
"skill_category": "Athletik",
"required_level": "einsteiger",
"target_level": "grundlagen",
"intensity": "mittel",
"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
**UI-Hinweis:** Das Feld `visibility` heißt in der Oberfläche **Freigabelevel** (`exerciseGovernanceLabels.js`).
### Lesen (`GET /exercises`, `GET /exercises/{id}`)
| `visibility` | Wer darf lesen? |
|--------------|-----------------|
| `official` | Plattform-weit |
| `private` | Ersteller (`created_by`); Plattform-Admin |
| `club` | Aktive Mitglieder des Objekt-`club_id`; Plattform-Admin ohne Mitgliedschaft (Audit-Zugang) |
Implementierung: `library_content_visible_to_profile` / `exercise_visible_to_profile` in `club_tenancy.py`.
### Bearbeiten (`PUT`, Varianten-CRUD, Medien an Übung)
| Bedingung | Wer darf bearbeiten? |
|-----------|----------------------|
| Ersteller | Immer (eigene Übung) |
| Plattform-Admin | Immer |
| `visibility=club` | Zusätzlich **`can_plan_in_club`** im Objekt-Verein: `club_admin`, `trainer`, `content_editor`, `division_lead` |
Implementierung: `_assert_can_edit_exercise` in `exercises.py`. **Varianten** haben kein eigenes Owner-Feld gleiche Prüfung wie Eltern-Übung.
### Löschen (`DELETE /exercises/{id}`)
| `visibility` | Wer darf löschen? |
|--------------|-------------------|
| `official` | Nur Plattform-Admin |
| `club` | Nur **`club_admin`** im Objekt-Verein |
| `private` | Ersteller; oder Vereins-Admin, der mit dem Ersteller einen gemeinsamen Verein teilt |
Implementierung: `_assert_can_delete_exercise` in `exercises.py`.
### 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 (veraltet — siehe Tabellen oben)
Die folgenden Kurzregeln sind durch die Ist-Implementierung ersetzt; nur zur historischen Einordnung:
- ~~Bearbeiten (PUT): Nur Ersteller oder Club-Admin~~ siehe **Bearbeiten**-Tabelle (`can_plan_in_club`)
- ~~Löschen (DELETE): Nur Ersteller oder Super-Admin~~ siehe **Löschen**-Tabelle
**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`
- Inline-Rich-Text: `{{exerciseMedia:id}}` bzw. `data-shinkan-exercise-media="<id>"`; optional `data-shinkan-exercise-media-size="small|medium|full"` für Layout.
### 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; Default beim Speichern **`mittel`**)
- `is_primary`: **Legacy** Spalte existiert in DB, wird bei POST/PUT **nicht ausgewertet** (immer `false` gespeichert); UI liefert/speichert kein Primär-Flag mehr; Scoring ignoriert das Feld
- `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.5
**Letzte Änderung:** 2026-05-08
**Status:** Living Spec