# mypy: disable-error-code="arg-type,assignment" # SQLAlchemy 1.x-style Column() descriptors are typed as Column[T] at static- # analysis time but return T at runtime. Converting models to Mapped[T] is # out of scope for Phase 1. Scoped ignore lets the rest of --strict apply. """ Audit Session service — lifecycle of audit sessions (create, list, get, start, complete, archive, delete, PDF). Phase 1 Step 4: extracted from ``compliance/api/audit_routes.py`` so the route layer becomes thin delegation. This module is HTTP-agnostic: it raises ``compliance.domain`` errors which the route layer translates to ``HTTPException`` via ``compliance.api._http_errors.translate_domain_errors``. Checklist and sign-off operations live in ``compliance.services.audit_signoff_service.AuditSignOffService``. """ import io import logging from datetime import datetime, timezone from typing import Any, List, Optional from uuid import uuid4 from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from compliance.db.models import ( AuditResultEnum, AuditSessionDB, AuditSessionStatusEnum, AuditSignOffDB, RegulationDB, RequirementDB, ) from compliance.domain import ( ConflictError, DomainError, NotFoundError, ValidationError, ) from compliance.schemas.audit_session import ( AuditSessionDetailResponse, AuditSessionResponse, AuditSessionSummary, AuditStatistics, CreateAuditSessionRequest, ) logger = logging.getLogger(__name__) class AuditSessionService: """Business logic for audit session lifecycle.""" def __init__(self, db: Session) -> None: self.db = db # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _get_or_raise(self, session_id: str) -> AuditSessionDB: session = ( self.db.query(AuditSessionDB) .filter(AuditSessionDB.id == session_id) .first() ) if not session: raise NotFoundError(f"Audit session {session_id} not found") return session @staticmethod def _to_summary(s: AuditSessionDB) -> AuditSessionSummary: 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, ) @staticmethod def _to_response(s: AuditSessionDB) -> AuditSessionResponse: return AuditSessionResponse( id=s.id, name=s.name, description=s.description, auditor_name=s.auditor_name, auditor_email=s.auditor_email, auditor_organization=s.auditor_organization, status=s.status.value, regulation_ids=s.regulation_ids, total_items=s.total_items, completed_items=s.completed_items, compliant_count=s.compliant_count, non_compliant_count=s.non_compliant_count, completion_percentage=s.completion_percentage, created_at=s.created_at, started_at=s.started_at, completed_at=s.completed_at, updated_at=s.updated_at, ) # ------------------------------------------------------------------ # Commands # ------------------------------------------------------------------ def create(self, request: CreateAuditSessionRequest) -> AuditSessionResponse: """Create a new audit session for structured compliance reviews.""" query = self.db.query(RequirementDB) if request.regulation_codes: reg_ids = ( self.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() 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, ) self.db.add(session) self.db.commit() self.db.refresh(session) return self._to_response(session) def list(self, status: Optional[str] = None) -> List[AuditSessionSummary]: """List all audit sessions, optionally filtered by status.""" query = self.db.query(AuditSessionDB) if status: try: status_enum = AuditSessionStatusEnum(status) except ValueError as exc: raise ValidationError( f"Invalid status: {status}. Valid values: draft, in_progress, completed, archived" ) from exc query = query.filter(AuditSessionDB.status == status_enum) sessions = query.order_by(AuditSessionDB.created_at.desc()).all() return [self._to_summary(s) for s in sessions] def get(self, session_id: str) -> AuditSessionDetailResponse: """Get detailed information about a specific audit session.""" session = self._get_or_raise(session_id) signoffs = ( self.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, ) base = self._to_response(session) return AuditSessionDetailResponse(**base.model_dump(), statistics=stats) def start(self, session_id: str) -> dict[str, Any]: """Move a session from draft to in_progress.""" session = self._get_or_raise(session_id) if session.status != AuditSessionStatusEnum.DRAFT: raise ConflictError( f"Session cannot be started. Current status: {session.status.value}" ) session.status = AuditSessionStatusEnum.IN_PROGRESS session.started_at = datetime.now(timezone.utc) self.db.commit() return {"success": True, "message": "Audit session started", "status": "in_progress"} def complete(self, session_id: str) -> dict[str, Any]: """Move a session from in_progress to completed.""" session = self._get_or_raise(session_id) if session.status != AuditSessionStatusEnum.IN_PROGRESS: raise ConflictError( f"Session cannot be completed. Current status: {session.status.value}" ) session.status = AuditSessionStatusEnum.COMPLETED session.completed_at = datetime.now(timezone.utc) self.db.commit() return {"success": True, "message": "Audit session completed", "status": "completed"} def archive(self, session_id: str) -> dict[str, Any]: """Archive a completed audit session.""" session = self._get_or_raise(session_id) if session.status != AuditSessionStatusEnum.COMPLETED: raise ConflictError( f"Only completed sessions can be archived. Current status: {session.status.value}" ) session.status = AuditSessionStatusEnum.ARCHIVED self.db.commit() return {"success": True, "message": "Audit session archived", "status": "archived"} def delete(self, session_id: str) -> dict[str, Any]: """Delete a draft or archived session.""" session = self._get_or_raise(session_id) if session.status not in ( AuditSessionStatusEnum.DRAFT, AuditSessionStatusEnum.ARCHIVED, ): raise ConflictError( f"Cannot delete session with status: {session.status.value}. Archive it first." ) self.db.query(AuditSignOffDB).filter( AuditSignOffDB.session_id == session_id ).delete() self.db.delete(session) self.db.commit() return {"success": True, "message": f"Audit session {session_id} deleted"} def generate_pdf( self, session_id: str, language: str, include_signatures: bool, ) -> StreamingResponse: """Generate a PDF audit report and return a streaming response.""" from compliance.services.audit_pdf_generator import AuditPDFGenerator self._get_or_raise(session_id) try: generator = AuditPDFGenerator(self.db) pdf_bytes, filename = generator.generate( session_id=session_id, language=language, include_signatures=include_signatures, ) except Exception as exc: logger.error(f"Failed to generate PDF report: {exc}") raise DomainError(f"Failed to generate PDF report: {exc}") from exc return StreamingResponse( io.BytesIO(pdf_bytes), media_type="application/pdf", headers={"Content-Disposition": f"attachment; filename={filename}"}, )