Enhance MediaWiki import functionality with category normalization and skill attributes
All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m17s
Test Suite / pytest-backend (pull_request) Successful in 35s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 12s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m8s

- Introduced `_normalize_mw_category` function to clean category names for API calls, ensuring consistent handling of category prefixes.
- Updated `SmwClient` methods to utilize normalized category names, improving data retrieval accuracy.
- Added `_wiki_category_or_default` function to provide default categories based on import type, enhancing user experience during imports.
- Integrated new fields `karate_relevance` and `relevance_level` into various admin components, allowing for better skill management.
- Incremented app version to 0.8.145 and updated changelog to reflect these changes.
This commit is contained in:
Lars 2026-05-16 11:05:15 +02:00
parent 949a77fe38
commit 623af621b4
11 changed files with 407 additions and 34 deletions

View File

@ -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,

View File

@ -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",

View File

@ -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:

View File

@ -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

View File

@ -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: ")

View File

@ -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",

View File

@ -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;

View File

@ -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,6 +610,16 @@ export default function MaturityModelsAdminPanel() {
<li key={ms.skill_id} className="admin-matrix-skill-list__item">
<div className="admin-matrix-skill-list__row">
<strong className="admin-matrix-skill-list__name">{ms.skill_name}</strong>
<span className="admin-matrix-skill-list__buttons">
<button
type="button"
className="btn btn-secondary btn-small"
disabled={saving || skillWikiLoading}
title="Karate-Relevanz und Relevanzgrad bearbeiten"
onClick={() => openSkillWikiModal(ms.skill_id, ms.skill_name)}
>
Wiki-Felder
</button>
<button
type="button"
className="btn btn-secondary btn-small"
@ -559,6 +627,7 @@ export default function MaturityModelsAdminPanel() {
>
Entfernen
</button>
</span>
</div>
{(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
<div className="admin-matrix-skill-list__path muted">
@ -595,6 +664,8 @@ export default function MaturityModelsAdminPanel() {
{(detail.model_skills || []).map((ms) => (
<tr key={ms.skill_id}>
<td className="admin-matrix-matrix__skill-cell">
<div className="admin-matrix-matrix__skill-head">
<div>
<div>{ms.skill_name}</div>
{(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
<div className="admin-matrix-matrix__skill-path muted">
@ -602,6 +673,17 @@ export default function MaturityModelsAdminPanel() {
{ms.skill_subcategory_name ? ` ${ms.skill_subcategory_name}` : ''}
</div>
) : null}
</div>
<button
type="button"
className="btn btn-ghost btn-tiny"
title="Karate-Relevanz und Relevanzgrad bearbeiten"
disabled={saving || skillWikiLoading}
onClick={() => openSkillWikiModal(ms.skill_id, ms.skill_name)}
>
</button>
</div>
</td>
{(detail.levels || []).map((l) => {
const key = `${ms.skill_id}-${l.level_number}`
@ -643,6 +725,90 @@ export default function MaturityModelsAdminPanel() {
)}
</div>
</div>
{skillWikiModal ? (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={closeSkillWikiModal}
>
<div
className="admin-modal-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="matrix-skill-wiki-title"
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="matrix-skill-wiki-title" className="admin-modal-sheet__title">
Fähigkeit: {skillWikiModal.skill_name}
</h3>
<button
type="button"
className="btn btn-secondary admin-modal-sheet__close"
disabled={skillWikiLoading}
onClick={closeSkillWikiModal}
aria-label="Schließen"
>
</button>
</div>
<div className="admin-modal-sheet__body">
{skillWikiLoading ? (
<p className="muted">Lade Daten</p>
) : (
<form className="skills-catalog-form" onSubmit={handleSaveSkillWikiFields}>
<label className="form-label">Karate-Relevanz (Wiki)</label>
<textarea
className="form-input"
rows={4}
value={skillWikiForm.karate_relevance}
onChange={(e) =>
setSkillWikiForm((f) => ({ ...f, karate_relevance: e.target.value }))
}
disabled={skillWikiLoading}
placeholder="Freitext (Wiki-Eigenschaft KarateRelevanz)"
/>
<label className="form-label">Relevanzgrad (Wiki, 13)</label>
<select
className="form-input"
value={
skillWikiForm.relevance_level === ''
? ''
: String(skillWikiForm.relevance_level)
}
onChange={(e) =>
setSkillWikiForm((f) => ({ ...f, relevance_level: e.target.value }))
}
disabled={skillWikiLoading}
>
<option value=""> nicht gesetzt </option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
<p className="muted admin-matrix-hint">
Änderungen gelten für die Fähigkeit im gesamten Katalog (gemeinsame Stammdaten).
</p>
<div className="skills-catalog-form__actions">
<button type="submit" className="btn btn-primary" disabled={skillWikiLoading}>
Speichern
</button>
<button
type="button"
className="btn btn-secondary"
disabled={skillWikiLoading}
onClick={closeSkillWikiModal}
>
Abbrechen
</button>
</div>
</form>
)}
</div>
</div>
</div>
) : null}
</div>
)
}

View File

@ -71,7 +71,9 @@ export default function SkillsCatalogAdmin() {
keywords: '',
status: 'active',
sort_order: '',
category_id: ''
category_id: '',
karate_relevance: '',
relevance_level: ''
})
const [newMainName, setNewMainName] = useState('')
@ -176,7 +178,12 @@ export default function SkillsCatalogAdmin() {
keywords: s.keywords || '',
status: s.status || 'active',
sort_order: s.sort_order ?? '',
category_id: s.category_id ?? ''
category_id: s.category_id ?? '',
karate_relevance: s.karate_relevance || '',
relevance_level:
s.relevance_level != null && s.relevance_level !== ''
? String(s.relevance_level)
: ''
})
setEditDialog({ type: 'skill', id: s.id })
}
@ -304,7 +311,15 @@ export default function SkillsCatalogAdmin() {
skillForm.sort_order === '' || skillForm.sort_order == null
? null
: Number(skillForm.sort_order),
category_id: cid
category_id: cid,
karate_relevance:
typeof skillForm.karate_relevance === 'string' && skillForm.karate_relevance.trim()
? skillForm.karate_relevance.trim()
: null,
relevance_level:
skillForm.relevance_level === '' || skillForm.relevance_level == null
? null
: Number(skillForm.relevance_level)
})
})
if (ok) {
@ -922,6 +937,31 @@ export default function SkillsCatalogAdmin() {
onChange={(e) => setSkillForm((f) => ({ ...f, description: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Karate-Relevanz (Wiki)</label>
<textarea
className="form-input"
rows={3}
value={skillForm.karate_relevance}
onChange={(e) =>
setSkillForm((f) => ({ ...f, karate_relevance: e.target.value }))
}
disabled={busy}
placeholder="Freitext aus Wiki / eigene Erläuterung"
/>
<label className="form-label">Relevanzgrad (Wiki, 13)</label>
<select
className="form-input"
value={skillForm.relevance_level === '' ? '' : String(skillForm.relevance_level)}
onChange={(e) =>
setSkillForm((f) => ({ ...f, relevance_level: e.target.value }))
}
disabled={busy}
>
<option value=""> nicht gesetzt </option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
<label className="form-label">Sortierung (optional)</label>
<input
className="form-input"

View File

@ -10,6 +10,13 @@ const WIKI_IMPORT_TABS = [
{ id: 'history', label: 'Historie', icon: History },
]
/** MediaWiki-Kategorienamen ohne Präfix „Kategorie:“ — karatetrainer.net aktueller Stand */
function defaultWikiCategory(importType) {
if (importType === 'skill') return 'Fähigkeitsbeschreibung'
if (importType === 'method') return 'Methodenbeschreibung'
return 'Übungen'
}
export default function MediaWikiImportPage() {
const [activeTab, setActiveTab] = useState('preview')
const [loading, setLoading] = useState(false)
@ -169,7 +176,11 @@ export default function MediaWikiImportPage() {
</label>
<select
value={previewType}
onChange={(e) => setPreviewType(e.target.value)}
onChange={(e) => {
const t = e.target.value
setPreviewType(t)
setPreviewCategory(defaultWikiCategory(t))
}}
style={{
width: '100%',
padding: '12px',
@ -315,7 +326,11 @@ export default function MediaWikiImportPage() {
</label>
<select
value={executeType}
onChange={(e) => setExecuteType(e.target.value)}
onChange={(e) => {
const t = e.target.value
setExecuteType(t)
setExecuteCategory(defaultWikiCategory(t))
}}
style={{
width: '100%',
padding: '12px',

View File

@ -52,7 +52,9 @@ function SkillsPage() {
description: '',
importance: 3,
keywords: [],
status: 'active'
status: 'active',
karate_relevance: '',
relevance_level: ''
})
} else {
setFormData({
@ -102,10 +104,20 @@ function SkillsPage() {
try {
if (modalType === 'skill') {
const raw = { ...formData }
raw.karate_relevance =
typeof raw.karate_relevance === 'string' && raw.karate_relevance.trim()
? raw.karate_relevance.trim()
: null
raw.relevance_level =
raw.relevance_level === '' || raw.relevance_level == null || raw.relevance_level === undefined
? null
: Number(raw.relevance_level)
if (editing) {
await api.updateSkill(editing.id, formData)
await api.updateSkill(editing.id, raw)
} else {
await api.createSkill(formData)
await api.createSkill(raw)
}
} else {
if (editing) {
@ -394,6 +406,41 @@ function SkillsPage() {
/>
</div>
{modalType === 'skill' && (
<>
<div className="form-row">
<label className="form-label">Karate-Relevanz (Wiki)</label>
<textarea
className="form-input"
rows={3}
value={formData.karate_relevance || ''}
onChange={(e) => updateFormField('karate_relevance', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Relevanzgrad (Wiki, 13)</label>
<select
className="form-input"
value={
formData.relevance_level === null ||
formData.relevance_level === undefined ||
formData.relevance_level === ''
? ''
: String(formData.relevance_level)
}
onChange={(e) =>
updateFormField('relevance_level', e.target.value === '' ? '' : e.target.value)
}
>
<option value=""> nicht gesetzt </option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
</div>
</>
)}
{modalType === 'skill' && (
<div className="form-row">
<label className="form-label">Wichtigkeit (1-5)</label>