Files
breakpilot-compliance/backend-compliance/compliance/api/audit_routes.py
Benjamin Admin 95fcba34cd
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 30s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s
fix(quality): Ruff/CVE/TS-Fixes, 104 neue Tests, Complexity-Refactoring
- Ruff: 144 auto-fixes (unused imports, == None → is None), F821/F811/F841 manuell
- CVEs: python-multipart>=0.0.22, weasyprint>=68.0, pillow>=12.1.1, npm audit fix (0 vulns)
- TS: 5 tote Drafting-Engine-Dateien entfernt, allowed-facts/sanitizer/StepHeader/context fixes
- Tests: +104 (ISMS 58, Evidence 18, VVT 14, Generation 14) → 1449 passed
- Refactoring: collect_ci_evidence (F→A), row_to_response (E→A), extract_requirements (E→A)
- Dead Code: pca-platform, 7 Go-Handler, dsr_api.py, duplicate Schemas entfernt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:00:33 +01: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
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.utcnow()
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.utcnow()
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.utcnow()
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.utcnow().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.utcnow()
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.utcnow()
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", regex="^(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)}"
)