refactor(backend/api): extract AuditSession service layer (Step 4 worked example)
Phase 1 Step 4 of PHASE1_RUNBOOK.md, first worked example. Demonstrates
the router -> service delegation pattern for all 18 oversized route
files still above the 500 LOC hard cap.
compliance/api/audit_routes.py (637 LOC) is decomposed into:
compliance/api/audit_routes.py (198) — thin handlers
compliance/services/audit_session_service.py (259) — session lifecycle
compliance/services/audit_signoff_service.py (319) — checklist + sign-off
compliance/api/_http_errors.py ( 43) — reusable error translator
Handlers shrink to 3-6 lines each:
@router.post("/sessions", response_model=AuditSessionResponse)
async def create_audit_session(
request: CreateAuditSessionRequest,
service: AuditSessionService = Depends(get_audit_session_service),
):
with translate_domain_errors():
return service.create(request)
Services are HTTP-agnostic: they raise NotFoundError / ConflictError /
ValidationError from compliance.domain, and the route layer translates
those to HTTPException(404/409/400) via the translate_domain_errors()
context manager in compliance.api._http_errors. The error translator is
reusable by every future Step 4 refactor.
Services take a sqlalchemy Session in the constructor and are wired via
Depends factories (get_audit_session_service / get_audit_signoff_service).
No globals, no module-level state.
Behavior is byte-identical at the HTTP boundary:
- Same paths, methods, status codes, response models
- Same error messages (domain error __str__ preserved)
- Same auto-start-on-first-signoff, same statistics calculation,
same signature hash format, same PDF streaming response
Verified:
- 173/173 pytest compliance/tests/ tests/contracts/ pass
- OpenAPI 360 paths / 484 operations unchanged
- audit_routes.py under soft 300 target
- Both new service files under soft 300 / hard 500
Note: compliance/tests/test_audit_routes.py contains placeholder tests
that do not actually import or call the handler functions — they only
assert on request-data shape. Real behavioral coverage relies on the
contract test. A follow-up commit should add TestClient-based
integration tests for the audit endpoints. Flagged in PHASE1_RUNBOOK.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
259
backend-compliance/compliance/services/audit_session_service.py
Normal file
259
backend-compliance/compliance/services/audit_session_service.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
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 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,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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:
|
||||
"""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:
|
||||
"""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:
|
||||
"""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:
|
||||
"""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}"},
|
||||
)
|
||||
Reference in New Issue
Block a user