diff --git a/backend/migrations/065_skills_wiki_karate_relevance.sql b/backend/migrations/065_skills_wiki_karate_relevance.sql new file mode 100644 index 0000000..601e56e --- /dev/null +++ b/backend/migrations/065_skills_wiki_karate_relevance.sql @@ -0,0 +1,11 @@ +-- Migration 065: Wiki-spezifische Felder fuer Fähigkeiten (KarateRelevanz, RelevanzLevel) +-- SMW karatetrainer.net; Import mappt in strukturierte Spalten statt nur Freitext in description + +ALTER TABLE skills + ADD COLUMN IF NOT EXISTS karate_relevance TEXT; + +ALTER TABLE skills + ADD COLUMN IF NOT EXISTS relevance_level SMALLINT CHECK (relevance_level IS NULL OR relevance_level BETWEEN 1 AND 3); + +COMMENT ON COLUMN skills.karate_relevance IS 'Wiki Karate-Relevanz (Plaintext aus SMW Property KarateRelevanz)'; +COMMENT ON COLUMN skills.relevance_level IS 'Wiki-RelevanzLevel 1–3 (Semantic MediaWiki)'; diff --git a/backend/models.py b/backend/models.py index c2dc7b0..a60798e 100644 --- a/backend/models.py +++ b/backend/models.py @@ -117,6 +117,8 @@ class SkillCreate(BaseModel): description: Optional[str] = None importance: Optional[int] = Field(None, ge=1, le=5) keywords: Optional[List[str]] = [] + karate_relevance: Optional[str] = None + relevance_level: Optional[int] = Field(None, ge=1, le=3) class SkillResponse(BaseModel): id: int @@ -125,6 +127,8 @@ class SkillResponse(BaseModel): description: Optional[str] importance: Optional[int] keywords: Optional[List[str]] + karate_relevance: Optional[str] = None + relevance_level: Optional[int] = None status: str created_at: datetime diff --git a/backend/routers/import_wiki.py b/backend/routers/import_wiki.py index 3a1a2fd..c8ae09e 100644 --- a/backend/routers/import_wiki.py +++ b/backend/routers/import_wiki.py @@ -615,8 +615,8 @@ def _assign_exercise_skills(cur, conn, exercise_id: int, skill_assignments: list conn.commit() -def _upsert_skill(mapped: dict, reimport: bool) -> Optional[int]: - """Legt Skill an oder überspringt bei Duplikat.""" +def _upsert_skill(mapped: dict, _reimport: bool) -> Optional[int]: + """Legt Skill an oder aktualisiert bestehenden Datensatz bei Namenskonflikt (Wiki-Reimport).""" with get_db() as conn: cur = get_cursor(conn) @@ -626,17 +626,27 @@ def _upsert_skill(mapped: dict, reimport: bool) -> Optional[int]: cur.execute("SELECT id FROM skill_categories WHERE name ILIKE %s", (mapped["category_name"],)) row = cur.fetchone() if row: - category_id = row['id'] + category_id = row["id"] cur.execute( - """INSERT INTO skills (name, description, category_id) - VALUES (%s, %s, %s) + """INSERT INTO skills (name, description, category_id, karate_relevance, relevance_level) + VALUES (%s, %s, %s, %s, %s) ON CONFLICT (name) DO UPDATE SET - description = EXCLUDED.description + description = COALESCE(EXCLUDED.description, skills.description), + category_id = COALESCE(EXCLUDED.category_id, skills.category_id), + karate_relevance = COALESCE(EXCLUDED.karate_relevance, skills.karate_relevance), + relevance_level = COALESCE(EXCLUDED.relevance_level, skills.relevance_level), + updated_at = NOW() RETURNING id""", - (mapped["name"], mapped.get("description"), category_id) + ( + mapped["name"], + mapped.get("description"), + category_id, + mapped.get("karate_relevance"), + mapped.get("relevance_level"), + ), ) - skill_id = cur.fetchone()['id'] + skill_id = cur.fetchone()["id"] conn.commit() return skill_id diff --git a/backend/routers/skills.py b/backend/routers/skills.py index acc4c66..e3ea747 100644 --- a/backend/routers/skills.py +++ b/backend/routers/skills.py @@ -181,9 +181,10 @@ def create_skill(data: dict, session=Depends(require_auth)): """ INSERT INTO skills ( name, category, description, importance, keywords, status, - category_id, main_category_id, focus_areas, sort_order + category_id, main_category_id, focus_areas, sort_order, + karate_relevance, relevance_level ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s) RETURNING id """, ( @@ -197,6 +198,8 @@ def create_skill(data: dict, session=Depends(require_auth)): main_id, focus if focus is not None else "[]", data.get("sort_order"), + data.get("karate_relevance"), + data.get("relevance_level"), ), ) @@ -232,6 +235,8 @@ def update_skill(skill_id: int, data: dict, session=Depends(require_auth)): "category_id", "main_category_id", "sort_order", + "karate_relevance", + "relevance_level", ): if key in data: sets.append(f"{key} = %s") diff --git a/backend/smw_mapper.py b/backend/smw_mapper.py index 9115061..419cff9 100644 --- a/backend/smw_mapper.py +++ b/backend/smw_mapper.py @@ -63,8 +63,8 @@ EXERCISE_PROPERTY_MAP = { # Fähigkeiten (skills) – Kategorie: Fähigkeitsbeschreibung SKILL_PROPERTY_MAP = { "Summary": "description", - "KarateRelevanz": "karate_relevance", # Wird in description ergänzt - "RelevanzLevel": "relevance_level", # 1-3, nicht direkt in skills DB + "KarateRelevanz": "karate_relevance", # Spalte skills.karate_relevance (+ Wiki-Import) + "RelevanzLevel": "relevance_level", # Spalte skills.relevance_level 1–3 } # Trainingsmethoden – Kategorie: Methodenbeschreibung @@ -172,6 +172,27 @@ def map_capability_level(level_str: str) -> str: return CAPABILITY_LEVEL_MAP.get(level_str.strip(), "basis") +def parse_wiki_relevance_level(raw: str | None) -> Optional[int]: + """ + RelevanzLevel aus Wiki (typisch 1–3). Erlaubt Zahl oder Text mit Ziffer z.B. "Level_2". + """ + if raw is None: + return None + s = str(raw).strip() + if not s: + return None + digits = re.findall(r"\d+", s) + if not digits: + return None + try: + n = int(digits[0]) + except ValueError: + return None + if n < 1 or n > 3: + return None + return n + + # ------------------------------------------------------------------ # # SMW-Property-Label → Mapper-Zielfeld (Werte wie in EXERCISE_PROPERTY_MAP) # # browse_subject liefert Anzeigenamen, nicht zwingend interne Property-IDs. # @@ -184,6 +205,23 @@ def _norm_prop_synonym(name: str) -> str: return "".join(c for c in s if c.isalnum()) +# Alternative SMW-Anzeigelabel → Zielfeld (gleiche Targets wie SKILL_PROPERTY_MAP) +SKILL_PROPERTY_SYNONYM_TO_TARGET: dict[str, str] = { + "karaterelevanz": "karate_relevance", + "karatebezug": "karate_relevance", + "relevanzlevel": "relevance_level", + "wikirelevanz": "karate_relevance", +} + + +def _skill_property_target(prop_name: str) -> str | None: + """Ermittelt Zielfeld für eine Skill-SMW-Property.""" + if prop_name in SKILL_PROPERTY_MAP: + return SKILL_PROPERTY_MAP[prop_name] + n = _norm_prop_synonym(prop_name) + return SKILL_PROPERTY_SYNONYM_TO_TARGET.get(n) + + # alternative Labels → Zielfeld-Name (gleiche Strings wie Werte in EXERCISE_PROPERTY_MAP) EXERCISE_PROPERTY_SYNONYM_TO_TARGET: dict[str, str] = { "primarycapability": "skill_names", @@ -359,24 +397,29 @@ def map_wiki_to_skill( "warnings": [], } - description_parts = [] + description_text: Optional[str] = None for prop_name, values in smw_props.items(): if not values: continue - target = SKILL_PROPERTY_MAP.get(prop_name) + target = _skill_property_target(prop_name) if not target: continue first_value = values[0] if isinstance(values, list) else values if target == "description": - description_parts.insert(0, wikitext_to_plaintext(first_value)) + description_text = wikitext_to_plaintext(first_value) elif target == "karate_relevance": - rel = wikitext_to_plaintext(first_value) - description_parts.append(f"\nKarate-Relevanz: {rel}") + mapped["karate_relevance"] = wikitext_to_plaintext(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: + mapped["warnings"].append(f"Unbekanntes RelevanzLevel: {first_value!r}") + else: + mapped["relevance_level"] = parsed - if description_parts: - mapped["description"] = "\n".join(description_parts).strip() + if description_text: + mapped["description"] = description_text return mapped diff --git a/backend/tests/test_smw_mapper_skill_wiki_fields.py b/backend/tests/test_smw_mapper_skill_wiki_fields.py new file mode 100644 index 0000000..94d989b --- /dev/null +++ b/backend/tests/test_smw_mapper_skill_wiki_fields.py @@ -0,0 +1,49 @@ +"""Tests: Wiki → Skill Mapping (KarateRelevanz, RelevanzLevel).""" + +from smw_mapper import ( + map_wiki_to_skill, + parse_wiki_relevance_level, +) + + +def test_parse_wiki_relevance_level_accepted_range(): + assert parse_wiki_relevance_level("1") == 1 + assert parse_wiki_relevance_level("3") == 3 + assert parse_wiki_relevance_level("Level_2_-_foo") == 2 + + +def test_parse_wiki_relevance_level_rejects(): + assert parse_wiki_relevance_level("") is None + assert parse_wiki_relevance_level("4") is None + assert parse_wiki_relevance_level("0") is None + assert parse_wiki_relevance_level("text") is None + + +def test_map_wiki_to_skill_karate_and_level(): + out = map_wiki_to_skill( + "Test_Skill", + 99, + { + "Summary": ["Kurztext"], + "KarateRelevanz": ["''Wichtig'' für [[Kumite]]"], + "RelevanzLevel": ["2"], + }, + ) + assert out["name"] == "Test_Skill" + assert out["description"] == "Kurztext" + assert "Kumite" in out["karate_relevance"] + assert out["relevance_level"] == 2 + assert out["warnings"] == [] + + +def test_map_wiki_to_skill_synonym_props(): + out = map_wiki_to_skill("S", None, {"relevanzlevel": ["3"], "karaterelevanz": ["Nur Karate"]}) + assert out["relevance_level"] == 3 + assert out["karate_relevance"] == "Nur Karate" + + +def test_map_wiki_to_skill_invalid_level_warning(): + out = map_wiki_to_skill("S", None, {"RelevanzLevel": ["9"]}) + assert "relevance_level" not in out + assert len(out["warnings"]) == 1 + assert "9" in out["warnings"][0] diff --git a/backend/version.py b/backend/version.py index 6e94c70..e85b733 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.142" -BUILD_DATE = "2026-05-14" -DB_SCHEMA_VERSION = "20260515064" +APP_VERSION = "0.8.143" +BUILD_DATE = "2026-05-16" +DB_SCHEMA_VERSION = "20260516065" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -19,7 +19,7 @@ MODULE_VERSIONS = { "media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold) "media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets "groups": "0.1.0", - "skills": "0.1.0", + "skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder "methods": "0.1.0", "exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break "training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak @@ -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.0", + "import_wiki": "1.0.1", # Skills: KarateRelevanz + RelevanzLevel → DB-Spalten "admin": "1.0.0", "membership": "1.0.0", "catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012) @@ -36,6 +36,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.143", + "date": "2026-05-16", + "changes": [ + "DB 065: skills.karate_relevance + skills.relevance_level (Wiki KarateRelevanz / RelevanzLevel 1–3); smw_mapper + Wiki-Import upsert.", + ], + }, { "version": "0.8.142", "date": "2026-05-16",