fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
914
backend/compliance/api/routes.py
Normal file
914
backend/compliance/api/routes.py
Normal file
@@ -0,0 +1,914 @@
|
||||
"""
|
||||
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, timedelta
|
||||
from typing import Optional, List
|
||||
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, BackgroundTasks
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
from ..db import (
|
||||
RegulationRepository,
|
||||
RequirementRepository,
|
||||
ControlRepository,
|
||||
EvidenceRepository,
|
||||
RiskRepository,
|
||||
AuditExportRepository,
|
||||
ControlStatusEnum,
|
||||
ControlDomainEnum,
|
||||
RiskLevelEnum,
|
||||
EvidenceStatusEnum,
|
||||
)
|
||||
from ..db.models import EvidenceDB, ControlDB
|
||||
from ..services.seeder import ComplianceSeeder
|
||||
from ..services.export_generator import AuditExportGenerator
|
||||
from ..services.auto_risk_updater import AutoRiskUpdater, ScanType
|
||||
from .schemas import (
|
||||
RegulationCreate, RegulationResponse, RegulationListResponse,
|
||||
RequirementCreate, RequirementResponse, RequirementListResponse,
|
||||
ControlCreate, ControlUpdate, ControlResponse, ControlListResponse, ControlReviewRequest,
|
||||
MappingCreate, MappingResponse, MappingListResponse,
|
||||
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, db: Session = Depends(get_db)):
|
||||
"""Get a specific requirement by ID."""
|
||||
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()
|
||||
|
||||
return {
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@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.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
|
||||
from datetime import datetime
|
||||
|
||||
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,
|
||||
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
|
||||
if "status" in update_data:
|
||||
try:
|
||||
update_data["status"] = ControlStatusEnum(update_data["status"])
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status: {update_data['status']}")
|
||||
|
||||
updated = repo.update(control.id, **update_data)
|
||||
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,
|
||||
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,
|
||||
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, ControlDB, ControlMappingDB,
|
||||
EvidenceDB, RiskDB, AuditExportDB
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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, ControlDB, ControlMappingDB,
|
||||
EvidenceDB, 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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user