diff --git a/backend/routers/import_wiki.py b/backend/routers/import_wiki.py index c8ae09e..4dbb918 100644 --- a/backend/routers/import_wiki.py +++ b/backend/routers/import_wiki.py @@ -30,6 +30,17 @@ CATEGORY_METHODS = os.getenv("MEDIAWIKI_CATEGORY_METHODS", "Methodenbeschrei CATEGORY_MODELS = os.getenv("MEDIAWIKI_CATEGORY_MODELS", "Reifegradmodelle") +def _wiki_category_or_default(category: Optional[str], import_type: str) -> str: + """Leeres category ⇒ Standard je import_type.""" + if (category or "").strip(): + return category.strip() + if import_type == "skill": + return CATEGORY_SKILLS + if import_type == "method": + return CATEGORY_METHODS + return CATEGORY_EXERCISES + + # ------------------------------------------------------------------ # # Pydantic Models # # ------------------------------------------------------------------ # @@ -59,7 +70,7 @@ def require_admin(session: dict = Depends(require_auth)) -> dict: @router.get("/import/mediawiki/preview") async def preview_import( - category: str = Query(default=CATEGORY_EXERCISES), + category: Optional[str] = Query(default=None), import_type: str = Query(default="exercise"), limit: int = Query(default=10, ge=1, le=500), session: dict = Depends(require_admin), @@ -68,9 +79,10 @@ async def preview_import( Zeigt Vorschau: Welche Seiten würden importiert werden? Überprüft Duplikate und mapped Felder ohne zu speichern. """ + resolved_category = _wiki_category_or_default(category, import_type) client = SmwClient() try: - members = await client.get_category_members(category, limit=limit) + members = await client.get_category_members(resolved_category, limit=limit) except SmwClientError as e: raise HTTPException(status_code=502, detail=f"Wiki-API nicht erreichbar: {e}") @@ -129,7 +141,7 @@ async def preview_import( }) return { - "category": category, + "category": resolved_category, "import_type": import_type, "total_found": len(members), "preview": preview, @@ -151,6 +163,7 @@ async def execute_import( Gibt sofort log_id zurück – Status via GET /import/mediawiki/status/{log_id}. """ profile_id = session["profile_id"] + resolved_category = _wiki_category_or_default(body.category, body.import_type) # Log-Eintrag anlegen with get_db() as conn: @@ -160,7 +173,7 @@ async def execute_import( (import_type, import_status, category, dry_run, reimport, imported_by) VALUES (%s, 'running', %s, %s, %s, %s) RETURNING id""", - (body.import_type, body.category, body.dry_run, body.reimport_existing, profile_id) + (body.import_type, resolved_category, body.dry_run, body.reimport_existing, profile_id) ) log_id = cur.fetchone()['id'] conn.commit() @@ -169,7 +182,7 @@ async def execute_import( background_tasks.add_task( _run_import, log_id=log_id, - category=body.category, + category=resolved_category, import_type=body.import_type, reimport=body.reimport_existing, dry_run=body.dry_run, diff --git a/backend/smw_client.py b/backend/smw_client.py index 0409bdb..1412d24 100644 --- a/backend/smw_client.py +++ b/backend/smw_client.py @@ -20,6 +20,26 @@ class SmwClientError(Exception): pass +def _normalize_mw_category(category: str) -> str: + """ + Bereinigt Kategorienamen für API cmtitle=Kategorie:… + Erlaubt z. B. 'Fähigkeitsbeschreibung', ' Kategorie:X ', 'kategorie:X' ohne Doppel-Prefix. + """ + c = (category or "").strip() + if not c: + raise SmwClientError("Kategorie (Seitenlisten-Name ohne Präfix) darf nicht leer sein") + + pref = "kategorie:" + while c.lower().startswith(pref): + c = c[len(pref) :].lstrip() + + remaining = (c or "").strip() + if not remaining: + raise SmwClientError("Kategorie (Seitenlisten-Name ohne Präfix) darf nicht leer sein") + + return remaining + + class SmwClient: """Stateless MediaWiki/SMW API Client mit Session-Login.""" @@ -110,12 +130,13 @@ class SmwClient: """ members = [] cmcontinue = None + cat = _normalize_mw_category(category) while True: params = { "action": "query", "list": "categorymembers", - "cmtitle": f"Kategorie:{category}", + "cmtitle": f"Kategorie:{cat}", "cmlimit": min(limit, 500), "cmtype": "page", # Nur Seiten, keine Unterkategorien "cmprop": "ids|title", @@ -134,8 +155,8 @@ class SmwClient: # Rekursiv durch Unterkategorien gehen if recursive: - subcats = await self._get_subcategories(category) - logger.info(f"Kategorie '{category}': {len(members)} direkte Seiten, {len(subcats)} Unterkategorien") + subcats = await self._get_subcategories(cat) + logger.info(f"Kategorie '{cat}': {len(members)} direkte Seiten, {len(subcats)} Unterkategorien") for subcat in subcats: if len(members) >= limit: @@ -150,12 +171,13 @@ class SmwClient: """Gibt alle Unterkategorien einer Kategorie zurück.""" subcats = [] cmcontinue = None + cat = _normalize_mw_category(category) while True: params = { "action": "query", "list": "categorymembers", - "cmtitle": f"Kategorie:{category}", + "cmtitle": f"Kategorie:{cat}", "cmlimit": 500, "cmtype": "subcat", # Nur Unterkategorien "cmprop": "ids|title", diff --git a/backend/smw_mapper.py b/backend/smw_mapper.py index 419cff9..970713e 100644 --- a/backend/smw_mapper.py +++ b/backend/smw_mapper.py @@ -408,9 +408,9 @@ def map_wiki_to_skill( first_value = values[0] if isinstance(values, list) else values if target == "description": - description_text = wikitext_to_plaintext(first_value) + description_text = wikitext_to_plaintext(str(first_value)) elif target == "karate_relevance": - mapped["karate_relevance"] = wikitext_to_plaintext(first_value) + mapped["karate_relevance"] = wikitext_to_plaintext(str(first_value)) elif target == "relevance_level": parsed = parse_wiki_relevance_level(first_value if isinstance(first_value, str) else str(first_value)) if parsed is None: diff --git a/backend/tests/test_import_wiki_category_default.py b/backend/tests/test_import_wiki_category_default.py new file mode 100644 index 0000000..aa4cec3 --- /dev/null +++ b/backend/tests/test_import_wiki_category_default.py @@ -0,0 +1,18 @@ +"""Resolver für leere/fehlende Kategorie je import_type.""" + +from routers.import_wiki import ( + CATEGORY_EXERCISES, + CATEGORY_METHODS, + CATEGORY_SKILLS, + _wiki_category_or_default, +) + + +def test_wiki_category_or_default_explicit(): + assert _wiki_category_or_default(" MeineKat ", "skill") == "MeineKat" + + +def test_wiki_category_or_default_falls_through(): + assert _wiki_category_or_default(None, "exercise") == CATEGORY_EXERCISES + assert _wiki_category_or_default("", "skill") == CATEGORY_SKILLS + assert _wiki_category_or_default(" ", "method") == CATEGORY_METHODS diff --git a/backend/tests/test_smw_client_category_normalize.py b/backend/tests/test_smw_client_category_normalize.py new file mode 100644 index 0000000..1abb1bb --- /dev/null +++ b/backend/tests/test_smw_client_category_normalize.py @@ -0,0 +1,21 @@ +import pytest + +from smw_client import _normalize_mw_category, SmwClientError + + +def test_normalize_strips_optional_kategorie_prefix(): + assert _normalize_mw_category("Fähigkeitsbeschreibung") == "Fähigkeitsbeschreibung" + assert _normalize_mw_category(" Kategorie:Fähigkeitsbeschreibung ") == "Fähigkeitsbeschreibung" + assert _normalize_mw_category("kategorie:test") == "test" + + +def test_normalize_strips_duplicate_prefix_chain(): + assert _normalize_mw_category("Kategorie:Kategorie:Foo") == "Foo" + + +def test_normalize_rejects_empty(): + with pytest.raises(SmwClientError, match="leer"): + _normalize_mw_category("") + with pytest.raises(SmwClientError, match="leer"): + _normalize_mw_category(" Kategorie: ") + diff --git a/backend/version.py b/backend/version.py index e85b733..a3c7b7d 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.143" +APP_VERSION = "0.8.145" BUILD_DATE = "2026-05-16" DB_SCHEMA_VERSION = "20260516065" @@ -27,7 +27,7 @@ MODULE_VERSIONS = { "planning": "0.13.0", # Vorlagen/Framework/Module/Graphs: RBAC wie Übungen (edit/delete/governance transition); Planungs-UI Sichtbarkeit neue Vorlage "dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine) "training_modules": "1.1.0", # PUT/DELETE: assert_library_content_* (Vereinsadmin löscht Vereins-Inhalt, Trainer bearbeitet club wie Übungen) - "import_wiki": "1.0.1", # Skills: KarateRelevanz + RelevanzLevel → DB-Spalten + "import_wiki": "1.0.3", # Default-Kategorie Fähigkeiten: Fähigkeitsbeschreibung; cmtitle-Normalisierung; UI Preview/Execute Defaults je Typ "admin": "1.0.0", "membership": "1.0.0", "catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012) @@ -36,6 +36,21 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.145", + "date": "2026-05-16", + "changes": [ + "Wiki-Import: Standard-Kategorie Fähigkeiten korrigiert auf „Fähigkeitsbeschreibung“ (korrekter Wiki-Name).", + ], + }, + { + "version": "0.8.144", + "date": "2026-05-16", + "changes": [ + "Wiki-Import: Default-Kategorie Fähigkeiten; SMW-Client entfernt versehentliches „Kategorie:“-Prefix; Vorschau/Ausführung nutzen Default-Kategorie je import_type wenn leer.", + "Admin UI Wiki-Import: Wechsel Import-Typ setzt passende Standard-Kategorie.", + ], + }, { "version": "0.8.143", "date": "2026-05-16", diff --git a/frontend/src/app.css b/frontend/src/app.css index a53ce54..a3d4378 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -3342,6 +3342,12 @@ a.analysis-split__nav-item { .admin-matrix-skill-list__name { font-size: 15px; } +.admin-matrix-skill-list__buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} .admin-matrix-skill-list__path { font-size: 12px; margin-top: 4px; @@ -3388,6 +3394,16 @@ a.analysis-split__nav-item { margin-top: 6px; line-height: 1.35; } +.admin-matrix-matrix__skill-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} +.admin-matrix-matrix__skill-head .btn-tiny { + flex-shrink: 0; + margin-top: 2px; +} .admin-matrix-matrix__cell { vertical-align: top; padding: 6px; diff --git a/frontend/src/components/admin/MaturityModelsAdminPanel.jsx b/frontend/src/components/admin/MaturityModelsAdminPanel.jsx index e9e2de4..dba4e4c 100644 --- a/frontend/src/components/admin/MaturityModelsAdminPanel.jsx +++ b/frontend/src/components/admin/MaturityModelsAdminPanel.jsx @@ -25,6 +25,13 @@ export default function MaturityModelsAdminPanel() { const [levelsForm, setLevelsForm] = useState([]) const [cellDraft, setCellDraft] = useState({}) const [skillToAdd, setSkillToAdd] = useState('') + /** Modal: Stammdaten Fähigkeit (Wiki-Felder) aus Matrix-Kontext */ + const [skillWikiModal, setSkillWikiModal] = useState(null) + const [skillWikiForm, setSkillWikiForm] = useState({ + karate_relevance: '', + relevance_level: '' + }) + const [skillWikiLoading, setSkillWikiLoading] = useState(false) useEffect(() => { let cancelled = false @@ -219,6 +226,57 @@ export default function MaturityModelsAdminPanel() { } } + async function openSkillWikiModal(skillId, skillName) { + setSkillWikiModal({ skill_id: skillId, skill_name: skillName }) + setSkillWikiLoading(true) + setSkillWikiForm({ karate_relevance: '', relevance_level: '' }) + try { + const s = await api.getSkill(skillId) + setSkillWikiForm({ + karate_relevance: s.karate_relevance || '', + relevance_level: + s.relevance_level != null && s.relevance_level !== '' + ? String(s.relevance_level) + : '' + }) + } catch (e) { + setError(e.message || String(e)) + setSkillWikiModal(null) + } finally { + setSkillWikiLoading(false) + } + } + + function closeSkillWikiModal() { + setSkillWikiModal(null) + } + + async function handleSaveSkillWikiFields(e) { + e.preventDefault() + if (!skillWikiModal) return + setError('') + setSkillWikiLoading(true) + try { + await api.updateSkill(skillWikiModal.skill_id, { + karate_relevance: + skillWikiForm.karate_relevance && skillWikiForm.karate_relevance.trim() + ? skillWikiForm.karate_relevance.trim() + : null, + relevance_level: + skillWikiForm.relevance_level === '' || skillWikiForm.relevance_level == null + ? null + : Number(skillWikiForm.relevance_level) + }) + const sk = await api.listSkills({ status: 'active' }) + setAllSkills(sk) + closeSkillWikiModal() + } catch (e) { + setError(e.message || String(e)) + } finally { + setSkillWikiLoading(false) + } + } + async function handleDeleteModel() { if (!selectedId || !isSuperadmin) return if (!confirm('Reifegradmodell dauerhaft löschen?')) return @@ -552,13 +610,24 @@ export default function MaturityModelsAdminPanel() {
  • {ms.skill_name} - + + + +
    {(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
    @@ -595,13 +664,26 @@ export default function MaturityModelsAdminPanel() { {(detail.model_skills || []).map((ms) => ( -
    {ms.skill_name}
    - {(ms.skill_main_category_name || ms.skill_subcategory_name) ? ( -
    - {ms.skill_main_category_name || '—'} - {ms.skill_subcategory_name ? ` › ${ms.skill_subcategory_name}` : ''} +
    +
    +
    {ms.skill_name}
    + {(ms.skill_main_category_name || ms.skill_subcategory_name) ? ( +
    + {ms.skill_main_category_name || '—'} + {ms.skill_subcategory_name ? ` › ${ms.skill_subcategory_name}` : ''} +
    + ) : null}
    - ) : null} + +
    {(detail.levels || []).map((l) => { const key = `${ms.skill_id}-${l.level_number}` @@ -643,6 +725,90 @@ export default function MaturityModelsAdminPanel() { )}
    + + {skillWikiModal ? ( +
    +
    e.stopPropagation()} + > +
    +

    + Fähigkeit: {skillWikiModal.skill_name} +

    + +
    +
    + {skillWikiLoading ? ( +

    Lade Daten…

    + ) : ( +
    + +