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>
265 lines
9.9 KiB
Python
265 lines
9.9 KiB
Python
# 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}"},
|
|
)
|