diff --git a/backend-compliance/compliance/api/document_review_routes.py b/backend-compliance/compliance/api/document_review_routes.py index a4f5d05..d9ea8cf 100644 --- a/backend-compliance/compliance/api/document_review_routes.py +++ b/backend-compliance/compliance/api/document_review_routes.py @@ -252,6 +252,20 @@ def approve_review( # Notify all OTHER roles mapped to this document type about the approval _notify_approval(db, tenant_id, review) + # Check training gaps + training_info = {"training_gaps": 0, "academy_available": False} + try: + from compliance.services.training_link_service import TrainingLinkService + tls = TrainingLinkService(db) + gaps = tls.check_training_gaps(tenant_id, review["document_type"], review.get("project_id")) + training_info = {"training_gaps": gaps.get("total_gaps", 0), "academy_available": gaps.get("academy_available", False)} + # Send training notification emails for each gap + if gaps.get("gaps"): + _notify_training_gaps(gaps["gaps"], review) + except Exception as e: + logger.warning("Training gap check failed (non-blocking): %s", e) + + review["training"] = training_info return review @@ -292,6 +306,32 @@ def _notify_approval(db: Session, tenant_id: str, review: dict): logger.warning("Approval notification failed (non-blocking): %s", e) +def _notify_training_gaps(gaps: list[dict], review: dict): + """Send training requirement emails to persons with outstanding modules.""" + try: + from compliance.services.smtp_sender import send_email + for gap in gaps: + if not gap.get("person_email"): + continue + send_email( + recipient=gap["person_email"], + subject=f"[BreakPilot] Schulungsbedarf: {gap['module_title']}", + body_html=f""" +
Sehr geehrte/r {gap['person_name']},
+nach Freigabe des Dokuments {review['document_title']} + ist fuer Ihre Rolle ({gap['role']}) eine Schulung erforderlich:
+{gap['module_title']} ({gap['module_code']})
+Status: {gap['status']}
+ +BreakPilot Compliance SDK
+ """, + ) + logger.info("Sent %d training gap notifications for %s", len(gaps), review["document_title"]) + except Exception as e: + logger.warning("Training notification failed (non-blocking): %s", e) + + @router.post("/{review_id}/reject") def reject_review( review_id: str, @@ -310,3 +350,31 @@ def reject_review( raise HTTPException(404, "Review not found") db.commit() return _row_to_dict(row) + + +# ============================================================================= +# Training Integration +# ============================================================================= + + +@router.get("/training-requirements") +def get_training_requirements( + document_type: str = Query(...), + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + from compliance.services.training_link_service import TrainingLinkService + service = TrainingLinkService(db) + return service.get_training_requirements(tenant_id, document_type) + + +@router.get("/training-gaps") +def get_training_gaps( + document_type: str = Query(...), + project_id: Optional[str] = Query(None), + db: Session = Depends(get_db), + tenant_id: str = Depends(_get_tenant_id), +): + from compliance.services.training_link_service import TrainingLinkService + service = TrainingLinkService(db) + return service.check_training_gaps(tenant_id, document_type, project_id) diff --git a/backend-compliance/compliance/services/training_link_service.py b/backend-compliance/compliance/services/training_link_service.py new file mode 100644 index 0000000..ef5013f --- /dev/null +++ b/backend-compliance/compliance/services/training_link_service.py @@ -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)} diff --git a/backend-compliance/migrations/115_role_training_mapping.sql b/backend-compliance/migrations/115_role_training_mapping.sql new file mode 100644 index 0000000..f410fdb --- /dev/null +++ b/backend-compliance/migrations/115_role_training_mapping.sql @@ -0,0 +1,22 @@ +-- Migration 115: Mapping between organizational compliance roles and training role codes +-- Bridges the Rollenkonzept (org_roles) with the Academy (training_matrix) + +CREATE TABLE IF NOT EXISTS compliance_role_training_mapping ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id VARCHAR(100) NOT NULL DEFAULT '__default__', + org_role_key VARCHAR(50) NOT NULL, + training_role_code VARCHAR(10) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, org_role_key) +); + +-- Seed default mapping +INSERT INTO compliance_role_training_mapping (tenant_id, org_role_key, training_role_code) VALUES +('__default__', 'dsb', 'R3'), +('__default__', 'gf', 'R1'), +('__default__', 'it_leiter', 'R2'), +('__default__', 'hr_leitung', 'R5'), +('__default__', 'einkauf', 'R6'), +('__default__', 'compliance_beauftragter', 'R4'), +('__default__', 'marketing_leitung', 'R7') +ON CONFLICT DO NOTHING;