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:
@@ -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"""
|
||||
<h2>Schulungsbedarf nach Dokument-Freigabe</h2>
|
||||
<p>Sehr geehrte/r <strong>{gap['person_name']}</strong>,</p>
|
||||
<p>nach Freigabe des Dokuments <strong>{review['document_title']}</strong>
|
||||
ist fuer Ihre Rolle (<strong>{gap['role']}</strong>) eine Schulung erforderlich:</p>
|
||||
<p><strong>{gap['module_title']}</strong> ({gap['module_code']})</p>
|
||||
<p>Status: {gap['status']}</p>
|
||||
<p><a href="/sdk/training/learner" style="background:#7c3aed;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;">Zur Academy</a></p>
|
||||
<p style="color:#888;font-size:12px;">BreakPilot Compliance SDK</p>
|
||||
""",
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -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)}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user