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
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:
parent
0275f76432
commit
949a77fe38
11
backend/migrations/065_skills_wiki_karate_relevance.sql
Normal file
11
backend/migrations/065_skills_wiki_karate_relevance.sql
Normal 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 1–3 (Semantic MediaWiki)';
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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 1–3
|
||||||
}
|
}
|
||||||
|
|
||||||
# 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 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) #
|
# 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
|
||||||
|
|
||||||
|
|
|
||||||
49
backend/tests/test_smw_mapper_skill_wiki_fields.py
Normal file
49
backend/tests/test_smw_mapper_skill_wiki_fields.py
Normal 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]
|
||||||
|
|
@ -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 1–3); smw_mapper + Wiki-Import upsert.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.142",
|
"version": "0.8.142",
|
||||||
"date": "2026-05-16",
|
"date": "2026-05-16",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user