630fffc0cc
- Migration 115: compliance_role_training_mapping table (org roles → training codes) - TrainingLinkService: queries training_modules/matrix/assignments to find gaps per person and role. Gracefully degrades when Go training tables don't exist yet. - document_review_routes: 2 new endpoints (training-requirements, training-gaps) - _notify_approval() now checks training gaps and sends emails to persons with outstanding modules, linking to /sdk/training/learner [migration-approved] Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
160 lines
6.9 KiB
Python
160 lines
6.9 KiB
Python
"""
|
|
Training Link Service — bridges document review approvals with the Academy.
|
|
|
|
After a document is approved, checks which roles need training on that
|
|
document type and identifies gaps (missing/overdue assignments).
|
|
|
|
Gracefully handles missing training tables (Go service not migrated yet).
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TrainingLinkService:
|
|
"""Links document approvals to training requirements."""
|
|
|
|
def __init__(self, db: Session) -> None:
|
|
self.db = db
|
|
|
|
def _training_tables_exist(self) -> bool:
|
|
"""Check if the Go-managed training tables exist."""
|
|
try:
|
|
self.db.execute(text("SELECT 1 FROM training_modules LIMIT 0"))
|
|
return True
|
|
except Exception:
|
|
self.db.rollback()
|
|
return False
|
|
|
|
def get_role_codes_for_document(self, tenant_id: str, document_type: str) -> list[dict]:
|
|
"""Map document type → org roles → training role codes."""
|
|
try:
|
|
q = text("""
|
|
SELECT m.role_key, t.training_role_code
|
|
FROM compliance_document_role_mapping m
|
|
LEFT JOIN compliance_role_training_mapping t
|
|
ON t.org_role_key = m.role_key
|
|
AND (t.tenant_id = :tid OR t.tenant_id = '__default__')
|
|
WHERE m.tenant_id = :tid OR m.tenant_id = '__default__'
|
|
AND m.document_type = :dt
|
|
""")
|
|
rows = self.db.execute(q, {"tid": tenant_id, "dt": document_type}).fetchall()
|
|
return [{"role_key": r.role_key, "training_role_code": r.training_role_code} for r in rows]
|
|
except Exception as e:
|
|
logger.warning("Failed to get role codes: %s", e)
|
|
return []
|
|
|
|
def get_training_requirements(self, tenant_id: str, document_type: str) -> dict[str, Any]:
|
|
"""Get training modules required for roles associated with a document type."""
|
|
if not self._training_tables_exist():
|
|
return {
|
|
"academy_available": False,
|
|
"message": "Academy noch nicht eingerichtet. Training-Module werden nach Aktivierung automatisch verknuepft.",
|
|
"requirements": [],
|
|
}
|
|
|
|
role_mappings = self.get_role_codes_for_document(tenant_id, document_type)
|
|
if not role_mappings:
|
|
return {"academy_available": True, "message": "Keine Rollen-Zuordnung fuer diesen Dokumenttyp.", "requirements": []}
|
|
|
|
role_codes = [r["training_role_code"] for r in role_mappings if r.get("training_role_code")]
|
|
if not role_codes:
|
|
return {"academy_available": True, "message": "Keine Training-Codes konfiguriert.", "requirements": []}
|
|
|
|
try:
|
|
placeholders = ",".join(f":rc{i}" for i in range(len(role_codes)))
|
|
params: dict[str, Any] = {"tid": tenant_id}
|
|
for i, rc in enumerate(role_codes):
|
|
params[f"rc{i}"] = rc
|
|
|
|
q = text(f"""
|
|
SELECT tm.role_code, m.module_code, m.title, m.description,
|
|
m.frequency_type, m.duration_minutes, tm.is_mandatory
|
|
FROM training_matrix tm
|
|
JOIN training_modules m ON m.id = tm.module_id
|
|
WHERE tm.tenant_id = :tid AND tm.role_code IN ({placeholders})
|
|
AND m.is_active = TRUE
|
|
ORDER BY tm.role_code, m.sort_order
|
|
""")
|
|
rows = self.db.execute(q, params).fetchall()
|
|
reqs = [dict(r._mapping) for r in rows]
|
|
return {"academy_available": True, "requirements": reqs, "total": len(reqs)}
|
|
except Exception as e:
|
|
logger.warning("Failed to query training requirements: %s", e)
|
|
return {"academy_available": True, "requirements": [], "error": str(e)}
|
|
|
|
def check_training_gaps(
|
|
self, tenant_id: str, document_type: str, project_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Check which persons assigned to roles have outstanding training."""
|
|
if not self._training_tables_exist():
|
|
return {"academy_available": False, "gaps": [], "total_gaps": 0}
|
|
|
|
role_mappings = self.get_role_codes_for_document(tenant_id, document_type)
|
|
if not role_mappings:
|
|
return {"academy_available": True, "gaps": [], "total_gaps": 0}
|
|
|
|
gaps = []
|
|
for rm in role_mappings:
|
|
role_key = rm["role_key"]
|
|
role_code = rm.get("training_role_code")
|
|
if not role_code:
|
|
continue
|
|
|
|
# Get person assigned to this role
|
|
where = "tenant_id = :tid AND role_key = :rk"
|
|
params: dict[str, Any] = {"tid": tenant_id, "rk": role_key}
|
|
if project_id:
|
|
where += " AND (project_id = :pid OR project_id IS NULL)"
|
|
params["pid"] = project_id
|
|
|
|
try:
|
|
person = self.db.execute(text(
|
|
f"SELECT person_name, person_email, role_label FROM compliance_org_roles WHERE {where} LIMIT 1"
|
|
), params).fetchone()
|
|
except Exception:
|
|
continue
|
|
|
|
if not person or not person.person_name:
|
|
continue
|
|
|
|
# Get required modules for this role code
|
|
try:
|
|
modules = self.db.execute(text("""
|
|
SELECT m.id, m.module_code, m.title FROM training_matrix tm
|
|
JOIN training_modules m ON m.id = tm.module_id
|
|
WHERE tm.tenant_id = :tid AND tm.role_code = :rc AND m.is_active = TRUE AND tm.is_mandatory = TRUE
|
|
"""), {"tid": tenant_id, "rc": role_code}).fetchall()
|
|
except Exception:
|
|
continue
|
|
|
|
for mod in modules:
|
|
# Check if assignment exists and is completed
|
|
try:
|
|
assignment = self.db.execute(text("""
|
|
SELECT status, progress_percent FROM training_assignments
|
|
WHERE tenant_id = :tid AND module_id = :mid AND user_email = :email
|
|
ORDER BY created_at DESC LIMIT 1
|
|
"""), {"tid": tenant_id, "mid": mod.id, "email": person.person_email}).fetchone()
|
|
except Exception:
|
|
assignment = None
|
|
|
|
if not assignment or assignment.status not in ("completed", "passed"):
|
|
gaps.append({
|
|
"person_name": person.person_name,
|
|
"person_email": person.person_email,
|
|
"role": person.role_label,
|
|
"role_key": role_key,
|
|
"module_code": mod.module_code,
|
|
"module_title": mod.title,
|
|
"status": assignment.status if assignment else "nicht_begonnen",
|
|
"progress": assignment.progress_percent if assignment else 0,
|
|
})
|
|
|
|
return {"academy_available": True, "gaps": gaps, "total_gaps": len(gaps)}
|