Files
breakpilot-compliance/backend-compliance/compliance/api/routes.py
Benjamin Admin e6201d5239 feat: Anti-Fake-Evidence System (Phase 1-4b)
Implement full evidence integrity pipeline to prevent compliance theater:
- Confidence levels (E0-E4), truth status tracking, assertion engine
- Four-Eyes approval workflow, audit trail, reject endpoint
- Evidence distribution dashboard, LLM audit routes
- Traceability matrix (backend endpoint + Compliance Hub UI tab)
- Anti-fake badges, control status machine, normative patterns
- 2 migrations, 4 test suites, MkDocs documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:15:45 +01:00

1032 lines
38 KiB
Python

"""
FastAPI routes for Compliance module.
Endpoints:
- /regulations: Manage regulations
- /requirements: Manage requirements
- /controls: Manage controls
- /mappings: Requirement-Control mappings
- /evidence: Evidence management
- /risks: Risk management
- /dashboard: Dashboard statistics
- /export: Audit export
"""
import logging
logger = logging.getLogger(__name__)
import os
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from .audit_trail_utils import log_audit_trail
from ..db import (
RegulationRepository,
RequirementRepository,
ControlRepository,
EvidenceRepository,
ControlStatusEnum,
ControlDomainEnum,
)
from ..db.models import EvidenceDB, ControlDB
from ..services.seeder import ComplianceSeeder
from ..services.export_generator import AuditExportGenerator
from .schemas import (
RegulationResponse, RegulationListResponse,
RequirementCreate, RequirementResponse, RequirementListResponse,
ControlUpdate, ControlResponse, ControlListResponse, ControlReviewRequest,
ExportRequest, ExportResponse, ExportListResponse,
SeedRequest, SeedResponse,
# Pagination schemas
PaginationMeta, PaginatedRequirementResponse, PaginatedControlResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/compliance", tags=["compliance"])
# ============================================================================
# Regulations
# ============================================================================
@router.get("/regulations", response_model=RegulationListResponse)
async def list_regulations(
is_active: Optional[bool] = None,
regulation_type: Optional[str] = None,
db: Session = Depends(get_db),
):
"""List all regulations."""
repo = RegulationRepository(db)
if is_active is not None:
regulations = repo.get_active() if is_active else repo.get_all()
else:
regulations = repo.get_all()
if regulation_type:
from ..db.models import RegulationTypeEnum
try:
reg_type = RegulationTypeEnum(regulation_type)
regulations = [r for r in regulations if r.regulation_type == reg_type]
except ValueError:
pass
# Add requirement counts
req_repo = RequirementRepository(db)
results = []
for reg in regulations:
reqs = req_repo.get_by_regulation(reg.id)
reg_dict = {
"id": reg.id,
"code": reg.code,
"name": reg.name,
"full_name": reg.full_name,
"regulation_type": reg.regulation_type.value if reg.regulation_type else None,
"source_url": reg.source_url,
"local_pdf_path": reg.local_pdf_path,
"effective_date": reg.effective_date,
"description": reg.description,
"is_active": reg.is_active,
"created_at": reg.created_at,
"updated_at": reg.updated_at,
"requirement_count": len(reqs),
}
results.append(RegulationResponse(**reg_dict))
return RegulationListResponse(regulations=results, total=len(results))
@router.get("/regulations/{code}", response_model=RegulationResponse)
async def get_regulation(code: str, db: Session = Depends(get_db)):
"""Get a specific regulation by code."""
repo = RegulationRepository(db)
regulation = repo.get_by_code(code)
if not regulation:
raise HTTPException(status_code=404, detail=f"Regulation {code} not found")
req_repo = RequirementRepository(db)
reqs = req_repo.get_by_regulation(regulation.id)
return RegulationResponse(
id=regulation.id,
code=regulation.code,
name=regulation.name,
full_name=regulation.full_name,
regulation_type=regulation.regulation_type.value if regulation.regulation_type else None,
source_url=regulation.source_url,
local_pdf_path=regulation.local_pdf_path,
effective_date=regulation.effective_date,
description=regulation.description,
is_active=regulation.is_active,
created_at=regulation.created_at,
updated_at=regulation.updated_at,
requirement_count=len(reqs),
)
@router.get("/regulations/{code}/requirements", response_model=RequirementListResponse)
async def get_regulation_requirements(
code: str,
is_applicable: Optional[bool] = None,
db: Session = Depends(get_db),
):
"""Get requirements for a specific regulation."""
reg_repo = RegulationRepository(db)
regulation = reg_repo.get_by_code(code)
if not regulation:
raise HTTPException(status_code=404, detail=f"Regulation {code} not found")
req_repo = RequirementRepository(db)
if is_applicable is not None:
requirements = req_repo.get_applicable(regulation.id) if is_applicable else req_repo.get_by_regulation(regulation.id)
else:
requirements = req_repo.get_by_regulation(regulation.id)
results = [
RequirementResponse(
id=r.id,
regulation_id=r.regulation_id,
regulation_code=code,
article=r.article,
paragraph=r.paragraph,
title=r.title,
description=r.description,
requirement_text=r.requirement_text,
breakpilot_interpretation=r.breakpilot_interpretation,
is_applicable=r.is_applicable,
applicability_reason=r.applicability_reason,
priority=r.priority,
created_at=r.created_at,
updated_at=r.updated_at,
)
for r in requirements
]
return RequirementListResponse(requirements=results, total=len(results))
@router.get("/requirements/{requirement_id}")
async def get_requirement(
requirement_id: str,
include_legal_context: bool = Query(False, description="Include RAG legal context"),
db: Session = Depends(get_db),
):
"""Get a specific requirement by ID, optionally with RAG legal context."""
from ..db.models import RequirementDB, RegulationDB
requirement = db.query(RequirementDB).filter(RequirementDB.id == requirement_id).first()
if not requirement:
raise HTTPException(status_code=404, detail=f"Requirement {requirement_id} not found")
regulation = db.query(RegulationDB).filter(RegulationDB.id == requirement.regulation_id).first()
result = {
"id": requirement.id,
"regulation_id": requirement.regulation_id,
"regulation_code": regulation.code if regulation else None,
"article": requirement.article,
"paragraph": requirement.paragraph,
"title": requirement.title,
"description": requirement.description,
"requirement_text": requirement.requirement_text,
"breakpilot_interpretation": requirement.breakpilot_interpretation,
"implementation_status": requirement.implementation_status or "not_started",
"implementation_details": requirement.implementation_details,
"code_references": requirement.code_references,
"documentation_links": requirement.documentation_links,
"evidence_description": requirement.evidence_description,
"evidence_artifacts": requirement.evidence_artifacts,
"auditor_notes": requirement.auditor_notes,
"audit_status": requirement.audit_status or "pending",
"last_audit_date": requirement.last_audit_date,
"last_auditor": requirement.last_auditor,
"is_applicable": requirement.is_applicable,
"applicability_reason": requirement.applicability_reason,
"priority": requirement.priority,
"source_page": requirement.source_page,
"source_section": requirement.source_section,
}
if include_legal_context:
try:
from ..services.rag_client import get_rag_client
from ..services.ai_compliance_assistant import AIComplianceAssistant
rag = get_rag_client()
assistant = AIComplianceAssistant()
query = f"{requirement.title} {requirement.article or ''}"
collection = assistant._collection_for_regulation(regulation.code if regulation else "")
rag_results = await rag.search(query, collection=collection, top_k=3)
result["legal_context"] = [
{
"text": r.text,
"regulation_code": r.regulation_code,
"regulation_short": r.regulation_short,
"article": r.article,
"score": r.score,
"source_url": r.source_url,
}
for r in rag_results
]
except Exception as e:
logger.warning("Failed to fetch legal context for %s: %s", requirement_id, e)
result["legal_context"] = []
return result
@router.get("/requirements", response_model=PaginatedRequirementResponse)
async def list_requirements_paginated(
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=500, description="Items per page"),
regulation_code: Optional[str] = Query(None, description="Filter by regulation code"),
status: Optional[str] = Query(None, description="Filter by implementation status"),
is_applicable: Optional[bool] = Query(None, description="Filter by applicability"),
search: Optional[str] = Query(None, description="Search in title/description"),
db: Session = Depends(get_db),
):
"""
List requirements with pagination and eager-loaded relationships.
This endpoint is optimized for large datasets (1000+ requirements) with:
- Eager loading to prevent N+1 queries
- Server-side pagination
- Full-text search support
"""
req_repo = RequirementRepository(db)
# Use the new paginated method with eager loading
requirements, total = req_repo.get_paginated(
page=page,
page_size=page_size,
regulation_code=regulation_code,
status=status,
is_applicable=is_applicable,
search=search,
)
# Calculate pagination metadata
total_pages = (total + page_size - 1) // page_size
results = [
RequirementResponse(
id=r.id,
regulation_id=r.regulation_id,
regulation_code=r.regulation.code if r.regulation else None,
article=r.article,
paragraph=r.paragraph,
title=r.title,
description=r.description,
requirement_text=r.requirement_text,
breakpilot_interpretation=r.breakpilot_interpretation,
is_applicable=r.is_applicable,
applicability_reason=r.applicability_reason,
priority=r.priority,
implementation_status=r.implementation_status or "not_started",
implementation_details=r.implementation_details,
code_references=r.code_references,
documentation_links=r.documentation_links,
evidence_description=r.evidence_description,
evidence_artifacts=r.evidence_artifacts,
auditor_notes=r.auditor_notes,
audit_status=r.audit_status or "pending",
last_audit_date=r.last_audit_date,
last_auditor=r.last_auditor,
source_page=r.source_page,
source_section=r.source_section,
created_at=r.created_at,
updated_at=r.updated_at,
)
for r in requirements
]
return PaginatedRequirementResponse(
data=results,
pagination=PaginationMeta(
page=page,
page_size=page_size,
total=total,
total_pages=total_pages,
has_next=page < total_pages,
has_prev=page > 1,
),
)
@router.post("/requirements", response_model=RequirementResponse)
async def create_requirement(
data: RequirementCreate,
db: Session = Depends(get_db),
):
"""Create a new requirement."""
# Verify regulation exists
reg_repo = RegulationRepository(db)
regulation = reg_repo.get_by_id(data.regulation_id)
if not regulation:
raise HTTPException(status_code=404, detail=f"Regulation {data.regulation_id} not found")
req_repo = RequirementRepository(db)
requirement = req_repo.create(
regulation_id=data.regulation_id,
article=data.article,
title=data.title,
paragraph=data.paragraph,
description=data.description,
requirement_text=data.requirement_text,
breakpilot_interpretation=data.breakpilot_interpretation,
is_applicable=data.is_applicable,
priority=data.priority,
)
return RequirementResponse(
id=requirement.id,
regulation_id=requirement.regulation_id,
regulation_code=regulation.code,
article=requirement.article,
paragraph=requirement.paragraph,
title=requirement.title,
description=requirement.description,
requirement_text=requirement.requirement_text,
breakpilot_interpretation=requirement.breakpilot_interpretation,
is_applicable=requirement.is_applicable,
applicability_reason=requirement.applicability_reason,
priority=requirement.priority,
created_at=requirement.created_at,
updated_at=requirement.updated_at,
)
@router.delete("/requirements/{requirement_id}")
async def delete_requirement(requirement_id: str, db: Session = Depends(get_db)):
"""Delete a requirement by ID."""
req_repo = RequirementRepository(db)
deleted = req_repo.delete(requirement_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Requirement {requirement_id} not found")
return {"success": True, "message": "Requirement deleted"}
@router.put("/requirements/{requirement_id}")
async def update_requirement(requirement_id: str, updates: dict, db: Session = Depends(get_db)):
"""Update a requirement with implementation/audit details."""
from ..db.models import RequirementDB
requirement = db.query(RequirementDB).filter(RequirementDB.id == requirement_id).first()
if not requirement:
raise HTTPException(status_code=404, detail=f"Requirement {requirement_id} not found")
# Allowed fields to update
allowed_fields = [
'implementation_status', 'implementation_details', 'code_references',
'documentation_links', 'evidence_description', 'evidence_artifacts',
'auditor_notes', 'audit_status', 'is_applicable', 'applicability_reason',
'breakpilot_interpretation'
]
for field in allowed_fields:
if field in updates:
setattr(requirement, field, updates[field])
# Track audit changes
if 'audit_status' in updates:
requirement.last_audit_date = datetime.utcnow()
# TODO: Get auditor from auth
requirement.last_auditor = updates.get('auditor_name', 'api_user')
requirement.updated_at = datetime.utcnow()
db.commit()
db.refresh(requirement)
return {"success": True, "message": "Requirement updated"}
# ============================================================================
# Controls
# ============================================================================
@router.get("/controls", response_model=ControlListResponse)
async def list_controls(
domain: Optional[str] = None,
status: Optional[str] = None,
is_automated: Optional[bool] = None,
search: Optional[str] = None,
db: Session = Depends(get_db),
):
"""List all controls with optional filters."""
repo = ControlRepository(db)
if domain:
try:
domain_enum = ControlDomainEnum(domain)
controls = repo.get_by_domain(domain_enum)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid domain: {domain}")
elif status:
try:
status_enum = ControlStatusEnum(status)
controls = repo.get_by_status(status_enum)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid status: {status}")
else:
controls = repo.get_all()
# Apply additional filters
if is_automated is not None:
controls = [c for c in controls if c.is_automated == is_automated]
if search:
search_lower = search.lower()
controls = [
c for c in controls
if search_lower in c.control_id.lower()
or search_lower in c.title.lower()
or (c.description and search_lower in c.description.lower())
]
# Add counts
evidence_repo = EvidenceRepository(db)
results = []
for ctrl in controls:
evidence = evidence_repo.get_by_control(ctrl.id)
results.append(ControlResponse(
id=ctrl.id,
control_id=ctrl.control_id,
domain=ctrl.domain.value if ctrl.domain else None,
control_type=ctrl.control_type.value if ctrl.control_type else None,
title=ctrl.title,
description=ctrl.description,
pass_criteria=ctrl.pass_criteria,
implementation_guidance=ctrl.implementation_guidance,
code_reference=ctrl.code_reference,
documentation_url=ctrl.documentation_url,
is_automated=ctrl.is_automated,
automation_tool=ctrl.automation_tool,
automation_config=ctrl.automation_config,
owner=ctrl.owner,
review_frequency_days=ctrl.review_frequency_days,
status=ctrl.status.value if ctrl.status else None,
status_notes=ctrl.status_notes,
last_reviewed_at=ctrl.last_reviewed_at,
next_review_at=ctrl.next_review_at,
created_at=ctrl.created_at,
updated_at=ctrl.updated_at,
evidence_count=len(evidence),
))
return ControlListResponse(controls=results, total=len(results))
@router.get("/controls/paginated", response_model=PaginatedControlResponse)
async def list_controls_paginated(
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=500, description="Items per page"),
domain: Optional[str] = Query(None, description="Filter by domain"),
status: Optional[str] = Query(None, description="Filter by status"),
is_automated: Optional[bool] = Query(None, description="Filter by automation"),
search: Optional[str] = Query(None, description="Search in title/description"),
db: Session = Depends(get_db),
):
"""
List controls with pagination and eager-loaded relationships.
This endpoint is optimized for large datasets with:
- Eager loading to prevent N+1 queries
- Server-side pagination
- Full-text search support
"""
repo = ControlRepository(db)
# Convert domain/status to enums if provided
domain_enum = None
status_enum = None
if domain:
try:
domain_enum = ControlDomainEnum(domain)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid domain: {domain}")
if status:
try:
status_enum = ControlStatusEnum(status)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid status: {status}")
controls, total = repo.get_paginated(
page=page,
page_size=page_size,
domain=domain_enum,
status=status_enum,
is_automated=is_automated,
search=search,
)
total_pages = (total + page_size - 1) // page_size
results = [
ControlResponse(
id=c.id,
control_id=c.control_id,
domain=c.domain.value if c.domain else None,
control_type=c.control_type.value if c.control_type else None,
title=c.title,
description=c.description,
pass_criteria=c.pass_criteria,
implementation_guidance=c.implementation_guidance,
code_reference=c.code_reference,
documentation_url=c.documentation_url,
is_automated=c.is_automated,
automation_tool=c.automation_tool,
automation_config=c.automation_config,
owner=c.owner,
review_frequency_days=c.review_frequency_days,
status=c.status.value if c.status else None,
status_notes=c.status_notes,
last_reviewed_at=c.last_reviewed_at,
next_review_at=c.next_review_at,
created_at=c.created_at,
updated_at=c.updated_at,
evidence_count=len(c.evidence) if c.evidence else 0,
)
for c in controls
]
return PaginatedControlResponse(
data=results,
pagination=PaginationMeta(
page=page,
page_size=page_size,
total=total,
total_pages=total_pages,
has_next=page < total_pages,
has_prev=page > 1,
),
)
@router.get("/controls/{control_id}", response_model=ControlResponse)
async def get_control(control_id: str, db: Session = Depends(get_db)):
"""Get a specific control by control_id."""
repo = ControlRepository(db)
control = repo.get_by_control_id(control_id)
if not control:
raise HTTPException(status_code=404, detail=f"Control {control_id} not found")
evidence_repo = EvidenceRepository(db)
evidence = evidence_repo.get_by_control(control.id)
return ControlResponse(
id=control.id,
control_id=control.control_id,
domain=control.domain.value if control.domain else None,
control_type=control.control_type.value if control.control_type else None,
title=control.title,
description=control.description,
pass_criteria=control.pass_criteria,
implementation_guidance=control.implementation_guidance,
code_reference=control.code_reference,
documentation_url=control.documentation_url,
is_automated=control.is_automated,
automation_tool=control.automation_tool,
automation_config=control.automation_config,
owner=control.owner,
review_frequency_days=control.review_frequency_days,
status=control.status.value if control.status else None,
status_notes=control.status_notes,
status_justification=control.status_justification,
last_reviewed_at=control.last_reviewed_at,
next_review_at=control.next_review_at,
created_at=control.created_at,
updated_at=control.updated_at,
evidence_count=len(evidence),
)
@router.put("/controls/{control_id}", response_model=ControlResponse)
async def update_control(
control_id: str,
update: ControlUpdate,
db: Session = Depends(get_db),
):
"""Update a control."""
repo = ControlRepository(db)
control = repo.get_by_control_id(control_id)
if not control:
raise HTTPException(status_code=404, detail=f"Control {control_id} not found")
update_data = update.model_dump(exclude_unset=True)
# Convert status string to enum and validate transition
if "status" in update_data:
try:
new_status_enum = ControlStatusEnum(update_data["status"])
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid status: {update_data['status']}")
# Validate status transition (Anti-Fake-Evidence)
from ..services.control_status_machine import validate_transition
current_status = control.status.value if control.status else "planned"
evidence_list = db.query(EvidenceDB).filter(EvidenceDB.control_id == control.id).all()
allowed, violations = validate_transition(
current_status=current_status,
new_status=update_data["status"],
evidence_list=evidence_list,
status_justification=update_data.get("status_justification") or update_data.get("status_notes"),
)
if not allowed:
raise HTTPException(
status_code=409,
detail={
"error": "Status transition not allowed",
"current_status": current_status,
"requested_status": update_data["status"],
"violations": violations,
}
)
update_data["status"] = new_status_enum
updated = repo.update(control.id, **update_data)
db.commit()
# Audit trail for status changes
new_status = updated.status.value if updated.status else None
if "status" in update.model_dump(exclude_unset=True) and current_status != new_status:
log_audit_trail(
db, "control", control.id, updated.control_id or updated.title,
"status_change",
performed_by=update.owner or "system",
field_changed="status",
old_value=current_status,
new_value=new_status,
)
db.commit()
return ControlResponse(
id=updated.id,
control_id=updated.control_id,
domain=updated.domain.value if updated.domain else None,
control_type=updated.control_type.value if updated.control_type else None,
title=updated.title,
description=updated.description,
pass_criteria=updated.pass_criteria,
implementation_guidance=updated.implementation_guidance,
code_reference=updated.code_reference,
documentation_url=updated.documentation_url,
is_automated=updated.is_automated,
automation_tool=updated.automation_tool,
automation_config=updated.automation_config,
owner=updated.owner,
review_frequency_days=updated.review_frequency_days,
status=updated.status.value if updated.status else None,
status_notes=updated.status_notes,
status_justification=updated.status_justification,
last_reviewed_at=updated.last_reviewed_at,
next_review_at=updated.next_review_at,
created_at=updated.created_at,
updated_at=updated.updated_at,
)
@router.put("/controls/{control_id}/review", response_model=ControlResponse)
async def review_control(
control_id: str,
review: ControlReviewRequest,
db: Session = Depends(get_db),
):
"""Mark a control as reviewed with new status."""
repo = ControlRepository(db)
control = repo.get_by_control_id(control_id)
if not control:
raise HTTPException(status_code=404, detail=f"Control {control_id} not found")
try:
status_enum = ControlStatusEnum(review.status)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid status: {review.status}")
updated = repo.mark_reviewed(control.id, status_enum, review.status_notes)
db.commit()
return ControlResponse(
id=updated.id,
control_id=updated.control_id,
domain=updated.domain.value if updated.domain else None,
control_type=updated.control_type.value if updated.control_type else None,
title=updated.title,
description=updated.description,
pass_criteria=updated.pass_criteria,
implementation_guidance=updated.implementation_guidance,
code_reference=updated.code_reference,
documentation_url=updated.documentation_url,
is_automated=updated.is_automated,
automation_tool=updated.automation_tool,
automation_config=updated.automation_config,
owner=updated.owner,
review_frequency_days=updated.review_frequency_days,
status=updated.status.value if updated.status else None,
status_notes=updated.status_notes,
status_justification=updated.status_justification,
last_reviewed_at=updated.last_reviewed_at,
next_review_at=updated.next_review_at,
created_at=updated.created_at,
updated_at=updated.updated_at,
)
@router.get("/controls/by-domain/{domain}", response_model=ControlListResponse)
async def get_controls_by_domain(domain: str, db: Session = Depends(get_db)):
"""Get controls by domain."""
try:
domain_enum = ControlDomainEnum(domain)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid domain: {domain}")
repo = ControlRepository(db)
controls = repo.get_by_domain(domain_enum)
results = [
ControlResponse(
id=c.id,
control_id=c.control_id,
domain=c.domain.value if c.domain else None,
control_type=c.control_type.value if c.control_type else None,
title=c.title,
description=c.description,
pass_criteria=c.pass_criteria,
implementation_guidance=c.implementation_guidance,
code_reference=c.code_reference,
documentation_url=c.documentation_url,
is_automated=c.is_automated,
automation_tool=c.automation_tool,
automation_config=c.automation_config,
owner=c.owner,
review_frequency_days=c.review_frequency_days,
status=c.status.value if c.status else None,
status_notes=c.status_notes,
last_reviewed_at=c.last_reviewed_at,
next_review_at=c.next_review_at,
created_at=c.created_at,
updated_at=c.updated_at,
)
for c in controls
]
return ControlListResponse(controls=results, total=len(results))
@router.post("/export", response_model=ExportResponse)
async def create_export(
request: ExportRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
):
"""Create a new audit export."""
generator = AuditExportGenerator(db)
export = generator.create_export(
requested_by="api_user", # TODO: Get from auth
export_type=request.export_type,
included_regulations=request.included_regulations,
included_domains=request.included_domains,
date_range_start=request.date_range_start,
date_range_end=request.date_range_end,
)
return ExportResponse(
id=export.id,
export_type=export.export_type,
export_name=export.export_name,
status=export.status.value if export.status else None,
requested_by=export.requested_by,
requested_at=export.requested_at,
completed_at=export.completed_at,
file_path=export.file_path,
file_hash=export.file_hash,
file_size_bytes=export.file_size_bytes,
total_controls=export.total_controls,
total_evidence=export.total_evidence,
compliance_score=export.compliance_score,
error_message=export.error_message,
)
@router.get("/export/{export_id}", response_model=ExportResponse)
async def get_export(export_id: str, db: Session = Depends(get_db)):
"""Get export status."""
generator = AuditExportGenerator(db)
export = generator.get_export_status(export_id)
if not export:
raise HTTPException(status_code=404, detail=f"Export {export_id} not found")
return ExportResponse(
id=export.id,
export_type=export.export_type,
export_name=export.export_name,
status=export.status.value if export.status else None,
requested_by=export.requested_by,
requested_at=export.requested_at,
completed_at=export.completed_at,
file_path=export.file_path,
file_hash=export.file_hash,
file_size_bytes=export.file_size_bytes,
total_controls=export.total_controls,
total_evidence=export.total_evidence,
compliance_score=export.compliance_score,
error_message=export.error_message,
)
@router.get("/export/{export_id}/download")
async def download_export(export_id: str, db: Session = Depends(get_db)):
"""Download export file."""
generator = AuditExportGenerator(db)
export = generator.get_export_status(export_id)
if not export:
raise HTTPException(status_code=404, detail=f"Export {export_id} not found")
if export.status.value != "completed":
raise HTTPException(status_code=400, detail="Export not completed")
if not export.file_path or not os.path.exists(export.file_path):
raise HTTPException(status_code=404, detail="Export file not found")
return FileResponse(
export.file_path,
media_type="application/zip",
filename=os.path.basename(export.file_path),
)
@router.get("/exports", response_model=ExportListResponse)
async def list_exports(
limit: int = 20,
offset: int = 0,
db: Session = Depends(get_db),
):
"""List recent exports."""
generator = AuditExportGenerator(db)
exports = generator.list_exports(limit, offset)
results = [
ExportResponse(
id=e.id,
export_type=e.export_type,
export_name=e.export_name,
status=e.status.value if e.status else None,
requested_by=e.requested_by,
requested_at=e.requested_at,
completed_at=e.completed_at,
file_path=e.file_path,
file_hash=e.file_hash,
file_size_bytes=e.file_size_bytes,
total_controls=e.total_controls,
total_evidence=e.total_evidence,
compliance_score=e.compliance_score,
error_message=e.error_message,
)
for e in exports
]
return ExportListResponse(exports=results, total=len(results))
# ============================================================================
# Seeding
# ============================================================================
@router.post("/init-tables")
async def init_tables(db: Session = Depends(get_db)):
"""Create compliance tables if they don't exist."""
from classroom_engine.database import engine
from ..db.models import (
RegulationDB, RequirementDB, ControlMappingDB,
RiskDB, AuditExportDB, AISystemDB
)
try:
# Create all tables
RegulationDB.__table__.create(engine, checkfirst=True)
RequirementDB.__table__.create(engine, checkfirst=True)
ControlDB.__table__.create(engine, checkfirst=True)
ControlMappingDB.__table__.create(engine, checkfirst=True)
EvidenceDB.__table__.create(engine, checkfirst=True)
RiskDB.__table__.create(engine, checkfirst=True)
AuditExportDB.__table__.create(engine, checkfirst=True)
AISystemDB.__table__.create(engine, checkfirst=True)
return {"success": True, "message": "Tables created successfully"}
except Exception as e:
logger.error(f"Table creation failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/create-indexes")
async def create_performance_indexes(db: Session = Depends(get_db)):
"""
Create additional performance indexes for large datasets.
These indexes are optimized for:
- Pagination queries (1000+ requirements)
- Full-text search
- Filtering by status/priority
"""
from sqlalchemy import text
indexes = [
# Priority index for sorting (descending, as we want high priority first)
("ix_req_priority_desc", "CREATE INDEX IF NOT EXISTS ix_req_priority_desc ON compliance_requirements (priority DESC)"),
# Compound index for common filtering patterns
("ix_req_applicable_status", "CREATE INDEX IF NOT EXISTS ix_req_applicable_status ON compliance_requirements (is_applicable, implementation_status)"),
# Control status index
("ix_ctrl_status", "CREATE INDEX IF NOT EXISTS ix_ctrl_status ON compliance_controls (status)"),
# Evidence collected_at for timeline queries
("ix_evidence_collected", "CREATE INDEX IF NOT EXISTS ix_evidence_collected ON compliance_evidence (collected_at DESC)"),
# Risk inherent risk level
("ix_risk_level", "CREATE INDEX IF NOT EXISTS ix_risk_level ON compliance_risks (inherent_risk)"),
]
created = []
errors = []
for idx_name, idx_sql in indexes:
try:
db.execute(text(idx_sql))
db.commit()
created.append(idx_name)
except Exception as e:
errors.append({"index": idx_name, "error": str(e)})
logger.warning(f"Index creation failed for {idx_name}: {e}")
return {
"success": len(errors) == 0,
"created": created,
"errors": errors,
"message": f"Created {len(created)} indexes" + (f", {len(errors)} failed" if errors else ""),
}
@router.post("/seed-risks")
async def seed_risks_only(db: Session = Depends(get_db)):
"""Seed only risks (incremental update for existing databases)."""
from classroom_engine.database import engine
from ..db.models import RiskDB
try:
# Ensure table exists
RiskDB.__table__.create(engine, checkfirst=True)
seeder = ComplianceSeeder(db)
count = seeder.seed_risks_only()
return {
"success": True,
"message": f"Successfully seeded {count} risks",
"risks_seeded": count,
}
except Exception as e:
logger.error(f"Risk seeding failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/seed", response_model=SeedResponse)
async def seed_database(
request: SeedRequest,
db: Session = Depends(get_db),
):
"""Seed the compliance database with initial data."""
from classroom_engine.database import engine
from ..db.models import (
RegulationDB, RequirementDB, ControlMappingDB,
RiskDB, AuditExportDB
)
try:
# Ensure tables exist first
RegulationDB.__table__.create(engine, checkfirst=True)
RequirementDB.__table__.create(engine, checkfirst=True)
ControlDB.__table__.create(engine, checkfirst=True)
ControlMappingDB.__table__.create(engine, checkfirst=True)
EvidenceDB.__table__.create(engine, checkfirst=True)
RiskDB.__table__.create(engine, checkfirst=True)
AuditExportDB.__table__.create(engine, checkfirst=True)
seeder = ComplianceSeeder(db)
counts = seeder.seed_all(force=request.force)
return SeedResponse(
success=True,
message="Database seeded successfully",
counts=counts,
)
except Exception as e:
logger.error(f"Seeding failed: {e}")
raise HTTPException(status_code=500, detail=str(e))