feat: enhance exercise mapping and filtering capabilities
- 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:
parent
76098f5244
commit
025b161d2f
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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])]
|
||||
|
||||
|
|
|
|||
67
frontend/src/components/SearchableSelect.jsx
Normal file
67
frontend/src/components/SearchableSelect.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user