# mypy: disable-error-code="arg-type,assignment" # See compliance/services/audit_session_service.py for rationale — SQLAlchemy # 1.x Column() descriptors are Column[T] statically but T at runtime. """ Audit Sign-Off service — audit checklist retrieval and per-requirement sign-off operations. Phase 1 Step 4: extracted from ``compliance/api/audit_routes.py``. HTTP-agnostic; raises ``compliance.domain`` errors translated at the route layer. """ import hashlib from datetime import datetime, timezone from typing import Optional from uuid import uuid4 from sqlalchemy import func from sqlalchemy.orm import Session from compliance.db.models import ( AuditResultEnum, AuditSessionDB, AuditSessionStatusEnum, AuditSignOffDB, ControlMappingDB, RegulationDB, RequirementDB, ) from compliance.domain import ConflictError, NotFoundError, ValidationError from compliance.schemas.audit_session import ( AuditChecklistItem, AuditChecklistResponse, AuditSessionSummary, AuditStatistics, SignOffRequest, SignOffResponse, ) from compliance.schemas.common import PaginationMeta class AuditSignOffService: """Business logic for audit checklist & per-requirement sign-offs.""" def __init__(self, db: Session) -> None: self.db = db # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _get_session_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 _signoff_to_response(signoff: AuditSignOffDB) -> SignOffResponse: 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, ) @staticmethod def _compute_stats( total: int, signoffs: list[AuditSignOffDB], completion_percentage: float ) -> AuditStatistics: return AuditStatistics( total=total, 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=total - len(signoffs), completion_percentage=completion_percentage, ) # ------------------------------------------------------------------ # Queries # ------------------------------------------------------------------ def get_checklist( self, session_id: str, page: int, page_size: int, status_filter: Optional[str], regulation_filter: Optional[str], search: Optional[str], ) -> AuditChecklistResponse: """Return the paginated audit checklist with per-requirement sign-off status.""" session = self._get_session_or_raise(session_id) query = self.db.query(RequirementDB).join(RegulationDB) if session.regulation_ids: query = query.filter(RegulationDB.code.in_(session.regulation_ids)) if regulation_filter: query = query.filter(RegulationDB.code == regulation_filter) if search: term = f"%{search}%" query = query.filter( (RequirementDB.title.ilike(term)) | (RequirementDB.article.ilike(term)) | (RequirementDB.description.ilike(term)) ) total_count = query.count() requirements = ( query.order_by(RegulationDB.code, RequirementDB.article) .offset((page - 1) * page_size) .limit(page_size) .all() ) req_ids = [r.id for r in requirements] signoffs = ( self.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} mapping_counts = ( self.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[str, int] = dict(mapping_counts) items: list[AuditChecklistItem] = [] for req in requirements: signoff = signoff_map.get(req.id) if status_filter: if status_filter == "pending" and signoff is not None: continue if status_filter != "pending" and ( signoff is None or signoff.result.value != status_filter ): continue items.append( 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(str(req.id), 0), implementation_status=req.implementation_status, priority=req.priority, ) ) all_signoffs = ( self.db.query(AuditSignOffDB) .filter(AuditSignOffDB.session_id == session_id) .all() ) stats = self._compute_stats( session.total_items, all_signoffs, 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, has_next=page * page_size < total_count, has_prev=page > 1, ), statistics=stats, ) def get_sign_off(self, session_id: str, requirement_id: str) -> SignOffResponse: """Return a single sign-off record for (session, requirement).""" signoff = ( self.db.query(AuditSignOffDB) .filter(AuditSignOffDB.session_id == session_id) .filter(AuditSignOffDB.requirement_id == requirement_id) .first() ) if not signoff: raise NotFoundError( f"No sign-off found for requirement {requirement_id} in session {session_id}" ) return self._signoff_to_response(signoff) # ------------------------------------------------------------------ # Commands # ------------------------------------------------------------------ def sign_off( self, session_id: str, requirement_id: str, request: SignOffRequest, ) -> SignOffResponse: """Create or update a sign-off; optionally produce a SHA-256 digital signature.""" session = self._get_session_or_raise(session_id) if session.status not in ( AuditSessionStatusEnum.DRAFT, AuditSessionStatusEnum.IN_PROGRESS, ): raise ConflictError( f"Cannot sign off items in session with status: {session.status.value}" ) requirement = ( self.db.query(RequirementDB) .filter(RequirementDB.id == requirement_id) .first() ) if not requirement: raise NotFoundError(f"Requirement {requirement_id} not found") try: result_enum = AuditResultEnum(request.result) except ValueError as exc: raise ValidationError( "Invalid result: " f"{request.result}. Valid values: compliant, compliant_notes, " "non_compliant, not_applicable, pending" ) from exc signoff = ( self.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: signoff.result = result_enum signoff.notes = request.notes signoff.updated_at = datetime.now(timezone.utc) else: signoff = AuditSignOffDB( id=str(uuid4()), session_id=session_id, requirement_id=requirement_id, result=result_enum, notes=request.notes, ) self.db.add(signoff) if request.sign: timestamp = datetime.now(timezone.utc).isoformat() data = ( f"{result_enum.value}|{requirement_id}|{session.auditor_name}|{timestamp}" ) signoff.signature_hash = hashlib.sha256(data.encode()).hexdigest() signoff.signed_at = datetime.now(timezone.utc) signoff.signed_by = session.auditor_name if was_new: session.completed_items += 1 if old_result != result_enum: if old_result in ( AuditResultEnum.COMPLIANT, 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 in ( AuditResultEnum.COMPLIANT, AuditResultEnum.COMPLIANT_WITH_NOTES, ): session.compliant_count += 1 elif result_enum == AuditResultEnum.NON_COMPLIANT: session.non_compliant_count += 1 if session.status == AuditSessionStatusEnum.DRAFT: session.status = AuditSessionStatusEnum.IN_PROGRESS session.started_at = datetime.now(timezone.utc) self.db.commit() self.db.refresh(signoff) return self._signoff_to_response(signoff)