Files
breakpilot-compliance/backend-compliance/compliance/services/audit_signoff_service.py
Sharang Parnerkar 883ef702ac tech-debt: mypy --strict config + integration tests for audit routes
Phase 1 Step 4 follow-up addressing the debt flagged in the worked-example
commit (4a91814).

## mypy --strict policy

Adds backend-compliance/mypy.ini declaring the strict-mode scope:

  Fully strict (enforced today):
    - compliance/domain/
    - compliance/schemas/
    - compliance/api/_http_errors.py
    - compliance/api/audit_routes.py        (refactored in Step 4)
    - compliance/services/audit_session_service.py
    - compliance/services/audit_signoff_service.py

  Loose (ignore_errors=True) with a migration path:
    - compliance/db/*                        — SQLAlchemy 1.x Column[] vs
                                               runtime T; unblocks Phase 1
                                               until a Mapped[T] migration.
    - compliance/api/<route>.py              — each route file flips to
                                               strict as its own Step 4
                                               refactor lands.
    - compliance/services/<legacy util>      — 14 utility services
                                               (llm_provider, pdf_extractor,
                                               seeder, ...) that predate the
                                               clean-arch refactor.
    - compliance/tests/                      — excluded (legacy placeholder
                                               style). The new TestClient-
                                               based integration suite is
                                               type-annotated.

The two new service files carry a scoped `# mypy: disable-error-code="arg-type,assignment"`
header for the ORM Column[T] issue — same underlying SQLAlchemy limitation,
narrowly scoped rather than wholesale ignore_errors.

Flow: `cd backend-compliance && mypy compliance/` -> clean on 119 files.
CI yaml updated to use the config instead of ad-hoc package lists.

## Bugs fixed while enabling strict

mypy --strict surfaced two latent bugs in the pre-refactor code. Both
were invisible because the old `compliance/tests/test_audit_routes.py`
is a placeholder suite that asserts on request-data shape and never
calls the handlers:

  - AuditSessionResponse.updated_at is a required field in the schema,
    but the original handler didn't pass it. Fixed in
    AuditSessionService._to_response.

  - PaginationMeta requires has_next + has_prev. The original audit
    checklist handler didn't compute them. Fixed in
    AuditSignOffService.get_checklist.

Both are behavior-preserving at the HTTP level because the old code
would have raised Pydantic ValidationError at response serialization
had the endpoint actually been exercised.

## Integration test suite

Adds backend-compliance/tests/test_audit_routes_integration.py — 26
real TestClient tests against an in-memory sqlite backend (StaticPool).
Replaces the coverage gap left by the placeholder suite.

Covers:
  - Session CRUD + lifecycle transitions (draft -> in_progress -> completed
    -> archived), including the 409 paths for illegal transitions
  - Checklist pagination, filtering, search
  - Sign-off create / update / auto-start-session / count-flipping
  - Sign-off 400 (invalid result), 404 (missing requirement), 409 (completed session)
  - Get-signoff 404 / 200 round-trip

Uses a module-scoped schema fixture + per-test DELETE-sweep so the
suite runs in ~2.3s despite the ~50-table ORM surface.

Verified:
  - 199/199 pytest (173 original + 26 new audit integration) pass
  - tests/contracts/test_openapi_baseline.py green, OpenAPI 360/484 unchanged
  - mypy compliance/ -> Success: no issues found in 119 source files

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

325 lines
12 KiB
Python

# 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)