630fffc0cc
- 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>
381 lines
15 KiB
Python
381 lines
15 KiB
Python
"""
|
|
FastAPI routes for Document Review Workflow.
|
|
|
|
Tracks which compliance documents have been sent for review, their status,
|
|
and handles email notifications to reviewers.
|
|
|
|
Endpoints:
|
|
GET /document-reviews — list reviews with filters
|
|
GET /document-reviews/stats — counts by status
|
|
POST /document-reviews — create review (auto-assign from mapping)
|
|
GET /document-reviews/{id} — single review
|
|
POST /document-reviews/{id}/send — send notification email
|
|
POST /document-reviews/{id}/approve — mark as approved
|
|
POST /document-reviews/{id}/reject — mark as rejected
|
|
GET /document-reviews/for-document — reviews for a specific doc type
|
|
"""
|
|
|
|
import hashlib
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from classroom_engine.database import get_db
|
|
from .tenant_utils import get_tenant_id as _get_tenant_id
|
|
from .db_utils import row_to_dict as _row_to_dict
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/document-reviews", tags=["document-reviews"])
|
|
|
|
|
|
# =============================================================================
|
|
# Schemas
|
|
# =============================================================================
|
|
|
|
|
|
class ReviewCreate(BaseModel):
|
|
document_type: str
|
|
document_title: str
|
|
document_content: Optional[str] = None
|
|
project_id: Optional[str] = None
|
|
submitted_by: Optional[str] = None
|
|
review_link: Optional[str] = None
|
|
|
|
|
|
class ReviewReject(BaseModel):
|
|
comment: str
|
|
|
|
|
|
# =============================================================================
|
|
# Routes
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("")
|
|
def list_reviews(
|
|
project_id: Optional[str] = Query(None),
|
|
status: Optional[str] = Query(None),
|
|
document_type: Optional[str] = Query(None),
|
|
reviewer_role_key: Optional[str] = Query(None),
|
|
limit: int = Query(50, le=200),
|
|
offset: int = Query(0),
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant_id),
|
|
):
|
|
where = ["tenant_id = :tid"]
|
|
params = {"tid": tenant_id, "lim": limit, "off": offset}
|
|
if project_id:
|
|
where.append("project_id = :pid")
|
|
params["pid"] = project_id
|
|
if status:
|
|
where.append("status = :status")
|
|
params["status"] = status
|
|
if document_type:
|
|
where.append("document_type = :dt")
|
|
params["dt"] = document_type
|
|
if reviewer_role_key:
|
|
where.append("reviewer_role_key = :rrk")
|
|
params["rrk"] = reviewer_role_key
|
|
|
|
q = text(f"""
|
|
SELECT * FROM compliance_document_reviews
|
|
WHERE {' AND '.join(where)}
|
|
ORDER BY created_at DESC LIMIT :lim OFFSET :off
|
|
""")
|
|
rows = db.execute(q, params).fetchall()
|
|
return [_row_to_dict(r) for r in rows]
|
|
|
|
|
|
@router.get("/stats")
|
|
def review_stats(
|
|
project_id: Optional[str] = Query(None),
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant_id),
|
|
):
|
|
where = "tenant_id = :tid"
|
|
params = {"tid": tenant_id}
|
|
if project_id:
|
|
where += " AND project_id = :pid"
|
|
params["pid"] = project_id
|
|
q = text(f"SELECT status, COUNT(*) as count FROM compliance_document_reviews WHERE {where} GROUP BY status")
|
|
rows = db.execute(q, params).fetchall()
|
|
return {r.status: r.count for r in rows}
|
|
|
|
|
|
@router.get("/for-document")
|
|
def reviews_for_document(
|
|
document_type: str = Query(...),
|
|
project_id: Optional[str] = Query(None),
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant_id),
|
|
):
|
|
where = "tenant_id = :tid AND document_type = :dt"
|
|
params = {"tid": tenant_id, "dt": document_type}
|
|
if project_id:
|
|
where += " AND project_id = :pid"
|
|
params["pid"] = project_id
|
|
q = text(f"SELECT * FROM compliance_document_reviews WHERE {where} ORDER BY created_at DESC LIMIT 10")
|
|
rows = db.execute(q, params).fetchall()
|
|
return [_row_to_dict(r) for r in rows]
|
|
|
|
|
|
@router.post("")
|
|
def create_review(
|
|
body: ReviewCreate,
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant_id),
|
|
):
|
|
# Find reviewer(s) from mapping + org_roles
|
|
q = text("""
|
|
SELECT m.role_key, m.is_primary, r.person_name, r.person_email, r.role_label
|
|
FROM compliance_document_role_mapping m
|
|
LEFT JOIN compliance_org_roles r
|
|
ON r.tenant_id = m.tenant_id AND r.role_key = m.role_key
|
|
AND (r.project_id = :pid OR r.project_id IS NULL)
|
|
WHERE m.tenant_id = :tid AND m.document_type = :dt
|
|
ORDER BY m.is_primary DESC
|
|
""")
|
|
mappings = db.execute(q, {"tid": tenant_id, "dt": body.document_type, "pid": body.project_id}).fetchall()
|
|
|
|
if not mappings:
|
|
raise HTTPException(404, f"No reviewer mapping found for document type '{body.document_type}'")
|
|
|
|
content_hash = hashlib.sha256(body.document_content.encode()).hexdigest() if body.document_content else None
|
|
created = []
|
|
for m in mappings:
|
|
m_dict = _row_to_dict(m)
|
|
ins = text("""
|
|
INSERT INTO compliance_document_reviews
|
|
(tenant_id, project_id, document_type, document_title, document_content_hash,
|
|
reviewer_role_key, reviewer_name, reviewer_email, submitted_by, review_link, submitted_at)
|
|
VALUES (:tid, :pid, :dt, :title, :hash, :rrk, :rn, :re, :sb, :rl, NOW())
|
|
RETURNING *
|
|
""")
|
|
row = db.execute(ins, {
|
|
"tid": tenant_id, "pid": body.project_id, "dt": body.document_type,
|
|
"title": body.document_title, "hash": content_hash,
|
|
"rrk": m_dict["role_key"], "rn": m_dict.get("person_name"),
|
|
"re": m_dict.get("person_email"), "sb": body.submitted_by,
|
|
"rl": body.review_link,
|
|
}).fetchone()
|
|
created.append(_row_to_dict(row))
|
|
db.commit()
|
|
return created
|
|
|
|
|
|
@router.get("/{review_id}")
|
|
def get_review(
|
|
review_id: str,
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant_id),
|
|
):
|
|
q = text("SELECT * FROM compliance_document_reviews WHERE id = :rid AND tenant_id = :tid")
|
|
row = db.execute(q, {"rid": review_id, "tid": tenant_id}).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Review not found")
|
|
return _row_to_dict(row)
|
|
|
|
|
|
@router.post("/{review_id}/send")
|
|
def send_notification(
|
|
review_id: str,
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant_id),
|
|
):
|
|
q = text("SELECT * FROM compliance_document_reviews WHERE id = :rid AND tenant_id = :tid")
|
|
row = db.execute(q, {"rid": review_id, "tid": tenant_id}).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Review not found")
|
|
review = _row_to_dict(row)
|
|
if not review.get("reviewer_email"):
|
|
raise HTTPException(400, "No email for reviewer — assign a person to this role first")
|
|
|
|
try:
|
|
from compliance.services.smtp_sender import send_email
|
|
result = send_email(
|
|
recipient=review["reviewer_email"],
|
|
subject=f"[BreakPilot] Dokument zur Pruefung: {review['document_title']}",
|
|
body_html=f"""
|
|
<h2>Dokument zur Pruefung</h2>
|
|
<p>Sehr geehrte/r <strong>{review.get('reviewer_name') or 'Pruefer/in'}</strong>,</p>
|
|
<p>das folgende Dokument wurde Ihnen zur inhaltlichen Pruefung zugewiesen:</p>
|
|
<table style="border-collapse:collapse;margin:16px 0;">
|
|
<tr><td style="padding:4px 12px 4px 0;font-weight:bold;">Dokument:</td>
|
|
<td>{review['document_title']}</td></tr>
|
|
<tr><td style="padding:4px 12px 4px 0;font-weight:bold;">Typ:</td>
|
|
<td>{review['document_type']}</td></tr>
|
|
<tr><td style="padding:4px 12px 4px 0;font-weight:bold;">Eingereicht von:</td>
|
|
<td>{review.get('submitted_by') or 'System'}</td></tr>
|
|
</table>
|
|
<p>Bitte pruefen Sie das Dokument auf <strong>inhaltliche Richtigkeit</strong>,
|
|
<strong>Vollstaendigkeit</strong> und <strong>Umsetzbarkeit</strong>.</p>
|
|
{f'<p><a href="{review["review_link"]}" style="background:#7c3aed;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;">Dokument oeffnen</a></p>' if review.get("review_link") else ''}
|
|
<p style="color:#888;font-size:12px;">BreakPilot Compliance SDK</p>
|
|
""",
|
|
)
|
|
# Update review status
|
|
db.execute(text("""
|
|
UPDATE compliance_document_reviews
|
|
SET status = 'in_review', email_sent = TRUE, email_sent_at = NOW(), updated_at = NOW()
|
|
WHERE id = :rid
|
|
"""), {"rid": review_id})
|
|
db.commit()
|
|
return {"sent": True, "email": review["reviewer_email"], "result": result}
|
|
except Exception as e:
|
|
logger.error("Failed to send review email: %s", e)
|
|
raise HTTPException(500, f"Email sending failed: {e}")
|
|
|
|
|
|
@router.post("/{review_id}/approve")
|
|
def approve_review(
|
|
review_id: str,
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant_id),
|
|
):
|
|
q = text("""
|
|
UPDATE compliance_document_reviews
|
|
SET status = 'approved', reviewed_at = NOW(), updated_at = NOW()
|
|
WHERE id = :rid AND tenant_id = :tid
|
|
RETURNING *
|
|
""")
|
|
row = db.execute(q, {"rid": review_id, "tid": tenant_id}).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Review not found")
|
|
db.commit()
|
|
review = _row_to_dict(row)
|
|
|
|
# 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
|
|
|
|
|
|
def _notify_approval(db: Session, tenant_id: str, review: dict):
|
|
"""Send approval notification to all other roles mapped to this document type."""
|
|
try:
|
|
from compliance.services.smtp_sender import send_email
|
|
q = text("""
|
|
SELECT DISTINCT r.person_name, r.person_email, r.role_label
|
|
FROM compliance_document_role_mapping m
|
|
JOIN compliance_org_roles r
|
|
ON r.tenant_id = m.tenant_id AND r.role_key = m.role_key
|
|
AND (r.project_id = :pid OR r.project_id IS NULL)
|
|
WHERE m.tenant_id = :tid AND m.document_type = :dt
|
|
AND m.role_key != :reviewer_key AND r.person_email IS NOT NULL
|
|
""")
|
|
others = db.execute(q, {
|
|
"tid": tenant_id, "dt": review["document_type"],
|
|
"pid": review.get("project_id"), "reviewer_key": review["reviewer_role_key"],
|
|
}).fetchall()
|
|
for other in others:
|
|
o = _row_to_dict(other)
|
|
send_email(
|
|
recipient=o["person_email"],
|
|
subject=f"[BreakPilot] Freigabe: {review['document_title']}",
|
|
body_html=f"""
|
|
<h2>Dokument freigegeben</h2>
|
|
<p>Sehr geehrte/r <strong>{o.get('person_name') or o['role_label']}</strong>,</p>
|
|
<p>das Dokument <strong>{review['document_title']}</strong> wurde von
|
|
{review.get('reviewer_name') or review['reviewer_role_key']} freigegeben.</p>
|
|
<p>Bitte pruefen Sie, ob fuer Ihren Verantwortungsbereich Handlungsbedarf besteht
|
|
(z.B. Schulungsbedarf, Prozessanpassungen).</p>
|
|
<p style="color:#888;font-size:12px;">BreakPilot Compliance SDK</p>
|
|
""",
|
|
)
|
|
logger.info("Notified %d other roles about approval of %s", len(others), review["document_title"])
|
|
except Exception as e:
|
|
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,
|
|
body: ReviewReject,
|
|
db: Session = Depends(get_db),
|
|
tenant_id: str = Depends(_get_tenant_id),
|
|
):
|
|
q = text("""
|
|
UPDATE compliance_document_reviews
|
|
SET status = 'rejected', reviewed_at = NOW(), review_comment = :comment, updated_at = NOW()
|
|
WHERE id = :rid AND tenant_id = :tid
|
|
RETURNING *
|
|
""")
|
|
row = db.execute(q, {"rid": review_id, "tid": tenant_id, "comment": body.comment}).fetchone()
|
|
if not row:
|
|
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)
|