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