2026-04-24 14:41:52 +02:00
|
|
|
|
"""
|
|
|
|
|
|
MediaWiki Import Router
|
|
|
|
|
|
|
|
|
|
|
|
Importiert Übungen, Fähigkeiten und Methoden aus Semantic MediaWiki
|
|
|
|
|
|
direkt via API (kein XML-Export). Nur Super-Admin darf importieren.
|
|
|
|
|
|
|
|
|
|
|
|
Wiki-URL: https://karatetrainer.net/api.php
|
|
|
|
|
|
"""
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import os
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks, Query
|
|
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
|
|
|
|
|
|
from db import get_db, get_cursor, r2d
|
|
|
|
|
|
from auth import require_auth
|
|
|
|
|
|
from smw_client import SmwClient, SmwClientError
|
|
|
|
|
|
from smw_mapper import map_wiki_to_exercise, map_wiki_to_skill, map_wiki_to_method, build_skill_assignments
|
2026-05-21 12:35:45 +02:00
|
|
|
|
from routers.exercises import normalize_exercise_skill_intensity
|
2026-04-24 14:41:52 +02:00
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api", tags=["import"])
|
|
|
|
|
|
|
|
|
|
|
|
# Kategorie-Namen aus .env (echte Namen von karatetrainer.net)
|
|
|
|
|
|
CATEGORY_EXERCISES = os.getenv("MEDIAWIKI_CATEGORY_EXERCISES", "Übungen")
|
|
|
|
|
|
CATEGORY_SKILLS = os.getenv("MEDIAWIKI_CATEGORY_SKILLS", "Fähigkeitsbeschreibung")
|
|
|
|
|
|
CATEGORY_METHODS = os.getenv("MEDIAWIKI_CATEGORY_METHODS", "Methodenbeschreibung")
|
|
|
|
|
|
CATEGORY_MODELS = os.getenv("MEDIAWIKI_CATEGORY_MODELS", "Reifegradmodelle")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-16 11:05:15 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-24 14:41:52 +02:00
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# Pydantic Models #
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
|
|
|
|
|
|
class ImportExecuteRequest(BaseModel):
|
|
|
|
|
|
category: str
|
|
|
|
|
|
import_type: str = "exercise" # exercise | skill | method
|
|
|
|
|
|
reimport_existing: bool = False
|
|
|
|
|
|
dry_run: bool = False
|
|
|
|
|
|
limit: Optional[int] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# Auth-Hilfsfunktion #
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
|
|
|
|
|
|
def require_admin(session: dict = Depends(require_auth)) -> dict:
|
|
|
|
|
|
"""Nur Super-Admins dürfen importieren."""
|
|
|
|
|
|
if session.get("role") not in ("admin", "superadmin"):
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="Nur Admins dürfen den Import ausführen")
|
|
|
|
|
|
return session
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# Preview Endpoint #
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/import/mediawiki/preview")
|
|
|
|
|
|
async def preview_import(
|
2026-05-16 11:05:15 +02:00
|
|
|
|
category: Optional[str] = Query(default=None),
|
2026-04-24 14:41:52 +02:00
|
|
|
|
import_type: str = Query(default="exercise"),
|
2026-04-24 17:15:17 +02:00
|
|
|
|
limit: int = Query(default=10, ge=1, le=500),
|
2026-04-24 14:41:52 +02:00
|
|
|
|
session: dict = Depends(require_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Zeigt Vorschau: Welche Seiten würden importiert werden?
|
|
|
|
|
|
Überprüft Duplikate und mapped Felder ohne zu speichern.
|
|
|
|
|
|
"""
|
2026-05-16 11:05:15 +02:00
|
|
|
|
resolved_category = _wiki_category_or_default(category, import_type)
|
2026-04-24 14:41:52 +02:00
|
|
|
|
client = SmwClient()
|
|
|
|
|
|
try:
|
2026-05-16 11:05:15 +02:00
|
|
|
|
members = await client.get_category_members(resolved_category, limit=limit)
|
2026-04-24 14:41:52 +02:00
|
|
|
|
except SmwClientError as e:
|
|
|
|
|
|
raise HTTPException(status_code=502, detail=f"Wiki-API nicht erreichbar: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
preview = []
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
|
|
|
|
|
|
for member in members[:limit]:
|
|
|
|
|
|
page_title = member["title"]
|
|
|
|
|
|
page_id = member.get("pageid")
|
|
|
|
|
|
|
|
|
|
|
|
# Duplikat-Check
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"SELECT id, last_imported FROM wiki_import_references WHERE wiki_page_title = %s AND content_type = %s",
|
|
|
|
|
|
(page_title, import_type)
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_ref = r2d(cur.fetchone())
|
|
|
|
|
|
|
|
|
|
|
|
# SMW Properties abrufen
|
|
|
|
|
|
mapped_fields = {}
|
|
|
|
|
|
warnings = []
|
|
|
|
|
|
errors = []
|
|
|
|
|
|
try:
|
|
|
|
|
|
smw_props = await client.browse_subject(page_title)
|
|
|
|
|
|
if import_type == "exercise":
|
|
|
|
|
|
mapped = map_wiki_to_exercise(page_title, page_id, smw_props)
|
|
|
|
|
|
elif import_type == "skill":
|
|
|
|
|
|
mapped = map_wiki_to_skill(page_title, page_id, smw_props)
|
|
|
|
|
|
else:
|
|
|
|
|
|
mapped = map_wiki_to_method(page_title, page_id, smw_props)
|
|
|
|
|
|
|
|
|
|
|
|
warnings = mapped.pop("warnings", [])
|
|
|
|
|
|
# Entferne interne Felder aus der Vorschau
|
|
|
|
|
|
for k in ("wiki_page_id", "import_source", "import_id"):
|
|
|
|
|
|
mapped.pop(k, None)
|
|
|
|
|
|
mapped_fields = mapped
|
|
|
|
|
|
|
|
|
|
|
|
# Warnung wenn Pflichtfelder fehlen (nur bei Übungen)
|
|
|
|
|
|
if import_type == "exercise":
|
|
|
|
|
|
if not mapped.get("goal") and not mapped.get("execution"):
|
|
|
|
|
|
errors.append("Pflichtfelder 'Ziel' und 'Durchführung' fehlen")
|
|
|
|
|
|
|
|
|
|
|
|
except SmwClientError as e:
|
|
|
|
|
|
errors.append(f"Seite nicht abrufbar: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
preview.append({
|
|
|
|
|
|
"wiki_page_title": page_title,
|
|
|
|
|
|
"wiki_page_id": page_id,
|
|
|
|
|
|
"already_imported": existing_ref is not None,
|
2026-04-29 13:50:54 +02:00
|
|
|
|
"last_imported_at": existing_ref["last_imported"].isoformat()
|
|
|
|
|
|
if existing_ref and existing_ref.get("last_imported") is not None
|
|
|
|
|
|
else None,
|
2026-04-24 14:41:52 +02:00
|
|
|
|
"mapped_fields": mapped_fields,
|
|
|
|
|
|
"warnings": warnings,
|
|
|
|
|
|
"errors": errors,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
2026-05-16 11:05:15 +02:00
|
|
|
|
"category": resolved_category,
|
2026-04-24 14:41:52 +02:00
|
|
|
|
"import_type": import_type,
|
|
|
|
|
|
"total_found": len(members),
|
|
|
|
|
|
"preview": preview,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# Execute Import (async Background Task) #
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/import/mediawiki/execute", status_code=202)
|
|
|
|
|
|
async def execute_import(
|
|
|
|
|
|
body: ImportExecuteRequest,
|
|
|
|
|
|
background_tasks: BackgroundTasks,
|
|
|
|
|
|
session: dict = Depends(require_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Startet einen MediaWiki-Import als Background-Task.
|
|
|
|
|
|
Gibt sofort log_id zurück – Status via GET /import/mediawiki/status/{log_id}.
|
|
|
|
|
|
"""
|
|
|
|
|
|
profile_id = session["profile_id"]
|
2026-05-16 11:05:15 +02:00
|
|
|
|
resolved_category = _wiki_category_or_default(body.category, body.import_type)
|
2026-04-24 14:41:52 +02:00
|
|
|
|
|
|
|
|
|
|
# Log-Eintrag anlegen
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""INSERT INTO wiki_import_log
|
|
|
|
|
|
(import_type, import_status, category, dry_run, reimport, imported_by)
|
|
|
|
|
|
VALUES (%s, 'running', %s, %s, %s, %s)
|
|
|
|
|
|
RETURNING id""",
|
2026-05-16 11:05:15 +02:00
|
|
|
|
(body.import_type, resolved_category, body.dry_run, body.reimport_existing, profile_id)
|
2026-04-24 14:41:52 +02:00
|
|
|
|
)
|
2026-04-24 20:37:31 +02:00
|
|
|
|
log_id = cur.fetchone()['id']
|
2026-04-24 14:41:52 +02:00
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# Import asynchron starten
|
|
|
|
|
|
background_tasks.add_task(
|
|
|
|
|
|
_run_import,
|
|
|
|
|
|
log_id=log_id,
|
2026-05-16 11:05:15 +02:00
|
|
|
|
category=resolved_category,
|
2026-04-24 14:41:52 +02:00
|
|
|
|
import_type=body.import_type,
|
|
|
|
|
|
reimport=body.reimport_existing,
|
|
|
|
|
|
dry_run=body.dry_run,
|
|
|
|
|
|
limit=body.limit,
|
2026-04-27 09:52:18 +02:00
|
|
|
|
imported_by=profile_id,
|
2026-04-24 14:41:52 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"log_id": log_id,
|
|
|
|
|
|
"status": "running",
|
|
|
|
|
|
"message": f"Import gestartet. Status: GET /api/import/mediawiki/status/{log_id}",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# Status & Logs #
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/import/mediawiki/status/{log_id}")
|
|
|
|
|
|
def get_import_status(
|
|
|
|
|
|
log_id: int,
|
|
|
|
|
|
session: dict = Depends(require_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Status eines laufenden oder abgeschlossenen Imports."""
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
cur.execute("SELECT * FROM wiki_import_log WHERE id = %s", (log_id,))
|
|
|
|
|
|
row = r2d(cur.fetchone())
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Import-Log nicht gefunden")
|
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/import/mediawiki/logs")
|
|
|
|
|
|
def list_import_logs(
|
|
|
|
|
|
session: dict = Depends(require_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Alle Import-Logs (neueste zuerst, ohne error_log Details)."""
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""SELECT id, import_type, import_status, category, dry_run, reimport,
|
|
|
|
|
|
items_total, items_imported, items_skipped, items_failed,
|
|
|
|
|
|
imported_by, started_at, finished_at
|
|
|
|
|
|
FROM wiki_import_log
|
|
|
|
|
|
ORDER BY started_at DESC
|
|
|
|
|
|
LIMIT 50"""
|
|
|
|
|
|
)
|
|
|
|
|
|
rows = cur.fetchall()
|
|
|
|
|
|
return [r2d(r) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/import/mediawiki/references/{ref_id}", status_code=204)
|
|
|
|
|
|
def delete_import_reference(
|
|
|
|
|
|
ref_id: int,
|
|
|
|
|
|
session: dict = Depends(require_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Löscht eine Import-Referenz, damit das Item bei nächstem Import
|
|
|
|
|
|
als neu behandelt wird (Re-Import ermöglichen).
|
|
|
|
|
|
"""
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
cur.execute("DELETE FROM wiki_import_references WHERE id = %s RETURNING id", (ref_id,))
|
|
|
|
|
|
deleted = cur.fetchone()
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
if not deleted:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Referenz nicht gefunden")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# Schema-Discovery (Debug-Hilfsmittel für Admins) #
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/import/mediawiki/discover/{page_title:path}")
|
|
|
|
|
|
async def discover_properties(
|
|
|
|
|
|
page_title: str,
|
|
|
|
|
|
session: dict = Depends(require_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Gibt alle SMW-Properties einer Wiki-Seite zurück.
|
|
|
|
|
|
Nützlich um Property-Namen zu ermitteln und smw_mapper.py anzupassen.
|
|
|
|
|
|
"""
|
|
|
|
|
|
client = SmwClient()
|
|
|
|
|
|
try:
|
|
|
|
|
|
props = await client.discover_properties(page_title)
|
|
|
|
|
|
except SmwClientError as e:
|
|
|
|
|
|
raise HTTPException(status_code=502, detail=f"Wiki-API Fehler: {e}")
|
|
|
|
|
|
return {"page_title": page_title, "properties": props}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# Background Import Worker #
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
|
|
|
|
|
|
async def _run_import(
|
|
|
|
|
|
log_id: int,
|
|
|
|
|
|
category: str,
|
|
|
|
|
|
import_type: str,
|
|
|
|
|
|
reimport: bool,
|
|
|
|
|
|
dry_run: bool,
|
|
|
|
|
|
limit: Optional[int],
|
2026-04-27 09:52:18 +02:00
|
|
|
|
imported_by: int,
|
2026-04-24 14:41:52 +02:00
|
|
|
|
):
|
|
|
|
|
|
"""Hintergrund-Task: Importiert alle Seiten einer Kategorie."""
|
|
|
|
|
|
client = SmwClient()
|
|
|
|
|
|
stats = {"total": 0, "imported": 0, "skipped": 0, "failed": 0}
|
|
|
|
|
|
errors = []
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
members = await client.get_category_members(category, limit=limit or 500)
|
|
|
|
|
|
stats["total"] = len(members)
|
|
|
|
|
|
_update_log(log_id, import_status="running", items_total=stats["total"])
|
|
|
|
|
|
|
|
|
|
|
|
for member in members:
|
|
|
|
|
|
page_title = member["title"]
|
|
|
|
|
|
page_id = member.get("pageid")
|
|
|
|
|
|
|
2026-04-27 08:45:27 +02:00
|
|
|
|
# Duplikat-Check: Prüfe ob bereits importiert UND ob tatsächlich existiert
|
2026-04-24 14:41:52 +02:00
|
|
|
|
if not reimport:
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
cur.execute(
|
2026-04-27 08:45:27 +02:00
|
|
|
|
"SELECT local_id FROM wiki_import_references WHERE wiki_page_title = %s AND content_type = %s",
|
2026-04-24 14:41:52 +02:00
|
|
|
|
(page_title, import_type)
|
|
|
|
|
|
)
|
2026-04-27 08:45:27 +02:00
|
|
|
|
ref = cur.fetchone()
|
|
|
|
|
|
if ref:
|
|
|
|
|
|
# Prüfe ob die Übung tatsächlich existiert
|
|
|
|
|
|
if import_type == "exercise":
|
|
|
|
|
|
cur.execute("SELECT id FROM exercises WHERE id = %s", (ref['local_id'],))
|
|
|
|
|
|
elif import_type == "skill":
|
|
|
|
|
|
cur.execute("SELECT id FROM skills WHERE id = %s", (ref['local_id'],))
|
|
|
|
|
|
else:
|
|
|
|
|
|
cur.execute("SELECT id FROM training_methods WHERE id = %s", (ref['local_id'],))
|
|
|
|
|
|
|
|
|
|
|
|
if cur.fetchone():
|
|
|
|
|
|
# Referenz UND Übung existieren → Skip
|
|
|
|
|
|
stats["skipped"] += 1
|
|
|
|
|
|
_update_log(log_id, **stats)
|
|
|
|
|
|
continue
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Referenz existiert, aber Übung fehlt → Lösche fehlerhafte Referenz
|
|
|
|
|
|
logger.warning("Orphaned reference found for '%s', re-importing", page_title)
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"DELETE FROM wiki_import_references WHERE wiki_page_title = %s AND content_type = %s",
|
|
|
|
|
|
(page_title, import_type)
|
|
|
|
|
|
)
|
|
|
|
|
|
conn.commit()
|
2026-04-24 14:41:52 +02:00
|
|
|
|
|
|
|
|
|
|
# SMW Properties abrufen (mit Retry)
|
|
|
|
|
|
try:
|
|
|
|
|
|
smw_props = await _fetch_with_retry(client, page_title)
|
|
|
|
|
|
except SmwClientError as e:
|
|
|
|
|
|
errors.append({"item": page_title, "error": str(e)})
|
|
|
|
|
|
stats["failed"] += 1
|
|
|
|
|
|
_update_log(log_id, **stats, error_log=errors)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Field-Mapping
|
|
|
|
|
|
if import_type == "exercise":
|
|
|
|
|
|
mapped = map_wiki_to_exercise(page_title, page_id, smw_props)
|
|
|
|
|
|
required_ok = bool(mapped.get("goal") or mapped.get("execution"))
|
|
|
|
|
|
elif import_type == "skill":
|
|
|
|
|
|
mapped = map_wiki_to_skill(page_title, page_id, smw_props)
|
|
|
|
|
|
required_ok = True
|
|
|
|
|
|
else:
|
|
|
|
|
|
mapped = map_wiki_to_method(page_title, page_id, smw_props)
|
|
|
|
|
|
required_ok = True
|
|
|
|
|
|
|
|
|
|
|
|
if not required_ok:
|
|
|
|
|
|
errors.append({"item": page_title, "error": "Pflichtfelder fehlen (Ziel + Durchführung)"})
|
|
|
|
|
|
stats["failed"] += 1
|
|
|
|
|
|
_update_log(log_id, **stats, error_log=errors)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if dry_run:
|
|
|
|
|
|
stats["imported"] += 1
|
|
|
|
|
|
_update_log(log_id, **stats)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Speichern
|
|
|
|
|
|
try:
|
|
|
|
|
|
if import_type == "exercise":
|
2026-04-27 09:52:18 +02:00
|
|
|
|
local_id = _upsert_exercise(mapped, reimport, imported_by)
|
2026-04-24 14:41:52 +02:00
|
|
|
|
elif import_type == "skill":
|
|
|
|
|
|
local_id = _upsert_skill(mapped, reimport)
|
|
|
|
|
|
else:
|
|
|
|
|
|
local_id = _upsert_method(mapped, reimport)
|
|
|
|
|
|
|
|
|
|
|
|
if local_id:
|
|
|
|
|
|
_upsert_wiki_ref(page_title, page_id, import_type, local_id)
|
|
|
|
|
|
stats["imported"] += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
stats["skipped"] += 1
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.exception("Fehler beim Speichern von '%s'", page_title)
|
|
|
|
|
|
errors.append({"item": page_title, "error": str(e)})
|
|
|
|
|
|
stats["failed"] += 1
|
|
|
|
|
|
|
|
|
|
|
|
_update_log(log_id, **stats, error_log=errors)
|
|
|
|
|
|
|
|
|
|
|
|
# Abschluss
|
|
|
|
|
|
_update_log(
|
|
|
|
|
|
log_id,
|
|
|
|
|
|
import_status="completed",
|
|
|
|
|
|
error_log=errors,
|
|
|
|
|
|
finished=True,
|
|
|
|
|
|
**stats,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.exception("Import-Prozess fehlgeschlagen (log_id=%s)", log_id)
|
|
|
|
|
|
_update_log(log_id, import_status="failed", error_log=[{"item": "global", "error": str(e)}], finished=True, **stats)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _fetch_with_retry(client: SmwClient, page_title: str, retries: int = 3) -> dict:
|
|
|
|
|
|
"""Ruft SMW-Properties mit bis zu 3 Versuchen ab."""
|
|
|
|
|
|
for attempt in range(retries):
|
|
|
|
|
|
try:
|
|
|
|
|
|
return await client.browse_subject(page_title)
|
|
|
|
|
|
except SmwClientError:
|
|
|
|
|
|
if attempt == retries - 1:
|
|
|
|
|
|
raise
|
|
|
|
|
|
await asyncio.sleep(2 ** attempt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# DB-Helper #
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
|
|
|
|
|
|
def _update_log(
|
|
|
|
|
|
log_id: int,
|
|
|
|
|
|
import_status: Optional[str] = None,
|
|
|
|
|
|
items_total: int = 0,
|
|
|
|
|
|
total: int = 0,
|
|
|
|
|
|
imported: int = 0,
|
|
|
|
|
|
skipped: int = 0,
|
|
|
|
|
|
failed: int = 0,
|
|
|
|
|
|
error_log: Optional[list] = None,
|
|
|
|
|
|
finished: bool = False,
|
|
|
|
|
|
):
|
|
|
|
|
|
import json as _json
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
fields = []
|
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
|
|
if import_status:
|
|
|
|
|
|
fields.append("import_status = %s")
|
|
|
|
|
|
params.append(import_status)
|
|
|
|
|
|
|
|
|
|
|
|
total_val = items_total or total
|
|
|
|
|
|
fields += [
|
|
|
|
|
|
"items_total = %s",
|
|
|
|
|
|
"items_imported = %s",
|
|
|
|
|
|
"items_skipped = %s",
|
|
|
|
|
|
"items_failed = %s",
|
|
|
|
|
|
]
|
|
|
|
|
|
params += [total_val, imported, skipped, failed]
|
|
|
|
|
|
|
|
|
|
|
|
if error_log is not None:
|
|
|
|
|
|
fields.append("error_log = %s")
|
|
|
|
|
|
params.append(_json.dumps(error_log))
|
|
|
|
|
|
|
|
|
|
|
|
if finished:
|
|
|
|
|
|
fields.append("finished_at = NOW()")
|
|
|
|
|
|
|
|
|
|
|
|
fields.append("updated_at = NOW()")
|
|
|
|
|
|
params.append(log_id)
|
|
|
|
|
|
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
f"UPDATE wiki_import_log SET {', '.join(fields)} WHERE id = %s",
|
|
|
|
|
|
params
|
|
|
|
|
|
)
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-27 09:52:18 +02:00
|
|
|
|
def _upsert_exercise(mapped: dict, reimport: bool, created_by: int) -> Optional[int]:
|
2026-04-24 14:41:52 +02:00
|
|
|
|
"""Legt Übung an oder aktualisiert sie (wenn reimport=True)."""
|
|
|
|
|
|
import json as _json
|
|
|
|
|
|
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
|
|
|
|
|
|
# Existiert die Übung bereits (über import_id)?
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"SELECT id FROM exercises WHERE import_id = %s AND import_source = 'mediawiki'",
|
|
|
|
|
|
(mapped["import_id"],)
|
|
|
|
|
|
)
|
|
|
|
|
|
existing = cur.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
equipment = _json.dumps(mapped.get("equipment", [])) if mapped.get("equipment") else None
|
|
|
|
|
|
|
|
|
|
|
|
if existing and reimport:
|
2026-04-27 08:52:05 +02:00
|
|
|
|
ex_id = existing['id']
|
2026-04-24 14:41:52 +02:00
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""UPDATE exercises SET
|
|
|
|
|
|
title = %s, summary = %s, goal = %s, execution = %s,
|
|
|
|
|
|
preparation = %s, trainer_notes = %s,
|
|
|
|
|
|
duration_min = %s, duration_max = %s,
|
|
|
|
|
|
group_size_min = %s, group_size_max = %s,
|
|
|
|
|
|
equipment = %s, updated_at = NOW()
|
|
|
|
|
|
WHERE id = %s""",
|
|
|
|
|
|
(
|
|
|
|
|
|
mapped.get("title"), mapped.get("summary"), mapped.get("goal"),
|
|
|
|
|
|
mapped.get("execution"), mapped.get("preparation"), mapped.get("trainer_notes"),
|
|
|
|
|
|
mapped.get("duration_min"), mapped.get("duration_max"),
|
|
|
|
|
|
mapped.get("group_size_min"), mapped.get("group_size_max"),
|
|
|
|
|
|
equipment, ex_id
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
elif existing and not reimport:
|
|
|
|
|
|
return None # Übersprungen
|
|
|
|
|
|
else:
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""INSERT INTO exercises
|
|
|
|
|
|
(title, summary, goal, execution, preparation, trainer_notes,
|
|
|
|
|
|
duration_min, duration_max, group_size_min, group_size_max,
|
2026-04-27 09:52:18 +02:00
|
|
|
|
equipment, visibility, status, import_source, import_id, created_by)
|
|
|
|
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
2026-04-24 14:41:52 +02:00
|
|
|
|
RETURNING id""",
|
|
|
|
|
|
(
|
|
|
|
|
|
mapped.get("title"), mapped.get("summary"), mapped.get("goal"),
|
|
|
|
|
|
mapped.get("execution"), mapped.get("preparation"), mapped.get("trainer_notes"),
|
|
|
|
|
|
mapped.get("duration_min"), mapped.get("duration_max"),
|
|
|
|
|
|
mapped.get("group_size_min"), mapped.get("group_size_max"),
|
|
|
|
|
|
equipment,
|
|
|
|
|
|
mapped.get("visibility", "private"),
|
|
|
|
|
|
mapped.get("status", "draft"),
|
|
|
|
|
|
"mediawiki",
|
|
|
|
|
|
mapped.get("import_id"),
|
2026-04-27 09:52:18 +02:00
|
|
|
|
created_by,
|
2026-04-24 14:41:52 +02:00
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-04-24 20:37:31 +02:00
|
|
|
|
ex_id = cur.fetchone()['id']
|
2026-04-24 14:41:52 +02:00
|
|
|
|
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# Katalog-Zuordnungen (Focus Areas, Skills, etc.)
|
|
|
|
|
|
_assign_exercise_catalogs(cur, conn, ex_id, mapped)
|
|
|
|
|
|
|
|
|
|
|
|
# Skill-Zuordnungen mit Levels
|
|
|
|
|
|
skill_assignments = build_skill_assignments(mapped)
|
|
|
|
|
|
_assign_exercise_skills(cur, conn, ex_id, skill_assignments)
|
|
|
|
|
|
|
|
|
|
|
|
return ex_id
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-28 07:25:33 +02:00
|
|
|
|
def _find_skill_id_by_label(cur, label: str) -> Optional[int]:
|
|
|
|
|
|
"""Katalog-Skill anhand Wiki-Label; exakt, ohne Leerzeichen, zuletzt Teilstring."""
|
|
|
|
|
|
if not label or not str(label).strip():
|
|
|
|
|
|
return None
|
|
|
|
|
|
raw = str(label).strip()
|
|
|
|
|
|
cur.execute("SELECT id FROM skills WHERE TRIM(name) ILIKE %s", (raw,))
|
|
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
if row:
|
|
|
|
|
|
return row["id"]
|
|
|
|
|
|
comp = "".join(raw.split())
|
|
|
|
|
|
if len(comp) >= 3:
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"SELECT id FROM skills WHERE regexp_replace(TRIM(name), E'\\s+', '', 'g') ILIKE %s",
|
|
|
|
|
|
(comp,),
|
|
|
|
|
|
)
|
|
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
if row:
|
|
|
|
|
|
return row["id"]
|
|
|
|
|
|
cur.execute("SELECT id FROM skills WHERE name ILIKE %s LIMIT 1", (f"%{raw}%",))
|
|
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
return row["id"] if row else None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-24 14:41:52 +02:00
|
|
|
|
def _assign_exercise_catalogs(cur, conn, exercise_id: int, mapped: dict):
|
|
|
|
|
|
"""Weist M:N Katalog-Zuordnungen für eine importierte Übung zu."""
|
|
|
|
|
|
|
|
|
|
|
|
# Focus Areas
|
|
|
|
|
|
for idx, name in enumerate(mapped.get("focus_area_names", [])):
|
|
|
|
|
|
cur.execute("SELECT id FROM focus_areas WHERE name ILIKE %s", (name,))
|
|
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
if row:
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""INSERT INTO exercise_focus_areas (exercise_id, focus_area_id, is_primary)
|
|
|
|
|
|
VALUES (%s, %s, %s)
|
|
|
|
|
|
ON CONFLICT (exercise_id, focus_area_id) DO NOTHING""",
|
2026-04-27 08:24:35 +02:00
|
|
|
|
(exercise_id, row['id'], idx == 0)
|
2026-04-24 14:41:52 +02:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning("Focus Area '%s' nicht im Katalog gefunden", name)
|
|
|
|
|
|
|
2026-04-28 07:25:33 +02:00
|
|
|
|
# Stilrichtungen
|
|
|
|
|
|
for idx, name in enumerate(mapped.get("style_names", [])):
|
|
|
|
|
|
cur.execute("SELECT id FROM style_directions WHERE name ILIKE %s", (name.strip(),))
|
2026-04-24 14:41:52 +02:00
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
if row:
|
|
|
|
|
|
cur.execute(
|
2026-04-28 07:25:33 +02:00
|
|
|
|
"""INSERT INTO exercise_style_directions (exercise_id, style_direction_id, is_primary)
|
|
|
|
|
|
VALUES (%s, %s, %s) ON CONFLICT (exercise_id, style_direction_id) DO NOTHING""",
|
|
|
|
|
|
(exercise_id, row["id"], idx == 0),
|
2026-04-24 14:41:52 +02:00
|
|
|
|
)
|
2026-04-28 07:25:33 +02:00
|
|
|
|
else:
|
|
|
|
|
|
logger.warning("Stilrichtung '%s' nicht im Katalog gefunden", name)
|
2026-04-24 14:41:52 +02:00
|
|
|
|
|
|
|
|
|
|
# Target Groups
|
|
|
|
|
|
for name in mapped.get("target_group_names", []):
|
|
|
|
|
|
cur.execute("SELECT id FROM target_groups WHERE name ILIKE %s", (name,))
|
|
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
if row:
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""INSERT INTO exercise_target_groups (exercise_id, target_group_id)
|
|
|
|
|
|
VALUES (%s, %s) ON CONFLICT DO NOTHING""",
|
2026-04-27 08:24:35 +02:00
|
|
|
|
(exercise_id, row['id'])
|
2026-04-24 14:41:52 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-28 07:25:33 +02:00
|
|
|
|
# Fähigkeiten: nur über _assign_exercise_skills (Levels + is_primary), nicht doppelt hier
|
2026-04-24 14:41:52 +02:00
|
|
|
|
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assign_exercise_skills(cur, conn, exercise_id: int, skill_assignments: list):
|
|
|
|
|
|
"""Weist Skill-Zuordnungen mit Levels einer Übung zu."""
|
|
|
|
|
|
for assignment in skill_assignments:
|
2026-04-28 07:25:33 +02:00
|
|
|
|
sid = _find_skill_id_by_label(cur, assignment.get("skill_name") or "")
|
|
|
|
|
|
if not sid:
|
|
|
|
|
|
logger.warning("Skill '%s' nicht im Katalog gefunden", assignment.get("skill_name"))
|
2026-04-24 14:41:52 +02:00
|
|
|
|
continue
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""INSERT INTO exercise_skills
|
|
|
|
|
|
(exercise_id, skill_id, target_level, required_level, intensity, is_primary)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s)
|
|
|
|
|
|
ON CONFLICT (exercise_id, skill_id) DO UPDATE SET
|
|
|
|
|
|
target_level = EXCLUDED.target_level,
|
2026-05-21 12:35:45 +02:00
|
|
|
|
required_level = EXCLUDED.required_level,
|
|
|
|
|
|
intensity = EXCLUDED.intensity,
|
|
|
|
|
|
is_primary = false""",
|
2026-04-24 14:41:52 +02:00
|
|
|
|
(
|
2026-04-28 07:25:33 +02:00
|
|
|
|
exercise_id,
|
|
|
|
|
|
sid,
|
2026-04-24 14:41:52 +02:00
|
|
|
|
assignment.get("target_level"),
|
|
|
|
|
|
assignment.get("required_level"),
|
2026-05-21 12:35:45 +02:00
|
|
|
|
normalize_exercise_skill_intensity(assignment.get("intensity")),
|
|
|
|
|
|
False,
|
2026-04-24 14:41:52 +02:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-16 10:56:15 +02:00
|
|
|
|
def _upsert_skill(mapped: dict, _reimport: bool) -> Optional[int]:
|
|
|
|
|
|
"""Legt Skill an oder aktualisiert bestehenden Datensatz bei Namenskonflikt (Wiki-Reimport)."""
|
2026-04-24 14:41:52 +02:00
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
|
|
|
|
|
|
# Kategorie auflösen (optional)
|
|
|
|
|
|
category_id = None
|
|
|
|
|
|
if mapped.get("category_name"):
|
|
|
|
|
|
cur.execute("SELECT id FROM skill_categories WHERE name ILIKE %s", (mapped["category_name"],))
|
|
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
if row:
|
2026-05-16 10:56:15 +02:00
|
|
|
|
category_id = row["id"]
|
2026-04-24 14:41:52 +02:00
|
|
|
|
|
|
|
|
|
|
cur.execute(
|
2026-05-16 10:56:15 +02:00
|
|
|
|
"""INSERT INTO skills (name, description, category_id, karate_relevance, relevance_level)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s)
|
2026-04-24 14:41:52 +02:00
|
|
|
|
ON CONFLICT (name) DO UPDATE SET
|
2026-05-16 10:56:15 +02:00
|
|
|
|
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()
|
2026-04-24 14:41:52 +02:00
|
|
|
|
RETURNING id""",
|
2026-05-16 10:56:15 +02:00
|
|
|
|
(
|
|
|
|
|
|
mapped["name"],
|
|
|
|
|
|
mapped.get("description"),
|
|
|
|
|
|
category_id,
|
|
|
|
|
|
mapped.get("karate_relevance"),
|
|
|
|
|
|
mapped.get("relevance_level"),
|
|
|
|
|
|
),
|
2026-04-24 14:41:52 +02:00
|
|
|
|
)
|
2026-05-16 10:56:15 +02:00
|
|
|
|
skill_id = cur.fetchone()["id"]
|
2026-04-24 14:41:52 +02:00
|
|
|
|
conn.commit()
|
|
|
|
|
|
return skill_id
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-29 13:50:54 +02:00
|
|
|
|
def _upsert_method(mapped: dict, _reimport: bool) -> Optional[int]:
|
|
|
|
|
|
"""Legt Trainingsmethode an oder aktualisiert Beschreibung nach Namen.
|
|
|
|
|
|
|
|
|
|
|
|
training_methods.name hat keinen UNIQUE-Constraint — daher kein ON CONFLICT (name).
|
|
|
|
|
|
"""
|
|
|
|
|
|
name = (mapped.get("name") or "").strip()
|
|
|
|
|
|
desc = mapped.get("description")
|
|
|
|
|
|
if not name:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-04-24 14:41:52 +02:00
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
cur.execute(
|
2026-04-29 13:50:54 +02:00
|
|
|
|
"SELECT id FROM training_methods WHERE TRIM(name) ILIKE TRIM(%s)",
|
|
|
|
|
|
(name,),
|
|
|
|
|
|
)
|
|
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
if row:
|
|
|
|
|
|
mid = row["id"]
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"UPDATE training_methods SET description = %s, updated_at = NOW() WHERE id = %s",
|
|
|
|
|
|
(desc, mid),
|
|
|
|
|
|
)
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
return mid
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"INSERT INTO training_methods (name, description) VALUES (%s, %s) RETURNING id",
|
|
|
|
|
|
(name, desc),
|
2026-04-24 14:41:52 +02:00
|
|
|
|
)
|
2026-04-29 13:50:54 +02:00
|
|
|
|
mid = cur.fetchone()["id"]
|
2026-04-24 14:41:52 +02:00
|
|
|
|
conn.commit()
|
2026-04-29 13:50:54 +02:00
|
|
|
|
return mid
|
2026-04-24 14:41:52 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _upsert_wiki_ref(
|
|
|
|
|
|
page_title: str, page_id: Optional[int], content_type: str, local_id: int
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Legt Import-Referenz an oder aktualisiert last_imported."""
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""INSERT INTO wiki_import_references
|
|
|
|
|
|
(wiki_page_title, wiki_page_id, content_type, local_id, last_imported)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, NOW())
|
|
|
|
|
|
ON CONFLICT (wiki_page_title, content_type) DO UPDATE SET
|
|
|
|
|
|
wiki_page_id = EXCLUDED.wiki_page_id,
|
|
|
|
|
|
local_id = EXCLUDED.local_id,
|
|
|
|
|
|
last_imported = NOW()""",
|
|
|
|
|
|
(page_title, page_id, content_type, local_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
conn.commit()
|