Files
breakpilot-compliance/backend-compliance/compliance/api/audit_routes.py
Sharang Parnerkar cb90d0db0c chore(backend): deprecation sweep — Pydantic V1 -> V2, utcnow -> tz-aware
Two low-risk Pydantic V1 idioms that will be hard errors in V3:
  - Query(regex=...) -> Query(pattern=...) (audit_routes, control_generator_routes)
  - class Config: from_attributes=True -> model_config = ConfigDict(...)
    in source_policy_router.py (schemas.py is intentionally skipped — it is
    the Phase 1 schema-split target and the ConfigDict conversion is most
    efficient to do during that split).

Naive -> aware datetime sweep across 47 files:
  - datetime.utcnow() -> datetime.now(timezone.utc)
  - default=datetime.utcnow -> default=lambda: datetime.now(timezone.utc)
  - onupdate=datetime.utcnow -> onupdate=lambda: datetime.now(timezone.utc)

All SQLAlchemy DateTime columns in the project already declare
timezone=True, so the DB schema expects aware datetimes. Before this
commit, the in-Python side was generating naive values and the driver
was silently coercing them. This is a latent-bug fix, not a behavior
change at the DB boundary.

Verified:
  - 173/173 pytest compliance/tests/ pass (same as baseline)
  - tests/contracts/test_openapi_baseline.py passes (360 paths,
    484 operations unchanged)
  - DeprecationWarning count dropped from 158 -> 35

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

638 lines
22 KiB
Python

"""
FastAPI routes for Audit Sessions & Sign-off functionality.
Sprint 3 Phase 3: Auditor-Verbesserungen
Endpoints:
- /audit/sessions: Manage audit sessions
- /audit/checklist: Audit checklist with sign-off
"""
import logging
from datetime import datetime, timezone
from typing import Optional, List
from uuid import uuid4
import hashlib
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from classroom_engine.database import get_db
from ..db.models import (
AuditSessionDB, AuditSignOffDB, AuditResultEnum, AuditSessionStatusEnum,
RequirementDB, RegulationDB, ControlMappingDB
)
from .schemas import (
CreateAuditSessionRequest, AuditSessionResponse, AuditSessionSummary, AuditSessionDetailResponse,
SignOffRequest, SignOffResponse,
AuditChecklistItem, AuditChecklistResponse, AuditStatistics,
PaginationMeta,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/audit", tags=["compliance-audit"])
# ============================================================================
# Audit Sessions
# ============================================================================
@router.post("/sessions", response_model=AuditSessionResponse)
async def create_audit_session(
request: CreateAuditSessionRequest,
db: Session = Depends(get_db),
):
"""
Create a new audit session for structured compliance reviews.
An audit session groups requirements for systematic review by an auditor.
"""
# Get total requirements count based on filters
query = db.query(RequirementDB)
if request.regulation_codes:
reg_ids = db.query(RegulationDB.id).filter(
RegulationDB.code.in_(request.regulation_codes)
).all()
reg_ids = [r[0] for r in reg_ids]
query = query.filter(RequirementDB.regulation_id.in_(reg_ids))
total_items = query.count()
# Create the session
session = AuditSessionDB(
id=str(uuid4()),
name=request.name,
description=request.description,
auditor_name=request.auditor_name,
auditor_email=request.auditor_email,
auditor_organization=request.auditor_organization,
status=AuditSessionStatusEnum.DRAFT,
regulation_ids=request.regulation_codes,
total_items=total_items,
completed_items=0,
compliant_count=0,
non_compliant_count=0,
)
db.add(session)
db.commit()
db.refresh(session)
return AuditSessionResponse(
id=session.id,
name=session.name,
description=session.description,
auditor_name=session.auditor_name,
auditor_email=session.auditor_email,
auditor_organization=session.auditor_organization,
status=session.status.value,
regulation_ids=session.regulation_ids,
total_items=session.total_items,
completed_items=session.completed_items,
compliant_count=session.compliant_count,
non_compliant_count=session.non_compliant_count,
completion_percentage=session.completion_percentage,
created_at=session.created_at,
started_at=session.started_at,
completed_at=session.completed_at,
)
@router.get("/sessions", response_model=List[AuditSessionSummary])
async def list_audit_sessions(
status: Optional[str] = None,
db: Session = Depends(get_db),
):
"""
List all audit sessions, optionally filtered by status.
"""
query = db.query(AuditSessionDB)
if status:
try:
status_enum = AuditSessionStatusEnum(status)
query = query.filter(AuditSessionDB.status == status_enum)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid status: {status}. Valid values: draft, in_progress, completed, archived"
)
sessions = query.order_by(AuditSessionDB.created_at.desc()).all()
return [
AuditSessionSummary(
id=s.id,
name=s.name,
auditor_name=s.auditor_name,
status=s.status.value,
total_items=s.total_items,
completed_items=s.completed_items,
completion_percentage=s.completion_percentage,
created_at=s.created_at,
started_at=s.started_at,
completed_at=s.completed_at,
)
for s in sessions
]
@router.get("/sessions/{session_id}", response_model=AuditSessionDetailResponse)
async def get_audit_session(
session_id: str,
db: Session = Depends(get_db),
):
"""
Get detailed information about a specific audit session.
"""
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
# Get sign-off statistics
signoffs = db.query(AuditSignOffDB).filter(AuditSignOffDB.session_id == session_id).all()
stats = AuditStatistics(
total=session.total_items,
compliant=sum(1 for s in signoffs if s.result == AuditResultEnum.COMPLIANT),
compliant_with_notes=sum(1 for s in signoffs if s.result == AuditResultEnum.COMPLIANT_WITH_NOTES),
non_compliant=sum(1 for s in signoffs if s.result == AuditResultEnum.NON_COMPLIANT),
not_applicable=sum(1 for s in signoffs if s.result == AuditResultEnum.NOT_APPLICABLE),
pending=session.total_items - len(signoffs),
completion_percentage=session.completion_percentage,
)
return AuditSessionDetailResponse(
id=session.id,
name=session.name,
description=session.description,
auditor_name=session.auditor_name,
auditor_email=session.auditor_email,
auditor_organization=session.auditor_organization,
status=session.status.value,
regulation_ids=session.regulation_ids,
total_items=session.total_items,
completed_items=session.completed_items,
compliant_count=session.compliant_count,
non_compliant_count=session.non_compliant_count,
completion_percentage=session.completion_percentage,
created_at=session.created_at,
started_at=session.started_at,
completed_at=session.completed_at,
statistics=stats,
)
@router.put("/sessions/{session_id}/start")
async def start_audit_session(
session_id: str,
db: Session = Depends(get_db),
):
"""
Start an audit session (change status from draft to in_progress).
"""
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
if session.status != AuditSessionStatusEnum.DRAFT:
raise HTTPException(
status_code=400,
detail=f"Session cannot be started. Current status: {session.status.value}"
)
session.status = AuditSessionStatusEnum.IN_PROGRESS
session.started_at = datetime.now(timezone.utc)
db.commit()
return {"success": True, "message": "Audit session started", "status": "in_progress"}
@router.put("/sessions/{session_id}/complete")
async def complete_audit_session(
session_id: str,
db: Session = Depends(get_db),
):
"""
Complete an audit session (change status from in_progress to completed).
"""
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
if session.status != AuditSessionStatusEnum.IN_PROGRESS:
raise HTTPException(
status_code=400,
detail=f"Session cannot be completed. Current status: {session.status.value}"
)
session.status = AuditSessionStatusEnum.COMPLETED
session.completed_at = datetime.now(timezone.utc)
db.commit()
return {"success": True, "message": "Audit session completed", "status": "completed"}
@router.put("/sessions/{session_id}/archive")
async def archive_audit_session(
session_id: str,
db: Session = Depends(get_db),
):
"""
Archive a completed audit session.
"""
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
if session.status != AuditSessionStatusEnum.COMPLETED:
raise HTTPException(
status_code=400,
detail=f"Only completed sessions can be archived. Current status: {session.status.value}"
)
session.status = AuditSessionStatusEnum.ARCHIVED
db.commit()
return {"success": True, "message": "Audit session archived", "status": "archived"}
@router.delete("/sessions/{session_id}")
async def delete_audit_session(
session_id: str,
db: Session = Depends(get_db),
):
"""
Delete an audit session and all its sign-offs.
Only draft sessions can be deleted.
"""
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
if session.status not in [AuditSessionStatusEnum.DRAFT, AuditSessionStatusEnum.ARCHIVED]:
raise HTTPException(
status_code=400,
detail=f"Cannot delete session with status: {session.status.value}. Archive it first."
)
# Delete all sign-offs first (cascade should handle this, but be explicit)
db.query(AuditSignOffDB).filter(AuditSignOffDB.session_id == session_id).delete()
# Delete the session
db.delete(session)
db.commit()
return {"success": True, "message": f"Audit session {session_id} deleted"}
# ============================================================================
# Audit Checklist & Sign-off
# ============================================================================
@router.get("/checklist/{session_id}", response_model=AuditChecklistResponse)
async def get_audit_checklist(
session_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
status_filter: Optional[str] = None,
regulation_filter: Optional[str] = None,
search: Optional[str] = None,
db: Session = Depends(get_db),
):
"""
Get the audit checklist for a session with pagination.
Returns requirements with their current sign-off status.
"""
# Get the session
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
# Build base query for requirements
query = db.query(RequirementDB).join(RegulationDB)
# Apply session's regulation filter
if session.regulation_ids:
query = query.filter(RegulationDB.code.in_(session.regulation_ids))
# Apply additional filters
if regulation_filter:
query = query.filter(RegulationDB.code == regulation_filter)
if search:
search_term = f"%{search}%"
query = query.filter(
(RequirementDB.title.ilike(search_term)) |
(RequirementDB.article.ilike(search_term)) |
(RequirementDB.description.ilike(search_term))
)
# Get total count before pagination
total_count = query.count()
# Apply pagination
requirements = (
query
.order_by(RegulationDB.code, RequirementDB.article)
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
# Get existing sign-offs for these requirements
req_ids = [r.id for r in requirements]
signoffs = (
db.query(AuditSignOffDB)
.filter(AuditSignOffDB.session_id == session_id)
.filter(AuditSignOffDB.requirement_id.in_(req_ids))
.all()
)
signoff_map = {s.requirement_id: s for s in signoffs}
# Get control mappings counts
mapping_counts = (
db.query(ControlMappingDB.requirement_id, func.count(ControlMappingDB.id))
.filter(ControlMappingDB.requirement_id.in_(req_ids))
.group_by(ControlMappingDB.requirement_id)
.all()
)
mapping_count_map = dict(mapping_counts)
# Build checklist items
items = []
for req in requirements:
signoff = signoff_map.get(req.id)
# Apply status filter if specified
if status_filter:
if status_filter == "pending" and signoff is not None:
continue
elif status_filter != "pending" and (signoff is None or signoff.result.value != status_filter):
continue
item = AuditChecklistItem(
requirement_id=req.id,
regulation_code=req.regulation.code,
article=req.article,
paragraph=req.paragraph,
title=req.title,
description=req.description,
current_result=signoff.result.value if signoff else "pending",
notes=signoff.notes if signoff else None,
is_signed=signoff.signature_hash is not None if signoff else False,
signed_at=signoff.signed_at if signoff else None,
signed_by=signoff.signed_by if signoff else None,
evidence_count=0, # TODO: Add evidence count
controls_mapped=mapping_count_map.get(req.id, 0),
implementation_status=req.implementation_status,
priority=req.priority,
)
items.append(item)
# Calculate statistics
all_signoffs = db.query(AuditSignOffDB).filter(AuditSignOffDB.session_id == session_id).all()
stats = AuditStatistics(
total=session.total_items,
compliant=sum(1 for s in all_signoffs if s.result == AuditResultEnum.COMPLIANT),
compliant_with_notes=sum(1 for s in all_signoffs if s.result == AuditResultEnum.COMPLIANT_WITH_NOTES),
non_compliant=sum(1 for s in all_signoffs if s.result == AuditResultEnum.NON_COMPLIANT),
not_applicable=sum(1 for s in all_signoffs if s.result == AuditResultEnum.NOT_APPLICABLE),
pending=session.total_items - len(all_signoffs),
completion_percentage=session.completion_percentage,
)
return AuditChecklistResponse(
session=AuditSessionSummary(
id=session.id,
name=session.name,
auditor_name=session.auditor_name,
status=session.status.value,
total_items=session.total_items,
completed_items=session.completed_items,
completion_percentage=session.completion_percentage,
created_at=session.created_at,
started_at=session.started_at,
completed_at=session.completed_at,
),
items=items,
pagination=PaginationMeta(
page=page,
page_size=page_size,
total=total_count,
total_pages=(total_count + page_size - 1) // page_size,
),
statistics=stats,
)
@router.put("/checklist/{session_id}/items/{requirement_id}/sign-off", response_model=SignOffResponse)
async def sign_off_item(
session_id: str,
requirement_id: str,
request: SignOffRequest,
db: Session = Depends(get_db),
):
"""
Sign off on a specific requirement in an audit session.
If sign=True, creates a digital signature (SHA-256 hash).
"""
# Validate session exists and is in progress
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
if session.status not in [AuditSessionStatusEnum.DRAFT, AuditSessionStatusEnum.IN_PROGRESS]:
raise HTTPException(
status_code=400,
detail=f"Cannot sign off items in session with status: {session.status.value}"
)
# Validate requirement exists
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")
# Map string result to enum
try:
result_enum = AuditResultEnum(request.result)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid result: {request.result}. Valid values: compliant, compliant_notes, non_compliant, not_applicable, pending"
)
# Check if sign-off already exists
signoff = (
db.query(AuditSignOffDB)
.filter(AuditSignOffDB.session_id == session_id)
.filter(AuditSignOffDB.requirement_id == requirement_id)
.first()
)
was_new = signoff is None
old_result = signoff.result if signoff else None
if signoff:
# Update existing sign-off
signoff.result = result_enum
signoff.notes = request.notes
signoff.updated_at = datetime.now(timezone.utc)
else:
# Create new sign-off
signoff = AuditSignOffDB(
id=str(uuid4()),
session_id=session_id,
requirement_id=requirement_id,
result=result_enum,
notes=request.notes,
)
db.add(signoff)
# Create digital signature if requested
signature = None
if request.sign:
timestamp = datetime.now(timezone.utc).isoformat()
data = f"{result_enum.value}|{requirement_id}|{session.auditor_name}|{timestamp}"
signature = hashlib.sha256(data.encode()).hexdigest()
signoff.signature_hash = signature
signoff.signed_at = datetime.now(timezone.utc)
signoff.signed_by = session.auditor_name
# Update session statistics
if was_new:
session.completed_items += 1
# Update compliant/non-compliant counts
if old_result != result_enum:
if old_result == AuditResultEnum.COMPLIANT or old_result == AuditResultEnum.COMPLIANT_WITH_NOTES:
session.compliant_count = max(0, session.compliant_count - 1)
elif old_result == AuditResultEnum.NON_COMPLIANT:
session.non_compliant_count = max(0, session.non_compliant_count - 1)
if result_enum == AuditResultEnum.COMPLIANT or result_enum == AuditResultEnum.COMPLIANT_WITH_NOTES:
session.compliant_count += 1
elif result_enum == AuditResultEnum.NON_COMPLIANT:
session.non_compliant_count += 1
# Auto-start session if this is the first sign-off
if session.status == AuditSessionStatusEnum.DRAFT:
session.status = AuditSessionStatusEnum.IN_PROGRESS
session.started_at = datetime.now(timezone.utc)
db.commit()
db.refresh(signoff)
return SignOffResponse(
id=signoff.id,
session_id=signoff.session_id,
requirement_id=signoff.requirement_id,
result=signoff.result.value,
notes=signoff.notes,
is_signed=signoff.signature_hash is not None,
signature_hash=signoff.signature_hash,
signed_at=signoff.signed_at,
signed_by=signoff.signed_by,
created_at=signoff.created_at,
updated_at=signoff.updated_at,
)
@router.get("/checklist/{session_id}/items/{requirement_id}", response_model=SignOffResponse)
async def get_sign_off(
session_id: str,
requirement_id: str,
db: Session = Depends(get_db),
):
"""
Get the current sign-off status for a specific requirement.
"""
signoff = (
db.query(AuditSignOffDB)
.filter(AuditSignOffDB.session_id == session_id)
.filter(AuditSignOffDB.requirement_id == requirement_id)
.first()
)
if not signoff:
raise HTTPException(
status_code=404,
detail=f"No sign-off found for requirement {requirement_id} in session {session_id}"
)
return SignOffResponse(
id=signoff.id,
session_id=signoff.session_id,
requirement_id=signoff.requirement_id,
result=signoff.result.value,
notes=signoff.notes,
is_signed=signoff.signature_hash is not None,
signature_hash=signoff.signature_hash,
signed_at=signoff.signed_at,
signed_by=signoff.signed_by,
created_at=signoff.created_at,
updated_at=signoff.updated_at,
)
# ============================================================================
# PDF Report Generation
# ============================================================================
@router.get("/sessions/{session_id}/report/pdf")
async def generate_audit_pdf_report(
session_id: str,
language: str = Query("de", pattern="^(de|en)$"),
include_signatures: bool = Query(True),
db: Session = Depends(get_db),
):
"""
Generate a PDF report for an audit session.
Parameters:
- session_id: The audit session ID
- language: Output language ('de' or 'en'), default 'de'
- include_signatures: Include digital signature verification section
Returns:
- PDF file as streaming response
"""
from fastapi.responses import StreamingResponse
import io
from ..services.audit_pdf_generator import AuditPDFGenerator
# Validate session exists
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
if not session:
raise HTTPException(
status_code=404,
detail=f"Audit session {session_id} not found"
)
try:
generator = AuditPDFGenerator(db)
pdf_bytes, filename = generator.generate(
session_id=session_id,
language=language,
include_signatures=include_signatures,
)
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}",
}
)
except Exception as e:
logger.error(f"Failed to generate PDF report: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to generate PDF report: {str(e)}"
)