# 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 1–5 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
