diff --git a/backend/migrations/029_skill_level_standard_names.sql b/backend/migrations/029_skill_level_standard_names.sql
new file mode 100644
index 0000000..7287dba
--- /dev/null
+++ b/backend/migrations/029_skill_level_standard_names.sql
@@ -0,0 +1,27 @@
+-- Migration 029: Einheitliche Fähigkeitsstufen 1–5 (Basis … Optimierung)
+-- Datum: 2026-04-27
+
+-- exercise_skills: Legacy- und Zahl-Strings → kanonische Slugs
+UPDATE exercise_skills SET required_level = 'basis' WHERE required_level IN ('einsteiger', '1');
+UPDATE exercise_skills SET required_level = 'optimierung' WHERE required_level IN ('experte', '5');
+UPDATE exercise_skills SET required_level = 'grundlagen' WHERE required_level = '2';
+UPDATE exercise_skills SET required_level = 'aufbau' WHERE required_level = '3';
+UPDATE exercise_skills SET required_level = 'fortgeschritten' WHERE required_level = '4';
+
+UPDATE exercise_skills SET target_level = 'basis' WHERE target_level IN ('einsteiger', '1');
+UPDATE exercise_skills SET target_level = 'optimierung' WHERE target_level IN ('experte', '5');
+UPDATE exercise_skills SET target_level = 'grundlagen' WHERE target_level = '2';
+UPDATE exercise_skills SET target_level = 'aufbau' WHERE target_level = '3';
+UPDATE exercise_skills SET target_level = 'fortgeschritten' WHERE target_level = '4';
+
+-- Reifegradmodell-Stufen (einheitliche Bezeichnungen für Stufe 1–5)
+UPDATE model_levels SET
+ name = CASE level_number
+ WHEN 1 THEN 'Basis'
+ WHEN 2 THEN 'Grundlagen'
+ WHEN 3 THEN 'Aufbau'
+ WHEN 4 THEN 'Fortgeschritten'
+ WHEN 5 THEN 'Optimierung'
+ ELSE name
+ END
+WHERE level_number BETWEEN 1 AND 5;
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index c7c4f6c..78c3f9a 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -21,6 +21,53 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["exercises"])
+# Kanonische Fähigkeitsstufen 1–5 (Übung ↔ Skill-Zeile), siehe Migration 029
+_CANONICAL_SKILL_LEVELS = frozenset(
+ {"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"}
+)
+_LEGACY_SKILL_LEVEL_SLUG = {
+ "einsteiger": "basis",
+ "experte": "optimierung",
+ "1": "basis",
+ "2": "grundlagen",
+ "3": "aufbau",
+ "4": "fortgeschritten",
+ "5": "optimierung",
+}
+
+# SQL: numerischer Rang aus target_level (fallback required_level) für Filter
+_EXERCISE_SKILL_LEVEL_RANK_SQL = """
+CASE COALESCE(
+ NULLIF(TRIM(LOWER(es.target_level::text)), ''),
+ NULLIF(TRIM(LOWER(es.required_level::text)), '')
+)
+ WHEN 'basis' THEN 1
+ WHEN 'grundlagen' THEN 2
+ WHEN 'aufbau' THEN 3
+ WHEN 'fortgeschritten' THEN 4
+ WHEN 'optimierung' THEN 5
+ WHEN 'einsteiger' THEN 1
+ WHEN 'experte' THEN 5
+ WHEN '1' THEN 1
+ WHEN '2' THEN 2
+ WHEN '3' THEN 3
+ WHEN '4' THEN 4
+ WHEN '5' THEN 5
+ ELSE NULL END
+""".strip()
+
+
+def normalize_exercise_skill_level(value) -> Optional[str]:
+ """Wandelt Legacy-/Zahlencodes in kanonische Slugs; ungültig → None."""
+ if value is None:
+ return None
+ s = str(value).strip().lower()
+ if not s:
+ return None
+ if s in _CANONICAL_SKILL_LEVELS:
+ return s
+ return _LEGACY_SKILL_LEVEL_SLUG.get(s)
+
MEDIA_ROOT = Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent.parent / "media")))
MAX_EXERCISE_MEDIA = 10
MAX_UPLOAD_BYTES = 50 * 1024 * 1024
@@ -272,6 +319,9 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
(exercise_id,)
)
exercise["skills"] = [r2d(r) for r in cur.fetchall()]
+ for sk in exercise["skills"]:
+ sk["required_level"] = normalize_exercise_skill_level(sk.get("required_level"))
+ sk["target_level"] = normalize_exercise_skill_level(sk.get("target_level"))
# Variants (1:N) - mit Progression
cur.execute(
@@ -369,8 +419,8 @@ def assign_exercise_relations(cur, conn, exercise_id: int, data: dict):
skill["skill_id"],
skill.get("is_primary", False),
skill.get("intensity"),
- skill.get("required_level"),
- skill.get("target_level"),
+ normalize_exercise_skill_level(skill.get("required_level")),
+ normalize_exercise_skill_level(skill.get("target_level")),
skill.get("ai_suggested", False),
)
)
@@ -388,7 +438,16 @@ def list_exercises(
visibility: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None),
skill_id: Optional[int] = Query(default=None),
+ style_direction_id: Optional[int] = Query(default=None),
+ training_type_id: Optional[int] = Query(default=None),
+ target_group_id: Optional[int] = Query(default=None),
+ skill_min_level: Optional[int] = Query(default=None, ge=1, le=5),
+ skill_max_level: Optional[int] = Query(default=None, ge=1, le=5),
search: Optional[str] = Query(default=None),
+ ai_search: Optional[str] = Query(
+ default=None,
+ description="Platzhalter KI-Suche: derzeit gleiche Volltextlogik wie search (später Embeddings/Reranking)",
+ ),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
session: dict = Depends(require_auth),
@@ -428,10 +487,52 @@ def list_exercises(
where.append("EXISTS (SELECT 1 FROM exercise_skills es WHERE es.exercise_id = e.id AND es.skill_id = %s)")
params.append(skill_id)
- # Volltext-Suche (tsvector)
- if search:
+ if style_direction_id:
+ where.append(
+ "EXISTS (SELECT 1 FROM exercise_style_directions esd "
+ "WHERE esd.exercise_id = e.id AND esd.style_direction_id = %s)"
+ )
+ params.append(style_direction_id)
+
+ if training_type_id:
+ where.append(
+ "EXISTS (SELECT 1 FROM exercise_training_types ett "
+ "WHERE ett.exercise_id = e.id AND ett.training_type_id = %s)"
+ )
+ params.append(training_type_id)
+
+ if target_group_id:
+ where.append(
+ "EXISTS (SELECT 1 FROM exercise_target_groups etg "
+ "WHERE etg.exercise_id = e.id AND etg.target_group_id = %s)"
+ )
+ params.append(target_group_id)
+
+ if skill_min_level is not None or skill_max_level is not None:
+ lo = skill_min_level if skill_min_level is not None else 1
+ hi = skill_max_level if skill_max_level is not None else 5
+ if lo > hi:
+ lo, hi = hi, lo
+ where.append(
+ "EXISTS (SELECT 1 FROM exercise_skills es WHERE es.exercise_id = e.id AND ("
+ + _EXERCISE_SKILL_LEVEL_RANK_SQL
+ + ") BETWEEN %s AND %s)"
+ )
+ params.extend([lo, hi])
+
+ # Volltext (tsvector); ai_search gleiche Engine, bei zwei Begriffen ODER-Verknüpfung
+ s1 = (search or "").strip()
+ s2 = (ai_search or "").strip()
+ if s1 and s2 and s1 != s2:
+ where.append(
+ "(e.search_vector @@ plainto_tsquery('german', %s) "
+ "OR e.search_vector @@ plainto_tsquery('german', %s))"
+ )
+ params.extend([s1, s2])
+ elif s1 or s2:
+ qtext = s1 or s2
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
- params.append(search)
+ params.append(qtext)
# Query (primary_focus_name für Listen-Ansicht gemäß Spec-Beispiel „focus_area“-Label)
query = f"""
diff --git a/backend/smw_mapper.py b/backend/smw_mapper.py
index 4ab5ff3..61cdf4a 100644
--- a/backend/smw_mapper.py
+++ b/backend/smw_mapper.py
@@ -21,11 +21,11 @@ logger = logging.getLogger(__name__)
# Mapping: SMW-Integer → Shinkan-Stufenname
CAPABILITY_LEVEL_MAP = {
- "1": "einsteiger",
+ "1": "basis",
"2": "grundlagen",
"3": "aufbau",
"4": "fortgeschritten",
- "5": "experte",
+ "5": "optimierung",
}
@@ -167,8 +167,8 @@ def parse_equipment(raw: list[str]) -> list[str]:
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")
+ """Wandelt Integer-Level in kanonischen Stufen-Slug: "3" → "aufbau" """
+ return CAPABILITY_LEVEL_MAP.get(level_str.strip(), "basis")
# ------------------------------------------------------------------ #
@@ -273,9 +273,7 @@ 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: 3}, {skill: Schnelligkeitsausdauer, target_level: 2}]
-
- WICHTIG: target_level ist INTEGER (1-5), nicht String!
+ → target_level als kanonischer Slug (basis … optimierung), DB VARCHAR.
"""
skills = mapped.get("skill_names", [])
levels = mapped.get("skill_levels_raw", [])
@@ -283,18 +281,18 @@ def build_skill_assignments(mapped: dict) -> list[dict]:
assignments = []
for idx, skill_name in enumerate(skills):
level_str = levels[idx] if idx < len(levels) else "1"
- # Konvertiere zu INTEGER statt String-Namen
try:
- target_level = int(level_str.strip())
- except (ValueError, AttributeError):
- target_level = 1 # Fallback
+ raw = str(level_str).strip()
+ except (TypeError, AttributeError):
+ raw = "1"
+ target_slug = map_capability_level(raw) if raw else "basis"
assignments.append({
- "skill_name": skill_name,
- "target_level": target_level, # INTEGER 1-5
- "required_level": None, # Nicht im Wiki spezifiziert
- "intensity": None, # Nicht im Wiki spezifiziert
- "is_primary": idx == 0,
+ "skill_name": skill_name,
+ "target_level": target_slug,
+ "required_level": None,
+ "intensity": None,
+ "is_primary": idx == 0,
})
return assignments
diff --git a/backend/version.py b/backend/version.py
index a3d5c35..83bcc74 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.7.6"
+APP_VERSION = "0.7.7"
BUILD_DATE = "2026-04-27"
-DB_SCHEMA_VERSION = "20260427028"
+DB_SCHEMA_VERSION = "20260427029"
MODULE_VERSIONS = {
"auth": "1.0.0",
@@ -23,6 +23,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.7.7",
+ "date": "2026-04-27",
+ "changes": [
+ "DB 029: Fähigkeitsstufen Einheit (basis–optimierung), model_levels Namen 1–5",
+ "Übungen: GET /exercises Filter Stilrichtung, Trainingsstil, Zielgruppe, Fähigkeits-Stufe min/max, ai_search (Volltext-Platzhalter)",
+ "API: exercise_skills Level-Normalisierung bei Schreiben/Lesen; Wiki-Import Slugs statt Zahl in DB",
+ ],
+ },
{
"version": "0.7.6",
"date": "2026-04-27",
diff --git a/frontend/src/constants/skillLevels.js b/frontend/src/constants/skillLevels.js
new file mode 100644
index 0000000..2fba6c4
--- /dev/null
+++ b/frontend/src/constants/skillLevels.js
@@ -0,0 +1,37 @@
+/** Kanonische Übungs-Fähigkeitsstufen 1–5 (Slug → Anzeige) */
+export const SKILL_LEVEL_OPTIONS = [
+ { value: '', label: '—', level: null },
+ { value: 'basis', label: '1 · Basis', level: 1 },
+ { value: 'grundlagen', label: '2 · Grundlagen', level: 2 },
+ { value: 'aufbau', label: '3 · Aufbau', level: 3 },
+ { value: 'fortgeschritten', label: '4 · Fortgeschritten', level: 4 },
+ { value: 'optimierung', label: '5 · Optimierung', level: 5 },
+]
+
+const LEGACY_MAP = {
+ einsteiger: 'basis',
+ experte: 'optimierung',
+ '1': 'basis',
+ '2': 'grundlagen',
+ '3': 'aufbau',
+ '4': 'fortgeschritten',
+ '5': 'optimierung',
+}
+
+const LABEL_BY_SLUG = Object.fromEntries(
+ SKILL_LEVEL_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label])
+)
+
+export function normalizeSkillLevelSlug(raw) {
+ if (raw == null || raw === '') return ''
+ const s = String(raw).trim().toLowerCase()
+ if (LEGACY_MAP[s]) return LEGACY_MAP[s]
+ if (LABEL_BY_SLUG[s]) return s
+ return ''
+}
+
+export function formatSkillLevelSlug(raw) {
+ const slug = normalizeSkillLevelSlug(raw)
+ if (!slug) return ''
+ return LABEL_BY_SLUG[slug] || slug
+}
diff --git a/frontend/src/pages/ExerciseDetailPage.jsx b/frontend/src/pages/ExerciseDetailPage.jsx
index b1c1240..ace9823 100644
--- a/frontend/src/pages/ExerciseDetailPage.jsx
+++ b/frontend/src/pages/ExerciseDetailPage.jsx
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
+import { formatSkillLevelSlug } from '../constants/skillLevels'
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
@@ -242,15 +243,19 @@ function ExerciseDetailPage() {
Fähigkeiten
+ Standardfilter aus Verein und Trainerrolle folgen später; die KI-Nutzung der zweiten Zeile + kommt mit einem eigenen Endpunkt. +
+