Enhance skill model and import functionality with karate relevance and relevance level
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m11s

- Added `karate_relevance` and `relevance_level` fields to the SkillCreate and SkillResponse models, allowing for more detailed skill attributes.
- Updated the SMW property mapping to include these new fields, facilitating their integration during data import.
- Implemented parsing logic for relevance levels from Wiki data, ensuring proper handling of values between 1 and 3.
- Modified the upsert and create skill functions to support the new fields, ensuring they are correctly stored and updated in the database.
- Incremented app version to 0.8.143 and updated changelog to reflect these changes.
This commit is contained in:
Lars 2026-05-16 10:56:15 +02:00
parent 0275f76432
commit 949a77fe38
7 changed files with 153 additions and 24 deletions

View File

@ -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 13 (Semantic MediaWiki)';

View File

@ -117,6 +117,8 @@ class SkillCreate(BaseModel):
description: Optional[str] = None description: Optional[str] = None
importance: Optional[int] = Field(None, ge=1, le=5) importance: Optional[int] = Field(None, ge=1, le=5)
keywords: Optional[List[str]] = [] keywords: Optional[List[str]] = []
karate_relevance: Optional[str] = None
relevance_level: Optional[int] = Field(None, ge=1, le=3)
class SkillResponse(BaseModel): class SkillResponse(BaseModel):
id: int id: int
@ -125,6 +127,8 @@ class SkillResponse(BaseModel):
description: Optional[str] description: Optional[str]
importance: Optional[int] importance: Optional[int]
keywords: Optional[List[str]] keywords: Optional[List[str]]
karate_relevance: Optional[str] = None
relevance_level: Optional[int] = None
status: str status: str
created_at: datetime created_at: datetime

View File

@ -615,8 +615,8 @@ def _assign_exercise_skills(cur, conn, exercise_id: int, skill_assignments: list
conn.commit() conn.commit()
def _upsert_skill(mapped: dict, reimport: bool) -> Optional[int]: def _upsert_skill(mapped: dict, _reimport: bool) -> Optional[int]:
"""Legt Skill an oder überspringt bei Duplikat.""" """Legt Skill an oder aktualisiert bestehenden Datensatz bei Namenskonflikt (Wiki-Reimport)."""
with get_db() as conn: with get_db() as conn:
cur = get_cursor(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"],)) cur.execute("SELECT id FROM skill_categories WHERE name ILIKE %s", (mapped["category_name"],))
row = cur.fetchone() row = cur.fetchone()
if row: if row:
category_id = row['id'] category_id = row["id"]
cur.execute( cur.execute(
"""INSERT INTO skills (name, description, category_id) """INSERT INTO skills (name, description, category_id, karate_relevance, relevance_level)
VALUES (%s, %s, %s) VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (name) DO UPDATE SET 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""", 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() conn.commit()
return skill_id return skill_id

View File

@ -181,9 +181,10 @@ def create_skill(data: dict, session=Depends(require_auth)):
""" """
INSERT INTO skills ( INSERT INTO skills (
name, category, description, importance, keywords, status, 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 RETURNING id
""", """,
( (
@ -197,6 +198,8 @@ def create_skill(data: dict, session=Depends(require_auth)):
main_id, main_id,
focus if focus is not None else "[]", focus if focus is not None else "[]",
data.get("sort_order"), 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", "category_id",
"main_category_id", "main_category_id",
"sort_order", "sort_order",
"karate_relevance",
"relevance_level",
): ):
if key in data: if key in data:
sets.append(f"{key} = %s") sets.append(f"{key} = %s")

View File

@ -63,8 +63,8 @@ EXERCISE_PROPERTY_MAP = {
# Fähigkeiten (skills) Kategorie: Fähigkeitsbeschreibung # Fähigkeiten (skills) Kategorie: Fähigkeitsbeschreibung
SKILL_PROPERTY_MAP = { SKILL_PROPERTY_MAP = {
"Summary": "description", "Summary": "description",
"KarateRelevanz": "karate_relevance", # Wird in description ergänzt "KarateRelevanz": "karate_relevance", # Spalte skills.karate_relevance (+ Wiki-Import)
"RelevanzLevel": "relevance_level", # 1-3, nicht direkt in skills DB "RelevanzLevel": "relevance_level", # Spalte skills.relevance_level 13
} }
# Trainingsmethoden Kategorie: Methodenbeschreibung # Trainingsmethoden Kategorie: Methodenbeschreibung
@ -172,6 +172,27 @@ def map_capability_level(level_str: str) -> str:
return CAPABILITY_LEVEL_MAP.get(level_str.strip(), "basis") return CAPABILITY_LEVEL_MAP.get(level_str.strip(), "basis")
def parse_wiki_relevance_level(raw: str | None) -> Optional[int]:
"""
RelevanzLevel aus Wiki (typisch 13). 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) # # SMW-Property-Label → Mapper-Zielfeld (Werte wie in EXERCISE_PROPERTY_MAP) #
# browse_subject liefert Anzeigenamen, nicht zwingend interne Property-IDs. # # 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()) 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) # alternative Labels → Zielfeld-Name (gleiche Strings wie Werte in EXERCISE_PROPERTY_MAP)
EXERCISE_PROPERTY_SYNONYM_TO_TARGET: dict[str, str] = { EXERCISE_PROPERTY_SYNONYM_TO_TARGET: dict[str, str] = {
"primarycapability": "skill_names", "primarycapability": "skill_names",
@ -359,24 +397,29 @@ def map_wiki_to_skill(
"warnings": [], "warnings": [],
} }
description_parts = [] description_text: Optional[str] = None
for prop_name, values in smw_props.items(): for prop_name, values in smw_props.items():
if not values: if not values:
continue continue
target = SKILL_PROPERTY_MAP.get(prop_name) target = _skill_property_target(prop_name)
if not target: if not target:
continue continue
first_value = values[0] if isinstance(values, list) else values first_value = values[0] if isinstance(values, list) else values
if target == "description": if target == "description":
description_parts.insert(0, wikitext_to_plaintext(first_value)) description_text = wikitext_to_plaintext(first_value)
elif target == "karate_relevance": elif target == "karate_relevance":
rel = wikitext_to_plaintext(first_value) mapped["karate_relevance"] = wikitext_to_plaintext(first_value)
description_parts.append(f"\nKarate-Relevanz: {rel}") 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: if description_text:
mapped["description"] = "\n".join(description_parts).strip() mapped["description"] = description_text
return mapped return mapped

View File

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

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.142" APP_VERSION = "0.8.143"
BUILD_DATE = "2026-05-14" BUILD_DATE = "2026-05-16"
DB_SCHEMA_VERSION = "20260515064" DB_SCHEMA_VERSION = "20260516065"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) "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_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 "media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
"groups": "0.1.0", "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", "methods": "0.1.0",
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break "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 "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 "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) "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) "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", "admin": "1.0.0",
"membership": "1.0.0", "membership": "1.0.0",
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012) "catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
@ -36,6 +36,13 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.143",
"date": "2026-05-16",
"changes": [
"DB 065: skills.karate_relevance + skills.relevance_level (Wiki KarateRelevanz / RelevanzLevel 13); smw_mapper + Wiki-Import upsert.",
],
},
{ {
"version": "0.8.142", "version": "0.8.142",
"date": "2026-05-16", "date": "2026-05-16",