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

- {exercise.skills.map((s) => ( - - {s.skill_name} - {s.intensity ? ` · ${s.intensity}` : ''} - {s.required_level || s.target_level - ? ` (${[s.required_level, s.target_level].filter(Boolean).join(' → ')})` - : ''} - - ))} + {exercise.skills.map((s) => { + const rl = formatSkillLevelSlug(s.required_level) + const tl = formatSkillLevelSlug(s.target_level) + const lvl = + rl || tl ? ` (${[rl, tl].filter(Boolean).join(' → ')})` : '' + return ( + + {s.skill_name} + {s.intensity ? ` · ${s.intensity}` : ''} + {lvl} + + ) + })}
)} diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index f2e66ea..10a46ea 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import api, { buildExerciseApiPayload } from '../utils/api' import RichTextEditor from '../components/RichTextEditor' +import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels' const INTENSITY_OPTIONS = [ { value: '', label: '—' }, @@ -10,15 +11,6 @@ const INTENSITY_OPTIONS = [ { value: 'hoch', label: 'hoch' }, ] -const LEVEL_OPTIONS = [ - { value: '', label: '—' }, - { value: 'einsteiger', label: 'Einsteiger' }, - { value: 'grundlagen', label: 'Grundlagen' }, - { value: 'aufbau', label: 'Aufbau' }, - { value: 'fortgeschritten', label: 'Fortgeschritten' }, - { value: 'experte', label: 'Experte' }, -] - function emptyForm() { return { title: '', @@ -78,8 +70,8 @@ function detailToForm(exercise) { skill_id: s.skill_id, is_primary: s.is_primary || false, intensity: s.intensity || '', - required_level: s.required_level || '', - target_level: s.target_level || '', + required_level: normalizeSkillLevelSlug(s.required_level), + target_level: normalizeSkillLevelSlug(s.target_level), })) || [], } } @@ -636,7 +628,7 @@ function ExerciseFormPage() { value={row.required_level || ''} onChange={(e) => updateSkillField(idx, 'required_level', e.target.value)} > - {LEVEL_OPTIONS.map((o) => ( + {SKILL_LEVEL_OPTIONS.map((o) => ( @@ -647,7 +639,7 @@ function ExerciseFormPage() { value={row.target_level || ''} onChange={(e) => updateSkillField(idx, 'target_level', e.target.value)} > - {LEVEL_OPTIONS.map((o) => ( + {SKILL_LEVEL_OPTIONS.map((o) => ( diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index 0d2b3c5..418b45e 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -1,35 +1,112 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useMemo } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' +import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' const PAGE_SIZE = 100 +const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) function ExercisesListPage() { const [exercises, setExercises] = useState([]) - const [focusAreas, setFocusAreas] = useState([]) + const [catalogs, setCatalogs] = useState({ + focusAreas: [], + styleDirections: [], + trainingTypes: [], + targetGroups: [], + skills: [], + }) + const [catalogsReady, setCatalogsReady] = useState(false) const [loading, setLoading] = useState(true) const [loadingMore, setLoadingMore] = useState(false) const [offset, setOffset] = useState(0) const [hasMore, setHasMore] = useState(false) + const [searchInput, setSearchInput] = useState('') + const [aiSearchInput, setAiSearchInput] = useState('') + const [debouncedSearch, setDebouncedSearch] = useState('') + const [debouncedAiSearch, setDebouncedAiSearch] = useState('') const [filters, setFilters] = useState({ focus_area: '', + style_direction_id: '', + training_type_id: '', + target_group_id: '', + skill_id: '', + skill_min_level: '', + skill_max_level: '', visibility: '', status: '', }) useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400) + return () => clearTimeout(t) + }, [searchInput]) + + useEffect(() => { + const t = setTimeout(() => setDebouncedAiSearch(aiSearchInput.trim()), 400) + return () => clearTimeout(t) + }, [aiSearchInput]) + + const queryBase = useMemo(() => { + const q = {} + const n = (v) => (v === '' || v == null ? undefined : Number(v)) + if (filters.focus_area) q.focus_area = n(filters.focus_area) + if (filters.style_direction_id) q.style_direction_id = n(filters.style_direction_id) + if (filters.training_type_id) q.training_type_id = n(filters.training_type_id) + if (filters.target_group_id) q.target_group_id = n(filters.target_group_id) + if (filters.skill_id) q.skill_id = n(filters.skill_id) + if (filters.skill_min_level) q.skill_min_level = n(filters.skill_min_level) + if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level) + if (filters.visibility) q.visibility = filters.visibility + if (filters.status) q.status = filters.status + if (debouncedSearch) q.search = debouncedSearch + if (debouncedAiSearch) q.ai_search = debouncedAiSearch + return q + }, [filters, debouncedSearch, debouncedAiSearch]) + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const [fa, sd, tt, tg, sk] = await Promise.all([ + api.listFocusAreas(), + api.listStyleDirections(), + api.listTrainingTypes(), + api.listTargetGroups(), + api.listSkills(), + ]) + if (!cancelled) { + setCatalogs({ + focusAreas: fa, + styleDirections: sd, + trainingTypes: tt, + targetGroups: tg, + skills: sk, + }) + setCatalogsReady(true) + } + } catch (err) { + if (!cancelled) { + console.error(err) + alert('Kataloge konnten nicht geladen werden: ' + err.message) + setCatalogsReady(true) + } + } + })() + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + if (!catalogsReady) return let cancelled = false const run = async () => { setLoading(true) setOffset(0) try { - const [batch, focusAreasData] = await Promise.all([ - api.listExercises({ ...filters, limit: PAGE_SIZE, offset: 0 }), - api.listFocusAreas(), - ]) + const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 }) if (cancelled) return setExercises(batch) - setFocusAreas(focusAreasData) setHasMore(batch.length === PAGE_SIZE) setOffset(batch.length) } catch (err) { @@ -45,13 +122,13 @@ function ExercisesListPage() { return () => { cancelled = true } - }, [filters]) + }, [queryBase, catalogsReady]) const loadMore = async () => { if (loadingMore || !hasMore) return setLoadingMore(true) try { - const batch = await api.listExercises({ ...filters, limit: PAGE_SIZE, offset }) + const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset }) setExercises((prev) => [...prev, ...batch]) setHasMore(batch.length === PAGE_SIZE) setOffset((o) => o + batch.length) @@ -72,7 +149,7 @@ function ExercisesListPage() { } } - if (loading) { + if (!catalogsReady || loading) { return (
@@ -100,6 +177,32 @@ function ExercisesListPage() {
+
+ + setSearchInput(e.target.value)} + autoComplete="off" + /> +
+
+ + setAiSearchInput(e.target.value)} + autoComplete="off" + /> +

+ Standardfilter aus Verein und Trainerrolle folgen später; die KI-Nutzung der zweiten Zeile + kommt mit einem eigenen Endpunkt. +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +