feat: enhance exercise mapping and filtering capabilities
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m56s

- Added support for style direction mappings in the backend, allowing for improved categorization of exercises.
- Introduced a new function to normalize property synonyms, enhancing the mapping of exercise properties.
- Updated the exercise catalog assignment logic to include style directions, ensuring proper database entries.
- Enhanced the ExercisesListPage with new filtering options for style directions, improving user experience and search capabilities.
This commit is contained in:
Lars 2026-04-28 07:25:33 +02:00
parent 76098f5244
commit 025b161d2f
4 changed files with 254 additions and 118 deletions

View File

@ -518,6 +518,29 @@ def _upsert_exercise(mapped: dict, reimport: bool, created_by: int) -> Optional[
return ex_id
def _find_skill_id_by_label(cur, label: str) -> Optional[int]:
"""Katalog-Skill anhand Wiki-Label; exakt, ohne Leerzeichen, zuletzt Teilstring."""
if not label or not str(label).strip():
return None
raw = str(label).strip()
cur.execute("SELECT id FROM skills WHERE TRIM(name) ILIKE %s", (raw,))
row = cur.fetchone()
if row:
return row["id"]
comp = "".join(raw.split())
if len(comp) >= 3:
cur.execute(
"SELECT id FROM skills WHERE regexp_replace(TRIM(name), E'\\s+', '', 'g') ILIKE %s",
(comp,),
)
row = cur.fetchone()
if row:
return row["id"]
cur.execute("SELECT id FROM skills WHERE name ILIKE %s LIMIT 1", (f"%{raw}%",))
row = cur.fetchone()
return row["id"] if row else None
def _assign_exercise_catalogs(cur, conn, exercise_id: int, mapped: dict):
"""Weist M:N Katalog-Zuordnungen für eine importierte Übung zu."""
@ -535,16 +558,18 @@ def _assign_exercise_catalogs(cur, conn, exercise_id: int, mapped: dict):
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,))
# Stilrichtungen
for idx, name in enumerate(mapped.get("style_names", [])):
cur.execute("SELECT id FROM style_directions WHERE name ILIKE %s", (name.strip(),))
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['id'])
"""INSERT INTO exercise_style_directions (exercise_id, style_direction_id, is_primary)
VALUES (%s, %s, %s) ON CONFLICT (exercise_id, style_direction_id) DO NOTHING""",
(exercise_id, row["id"], idx == 0),
)
else:
logger.warning("Stilrichtung '%s' nicht im Katalog gefunden", name)
# Target Groups
for name in mapped.get("target_group_names", []):
@ -557,16 +582,7 @@ def _assign_exercise_catalogs(cur, conn, exercise_id: int, mapped: dict):
(exercise_id, row['id'])
)
# 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['id'])
)
# Fähigkeiten: nur über _assign_exercise_skills (Levels + is_primary), nicht doppelt hier
conn.commit()
@ -574,10 +590,9 @@ def _assign_exercise_catalogs(cur, conn, exercise_id: int, mapped: dict):
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"])
sid = _find_skill_id_by_label(cur, assignment.get("skill_name") or "")
if not sid:
logger.warning("Skill '%s' nicht im Katalog gefunden", assignment.get("skill_name"))
continue
cur.execute(
"""INSERT INTO exercise_skills
@ -587,7 +602,8 @@ def _assign_exercise_skills(cur, conn, exercise_id: int, skill_assignments: list
target_level = EXCLUDED.target_level,
is_primary = EXCLUDED.is_primary""",
(
exercise_id, row['id'],
exercise_id,
sid,
assignment.get("target_level"),
assignment.get("required_level"),
assignment.get("intensity"),

View File

@ -51,6 +51,7 @@ EXERCISE_PROPERTY_MAP = {
"Zielgruppe": "target_group_names",
"Altersgruppe": "age_group_names",
"Trainingsmethode": "method_names", # Wiki-Seitenname z.B. "Plyometrisches_Training"
"Stilrichtung": "style_names", # z. B. Shotokan; siehe EXERCISE_PROPERTY_SYNONYM_TO_TARGET
# Fähigkeiten (als Namen + Level)
"PrimaryCapability": "skill_names", # Skill-Namen (können mehrere sein)
"CapabilityLevel": "skill_levels_raw", # Integer-Levels ["3", "2"] → aufbau, grundlagen
@ -171,6 +172,49 @@ def map_capability_level(level_str: str) -> str:
return CAPABILITY_LEVEL_MAP.get(level_str.strip(), "basis")
# ------------------------------------------------------------------ #
# SMW-Property-Label → Mapper-Zielfeld (Werte wie in EXERCISE_PROPERTY_MAP) #
# browse_subject liefert Anzeigenamen, nicht zwingend interne Property-IDs. #
# ------------------------------------------------------------------ #
def _norm_prop_synonym(name: str) -> str:
s = (name or "").strip().lower()
for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")):
s = s.replace(a, b)
return "".join(c for c in s if c.isalnum())
# alternative Labels → Zielfeld-Name (gleiche Strings wie Werte in EXERCISE_PROPERTY_MAP)
EXERCISE_PROPERTY_SYNONYM_TO_TARGET: dict[str, str] = {
"primarycapability": "skill_names",
"hauptfaehigkeit": "skill_names",
"primaerefaehigkeit": "skill_names",
"hauptfhigkeit": "skill_names",
"hauptfahigkeit": "skill_names",
"capabilitylevel": "skill_levels_raw",
"faehigkeitsstufe": "skill_levels_raw",
"faehigkeitslevel": "skill_levels_raw",
"capabilitystufe": "skill_levels_raw",
"stilrichtung": "style_names",
"trainingsstilrichtung": "style_names",
}
def _exercise_property_target(prop_name: str) -> str | None:
"""Ermittelt Zielfeld für eine SMW-Property; None = unbekannt."""
if prop_name in EXERCISE_PROPERTY_MAP:
return EXERCISE_PROPERTY_MAP[prop_name]
n = _norm_prop_synonym(prop_name)
if n in EXERCISE_PROPERTY_SYNONYM_TO_TARGET:
return EXERCISE_PROPERTY_SYNONYM_TO_TARGET[n]
nlow = (prop_name or "").lower()
if "primary" in nlow and "capab" in nlow and "level" not in nlow:
return "skill_names"
if "capab" in nlow and "level" in nlow:
return "skill_levels_raw"
return None
# ------------------------------------------------------------------ #
# Haupt-Mapping-Funktion #
# ------------------------------------------------------------------ #
@ -206,6 +250,7 @@ def map_wiki_to_exercise(
"age_group_names": [],
"skill_names": [],
"skill_levels_raw": [], # Integer-Strings ["3", "2"]
"style_names": [],
"method_names": [],
# Equipment
"equipment": [],
@ -217,7 +262,7 @@ def map_wiki_to_exercise(
if not values:
continue
target = EXERCISE_PROPERTY_MAP.get(prop_name)
target = _exercise_property_target(prop_name)
if not target:
continue
@ -259,6 +304,9 @@ def map_wiki_to_exercise(
elif target == "method_names":
mapped["method_names"] = [wiki_name_to_label(v) for v in (values if isinstance(values, list) else [values])]
elif target == "style_names":
mapped["style_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])]

View File

@ -0,0 +1,67 @@
import React, { useMemo, useState } from 'react'
/**
* Kombination aus Filter-Eingabe und Auswahl: Liste wird per Tippen eingeschränkt.
* Die aktuelle Auswahl bleibt sichtbar, auch wenn sie durch den Filter ausgeblendet würde.
*/
export default function SearchableSelect({
value,
onChange,
options = [],
idKey = 'id',
labelKey = 'label',
allLabel = 'Alle',
filterPlaceholder = 'Tippen zum Filtern…',
selectClassName = 'form-input',
className = '',
}) {
const [q, setQ] = useState('')
const rows = useMemo(() => {
return options.map((o) => ({
id: o[idKey],
label: typeof o[labelKey] === 'function' ? o[labelKey](o) : String(o[labelKey] ?? ''),
}))
}, [options, idKey, labelKey])
const filtered = useMemo(() => {
const t = q.trim().toLowerCase()
if (!t) return rows
return rows.filter((r) => r.label.toLowerCase().includes(t) || String(r.id).includes(t))
}, [rows, q])
const withSelection = useMemo(() => {
if (value === '' || value == null) return filtered
const sel = String(value)
if (filtered.some((r) => String(r.id) === sel)) return filtered
const found = rows.find((r) => String(r.id) === sel)
if (found) return [found, ...filtered]
return filtered
}, [filtered, rows, value])
return (
<div className={className} style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<input
type="search"
className={selectClassName}
placeholder={filterPlaceholder}
value={q}
onChange={(e) => setQ(e.target.value)}
autoComplete="off"
aria-label="Filter"
/>
<select
className={selectClassName}
value={value === '' || value == null ? '' : String(value)}
onChange={(e) => onChange(e.target.value === '' ? '' : e.target.value)}
>
<option value="">{allLabel}</option>
{withSelection.map((r) => (
<option key={String(r.id)} value={r.id}>
{r.label}
</option>
))}
</select>
</div>
)
}

View File

@ -2,6 +2,7 @@ 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'
import SearchableSelect from '../components/SearchableSelect'
const PAGE_SIZE = 100
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
@ -46,6 +47,52 @@ function ExercisesListPage() {
return () => clearTimeout(t)
}, [aiSearchInput])
const focusOptions = useMemo(
() =>
catalogs.focusAreas.map((fa) => ({
id: fa.id,
label: `${fa.icon || ''} ${fa.name || ''}`.trim(),
})),
[catalogs.focusAreas]
)
const styleOptions = useMemo(
() => catalogs.styleDirections.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
[catalogs.styleDirections]
)
const trainingTypeOptions = useMemo(
() => catalogs.trainingTypes.map((t) => ({ id: t.id, label: t.name || String(t.id) })),
[catalogs.trainingTypes]
)
const targetGroupOptions = useMemo(
() => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })),
[catalogs.targetGroups]
)
const skillOptions = useMemo(
() => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
[catalogs.skills]
)
const levelFilterOptions = useMemo(
() => LEVEL_FILTER_OPTS.map((o) => ({ id: o.level, label: o.label })),
[]
)
const visibilityOptions = useMemo(
() => [
{ id: 'private', label: 'Privat' },
{ id: 'club', label: 'Verein' },
{ id: 'official', label: 'Offiziell' },
],
[]
)
const statusOptions = useMemo(
() => [
{ id: 'draft', label: 'Entwurf' },
{ id: 'in_review', label: 'In Prüfung' },
{ id: 'approved', label: 'Freigegeben' },
{ id: 'archived', label: 'Archiviert' },
],
[]
)
const queryBase = useMemo(() => {
const q = {}
const n = (v) => (v === '' || v == null ? undefined : Number(v))
@ -205,135 +252,93 @@ function ExercisesListPage() {
</div>
<div>
<label className="form-label">Fokus</label>
<select
className="form-input"
<SearchableSelect
value={filters.focus_area}
onChange={(e) => setFilters({ ...filters, focus_area: e.target.value })}
>
<option value="">Alle</option>
{catalogs.focusAreas.map((fa) => (
<option key={fa.id} value={fa.id}>
{fa.icon} {fa.name}
</option>
))}
</select>
onChange={(v) => setFilters({ ...filters, focus_area: v })}
options={focusOptions}
allLabel="Alle Fokusbereiche"
filterPlaceholder="Fokus filtern…"
/>
</div>
<div>
<label className="form-label">Stilrichtung</label>
<select
className="form-input"
<SearchableSelect
value={filters.style_direction_id}
onChange={(e) => setFilters({ ...filters, style_direction_id: e.target.value })}
>
<option value="">Alle</option>
{catalogs.styleDirections.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
onChange={(v) => setFilters({ ...filters, style_direction_id: v })}
options={styleOptions}
allLabel="Alle Stilrichtungen"
filterPlaceholder="Stilrichtung filtern…"
/>
</div>
<div>
<label className="form-label">Trainingsstil</label>
<select
className="form-input"
<SearchableSelect
value={filters.training_type_id}
onChange={(e) => setFilters({ ...filters, training_type_id: e.target.value })}
>
<option value="">Alle</option>
{catalogs.trainingTypes.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
onChange={(v) => setFilters({ ...filters, training_type_id: v })}
options={trainingTypeOptions}
allLabel="Alle Trainingsstile"
filterPlaceholder="Trainingsstil filtern…"
/>
</div>
<div>
<label className="form-label">Zielgruppe</label>
<select
className="form-input"
<SearchableSelect
value={filters.target_group_id}
onChange={(e) => setFilters({ ...filters, target_group_id: e.target.value })}
>
<option value="">Alle</option>
{catalogs.targetGroups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
onChange={(v) => setFilters({ ...filters, target_group_id: v })}
options={targetGroupOptions}
allLabel="Alle Zielgruppen"
filterPlaceholder="Zielgruppe filtern…"
/>
</div>
<div>
<label className="form-label">Fähigkeit</label>
<select
className="form-input"
<SearchableSelect
value={filters.skill_id}
onChange={(e) => setFilters({ ...filters, skill_id: e.target.value })}
>
<option value="">Alle</option>
{catalogs.skills.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
onChange={(v) => setFilters({ ...filters, skill_id: v })}
options={skillOptions}
allLabel="Alle Fähigkeiten"
filterPlaceholder="Fähigkeit filtern…"
/>
</div>
<div>
<label className="form-label">Fähigkeit Stufe von</label>
<select
className="form-input"
<SearchableSelect
value={filters.skill_min_level}
onChange={(e) => setFilters({ ...filters, skill_min_level: e.target.value })}
>
<option value="">egal</option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={o.value} value={String(o.level)}>
{o.label}
</option>
))}
</select>
onChange={(v) => setFilters({ ...filters, skill_min_level: v })}
options={levelFilterOptions}
allLabel="egal (min)"
filterPlaceholder="Stufe suchen…"
/>
</div>
<div>
<label className="form-label">Fähigkeit Stufe bis</label>
<select
className="form-input"
<SearchableSelect
value={filters.skill_max_level}
onChange={(e) => setFilters({ ...filters, skill_max_level: e.target.value })}
>
<option value="">egal</option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={`m-${o.value}`} value={String(o.level)}>
{o.label}
</option>
))}
</select>
onChange={(v) => setFilters({ ...filters, skill_max_level: v })}
options={levelFilterOptions}
allLabel="egal (max)"
filterPlaceholder="Stufe suchen…"
/>
</div>
<div>
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
<SearchableSelect
value={filters.visibility}
onChange={(e) => setFilters({ ...filters, visibility: e.target.value })}
>
<option value="">Alle</option>
<option value="private">Privat</option>
<option value="club">Verein</option>
<option value="official">Offiziell</option>
</select>
onChange={(v) => setFilters({ ...filters, visibility: v })}
options={visibilityOptions}
allLabel="Alle"
filterPlaceholder="Filtern…"
/>
</div>
<div>
<label className="form-label">Status</label>
<select
className="form-input"
<SearchableSelect
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
>
<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>
onChange={(v) => setFilters({ ...filters, status: v })}
options={statusOptions}
allLabel="Alle"
filterPlaceholder="Filtern…"
/>
</div>
</div>