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

Dokument zur Pruefung

Sehr geehrte/r {review.get('reviewer_name') or 'Pruefer/in'},

das folgende Dokument wurde Ihnen zur inhaltlichen Pruefung zugewiesen:

Dokument: {review['document_title']}
Typ: {review['document_type']}
Eingereicht von: {review.get('submitted_by') or 'System'}

Bitte pruefen Sie das Dokument auf inhaltliche Richtigkeit, Vollstaendigkeit und Umsetzbarkeit.

{f'

Dokument oeffnen

' if review.get("review_link") else ''}

BreakPilot Compliance SDK

""", ) # 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"""

Dokument freigegeben

Sehr geehrte/r {o.get('person_name') or o['role_label']},

das Dokument {review['document_title']} wurde von {review.get('reviewer_name') or review['reviewer_role_key']} freigegeben.

Bitte pruefen Sie, ob fuer Ihren Verantwortungsbereich Handlungsbedarf besteht (z.B. Schulungsbedarf, Prozessanpassungen).

BreakPilot Compliance SDK

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

Schulungsbedarf nach Dokument-Freigabe

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']}

Zur Academy

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, 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)