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
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
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
|
||||
|
||||
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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user