Files
breakpilot-compliance/backend-compliance/compliance/api/dashboard_routes.py
Sharang Parnerkar 3320ef94fc refactor: phase 0 guardrails + phase 1 step 2 (models.py split)
Squash of branch refactor/phase0-guardrails-and-models-split — 4 commits,
81 files, 173/173 pytest green, OpenAPI contract preserved (360 paths /
484 operations).

## Phase 0 — Architecture guardrails

Three defense-in-depth layers to keep the architecture rules enforced
regardless of who opens Claude Code in this repo:

  1. .claude/settings.json PreToolUse hook on Write/Edit blocks any file
     that would exceed the 500-line hard cap. Auto-loads in every Claude
     session in this repo.
  2. scripts/githooks/pre-commit (install via scripts/install-hooks.sh)
     enforces the LOC cap locally, freezes migrations/ without
     [migration-approved], and protects guardrail files without
     [guardrail-change].
  3. .gitea/workflows/ci.yaml gains loc-budget + guardrail-integrity +
     sbom-scan (syft+grype) jobs, adds mypy --strict for the new Python
     packages (compliance/{services,repositories,domain,schemas}), and
     tsc --noEmit for admin-compliance + developer-portal.

Per-language conventions documented in AGENTS.python.md, AGENTS.go.md,
AGENTS.typescript.md at the repo root — layering, tooling, and explicit
"what you may NOT do" lists. Root CLAUDE.md is prepended with the six
non-negotiable rules. Each of the 10 services gets a README.md.

scripts/check-loc.sh enforces soft 300 / hard 500 and surfaces the
current baseline of 205 hard + 161 soft violations so Phases 1-4 can
drain it incrementally. CI gates only CHANGED files in PRs so the
legacy baseline does not block unrelated work.

## Deprecation sweep

47 files. Pydantic V1 regex= -> pattern= (2 sites), class Config ->
ConfigDict in source_policy_router.py (schemas.py intentionally skipped;
it is the Phase 1 Step 3 split target). datetime.utcnow() ->
datetime.now(timezone.utc) everywhere including SQLAlchemy default=
callables. All DB columns already declare timezone=True, so this is a
latent-bug fix at the Python side, not a schema change.

DeprecationWarning count dropped from 158 to 35.

## Phase 1 Step 1 — Contract test harness

tests/contracts/test_openapi_baseline.py diffs the live FastAPI /openapi.json
against tests/contracts/openapi.baseline.json on every test run. Fails on
removed paths, removed status codes, or new required request body fields.
Regenerate only via tests/contracts/regenerate_baseline.py after a
consumer-updated contract change. This is the safety harness for all
subsequent refactor commits.

## Phase 1 Step 2 — models.py split (1466 -> 85 LOC shim)

compliance/db/models.py is decomposed into seven sibling aggregate modules
following the existing repo pattern (dsr_models.py, vvt_models.py, ...):

  regulation_models.py       (134) — Regulation, Requirement
  control_models.py          (279) — Control, Mapping, Evidence, Risk
  ai_system_models.py        (141) — AISystem, AuditExport
  service_module_models.py   (176) — ServiceModule, ModuleRegulation, ModuleRisk
  audit_session_models.py    (177) — AuditSession, AuditSignOff
  isms_governance_models.py  (323) — ISMSScope, Context, Policy, Objective, SoA
  isms_audit_models.py       (468) — Finding, CAPA, MgmtReview, InternalAudit,
                                     AuditTrail, Readiness

models.py becomes an 85-line re-export shim in dependency order so
existing imports continue to work unchanged. Schema is byte-identical:
__tablename__, column definitions, relationship strings, back_populates,
cascade directives all preserved.

All new sibling files are under the 500-line hard cap; largest is
isms_audit_models.py at 468. No file in compliance/db/ now exceeds
the hard cap.

## Phase 1 Step 3 — infrastructure only

backend-compliance/compliance/{schemas,domain,repositories}/ packages
are created as landing zones with docstrings. compliance/domain/
exports DomainError / NotFoundError / ConflictError / ValidationError /
PermissionError — the base classes services will use to raise
domain-level errors instead of HTTPException.

PHASE1_RUNBOOK.md at backend-compliance/PHASE1_RUNBOOK.md documents
the nine-step execution plan for Phase 1: snapshot baseline,
characterization tests, split models.py (this commit), split schemas.py
(next), extract services, extract repositories, mypy --strict, coverage.

## Verification

  backend-compliance/.venv-phase1: uv python install 3.12 + pip -r requirements.txt
  PYTHONPATH=. pytest compliance/tests/ tests/contracts/
  -> 173 passed, 0 failed, 35 warnings, OpenAPI 360/484 unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:18:29 +02:00

378 lines
12 KiB
Python

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