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>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
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)}
|
||||
Reference in New Issue
Block a user