""" FastAPI routes for Dashboard, Executive Dashboard, and Reports. Endpoints: - /dashboard: Main compliance dashboard - /dashboard/executive: Executive summary for managers - /dashboard/trend: Compliance score trend over time - /score: Quick compliance score - /reports: Report generation """ import logging from datetime import datetime, timedelta, timezone from calendar import month_abbr from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from classroom_engine.database import get_db from ..db import ( RegulationRepository, RequirementRepository, ControlRepository, EvidenceRepository, RiskRepository, ) from .schemas import ( DashboardResponse, ExecutiveDashboardResponse, TrendDataPoint, RiskSummary, DeadlineItem, TeamWorkloadItem, ) logger = logging.getLogger(__name__) router = APIRouter(tags=["compliance-dashboard"]) # ============================================================================ # Dashboard # ============================================================================ @router.get("/dashboard", response_model=DashboardResponse) async def get_dashboard(db: Session = Depends(get_db)): """Get compliance dashboard statistics.""" reg_repo = RegulationRepository(db) req_repo = RequirementRepository(db) ctrl_repo = ControlRepository(db) evidence_repo = EvidenceRepository(db) risk_repo = RiskRepository(db) # Regulations regulations = reg_repo.get_active() requirements = req_repo.get_all() # Controls statistics ctrl_stats = ctrl_repo.get_statistics() controls = ctrl_repo.get_all() # Group controls by domain controls_by_domain = {} for ctrl in controls: domain = ctrl.domain.value if ctrl.domain else "unknown" if domain not in controls_by_domain: controls_by_domain[domain] = {"total": 0, "pass": 0, "partial": 0, "fail": 0, "planned": 0} controls_by_domain[domain]["total"] += 1 status = ctrl.status.value if ctrl.status else "planned" if status in controls_by_domain[domain]: controls_by_domain[domain][status] += 1 # Evidence statistics evidence_stats = evidence_repo.get_statistics() # Risk statistics risks = risk_repo.get_all() risks_by_level = {"low": 0, "medium": 0, "high": 0, "critical": 0} for risk in risks: level = risk.inherent_risk.value if risk.inherent_risk else "low" if level in risks_by_level: risks_by_level[level] += 1 # Calculate compliance score - use pre-calculated score from get_statistics() # or compute from by_status dict score = ctrl_stats.get("compliance_score", 0.0) return DashboardResponse( compliance_score=round(score, 1), total_regulations=len(regulations), total_requirements=len(requirements), total_controls=ctrl_stats.get("total", 0), controls_by_status=ctrl_stats.get("by_status", {}), controls_by_domain=controls_by_domain, total_evidence=evidence_stats.get("total", 0), evidence_by_status=evidence_stats.get("by_status", {}), total_risks=len(risks), risks_by_level=risks_by_level, recent_activity=[], ) @router.get("/score") async def get_compliance_score(db: Session = Depends(get_db)): """Get just the compliance score.""" ctrl_repo = ControlRepository(db) stats = ctrl_repo.get_statistics() total = stats.get("total", 0) passing = stats.get("pass", 0) partial = stats.get("partial", 0) if total > 0: score = ((passing + partial * 0.5) / total) * 100 else: score = 0 return { "score": round(score, 1), "total_controls": total, "passing_controls": passing, "partial_controls": partial, } # ============================================================================ # Executive Dashboard (Phase 3 - Sprint 1) # ============================================================================ @router.get("/dashboard/executive", response_model=ExecutiveDashboardResponse) async def get_executive_dashboard(db: Session = Depends(get_db)): """ Get executive dashboard for managers and decision makers. Provides: - Traffic light status (green/yellow/red) - Overall compliance score with trend - Top 5 open risks - Upcoming deadlines (control reviews, evidence expiry) - Team workload distribution """ reg_repo = RegulationRepository(db) req_repo = RequirementRepository(db) ctrl_repo = ControlRepository(db) risk_repo = RiskRepository(db) # Calculate compliance score ctrl_stats = ctrl_repo.get_statistics() total = ctrl_stats.get("total", 0) passing = ctrl_stats.get("pass", 0) partial = ctrl_stats.get("partial", 0) if total > 0: score = ((passing + partial * 0.5) / total) * 100 else: score = 0 # Determine traffic light status if score >= 80: traffic_light = "green" elif score >= 60: traffic_light = "yellow" else: traffic_light = "red" # Trend data — only show current score, no simulated history trend_data = [] if total > 0: now = datetime.now(timezone.utc) trend_data.append(TrendDataPoint( date=now.strftime("%Y-%m-%d"), score=round(score, 1), label=month_abbr[now.month][:3], )) # Get top 5 risks (sorted by severity) risks = risk_repo.get_all() risk_priority = {"critical": 4, "high": 3, "medium": 2, "low": 1} sorted_risks = sorted( [r for r in risks if r.status != "mitigated"], key=lambda r: ( risk_priority.get(r.inherent_risk.value if r.inherent_risk else "low", 1), r.impact * r.likelihood ), reverse=True )[:5] top_risks = [ RiskSummary( id=r.id, risk_id=r.risk_id, title=r.title, risk_level=r.inherent_risk.value if r.inherent_risk else "medium", owner=r.owner, status=r.status, category=r.category, impact=r.impact, likelihood=r.likelihood, ) for r in sorted_risks ] # Get upcoming deadlines controls = ctrl_repo.get_all() upcoming_deadlines = [] today = datetime.now(timezone.utc).date() for ctrl in controls: if ctrl.next_review_at: review_date = ctrl.next_review_at.date() if hasattr(ctrl.next_review_at, 'date') else ctrl.next_review_at days_remaining = (review_date - today).days if days_remaining <= 30: if days_remaining < 0: status = "overdue" elif days_remaining <= 7: status = "at_risk" else: status = "on_track" upcoming_deadlines.append(DeadlineItem( id=ctrl.id, title=f"Review: {ctrl.control_id} - {ctrl.title[:30]}", deadline=review_date.isoformat(), days_remaining=days_remaining, type="control_review", status=status, owner=ctrl.owner, )) upcoming_deadlines.sort(key=lambda x: x.days_remaining) upcoming_deadlines = upcoming_deadlines[:10] # Calculate team workload (by owner) owner_workload = {} for ctrl in controls: owner = ctrl.owner or "Unassigned" if owner not in owner_workload: owner_workload[owner] = {"pending": 0, "in_progress": 0, "completed": 0} status = ctrl.status.value if ctrl.status else "planned" if status in ["pass"]: owner_workload[owner]["completed"] += 1 elif status in ["partial"]: owner_workload[owner]["in_progress"] += 1 else: owner_workload[owner]["pending"] += 1 team_workload = [] for name, stats in owner_workload.items(): total_tasks = stats["pending"] + stats["in_progress"] + stats["completed"] completion_rate = (stats["completed"] / total_tasks * 100) if total_tasks > 0 else 0 team_workload.append(TeamWorkloadItem( name=name, pending_tasks=stats["pending"], in_progress_tasks=stats["in_progress"], completed_tasks=stats["completed"], total_tasks=total_tasks, completion_rate=round(completion_rate, 1), )) team_workload.sort(key=lambda x: x.total_tasks, reverse=True) # Get counts regulations = reg_repo.get_active() requirements = req_repo.get_all() open_risks = len([r for r in risks if r.status != "mitigated"]) return ExecutiveDashboardResponse( traffic_light_status=traffic_light, overall_score=round(score, 1), score_trend=trend_data, previous_score=trend_data[-2].score if len(trend_data) >= 2 else None, score_change=round(score - trend_data[-2].score, 1) if len(trend_data) >= 2 else None, total_regulations=len(regulations), total_requirements=len(requirements), total_controls=total, open_risks=open_risks, top_risks=top_risks, upcoming_deadlines=upcoming_deadlines, team_workload=team_workload, last_updated=datetime.now(timezone.utc).isoformat(), ) @router.get("/dashboard/trend") async def get_compliance_trend( months: int = Query(12, ge=1, le=24, description="Number of months to include"), db: Session = Depends(get_db), ): """ Get compliance score trend over time. Returns monthly compliance scores for trend visualization. """ ctrl_repo = ControlRepository(db) stats = ctrl_repo.get_statistics() total = stats.get("total", 0) passing = stats.get("pass", 0) partial = stats.get("partial", 0) current_score = ((passing + partial * 0.5) / total) * 100 if total > 0 else 0 # Trend data — only current score, no simulated history trend_data = [] if total > 0: now = datetime.now(timezone.utc) trend_data.append({ "date": now.strftime("%Y-%m-%d"), "score": round(current_score, 1), "label": f"{month_abbr[now.month]} {now.year % 100}", "month": now.month, "year": now.year, }) return { "current_score": round(current_score, 1), "trend": trend_data, "period_months": months, "generated_at": datetime.now(timezone.utc).isoformat(), } # ============================================================================ # Reports # ============================================================================ @router.get("/reports/summary") async def get_summary_report(db: Session = Depends(get_db)): """Get a quick summary report for the dashboard.""" from ..services.report_generator import ComplianceReportGenerator generator = ComplianceReportGenerator(db) return generator.generate_summary_report() @router.get("/reports/{period}") async def generate_period_report( period: str = "monthly", as_of_date: Optional[str] = None, db: Session = Depends(get_db), ): """ Generate a compliance report for the specified period. Args: period: One of 'weekly', 'monthly', 'quarterly', 'yearly' as_of_date: Report date (YYYY-MM-DD format, defaults to today) Returns: Complete compliance report """ from ..services.report_generator import ComplianceReportGenerator, ReportPeriod # Validate period try: report_period = ReportPeriod(period) except ValueError: raise HTTPException( status_code=400, detail=f"Invalid period '{period}'. Must be one of: weekly, monthly, quarterly, yearly" ) # Parse date report_date = None if as_of_date: try: report_date = datetime.strptime(as_of_date, "%Y-%m-%d").date() except ValueError: raise HTTPException( status_code=400, detail="Invalid date format. Use YYYY-MM-DD" ) generator = ComplianceReportGenerator(db) return generator.generate_report(report_period, report_date)