""" 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, AuditSessionListResponse, 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 AuditSessionDetail( 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)}" )