Files
breakpilot-compliance/backend-compliance/compliance/services/training_link_service.py
T
Benjamin Admin 630fffc0cc feat: Academy integration — training gap detection after document approval (F7)
- 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>
2026-05-03 22:03:25 +02:00

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)}