Squash of branch refactor/phase0-guardrails-and-models-split — 4 commits,
81 files, 173/173 pytest green, OpenAPI contract preserved (360 paths /
484 operations).
## Phase 0 — Architecture guardrails
Three defense-in-depth layers to keep the architecture rules enforced
regardless of who opens Claude Code in this repo:
1. .claude/settings.json PreToolUse hook on Write/Edit blocks any file
that would exceed the 500-line hard cap. Auto-loads in every Claude
session in this repo.
2. scripts/githooks/pre-commit (install via scripts/install-hooks.sh)
enforces the LOC cap locally, freezes migrations/ without
[migration-approved], and protects guardrail files without
[guardrail-change].
3. .gitea/workflows/ci.yaml gains loc-budget + guardrail-integrity +
sbom-scan (syft+grype) jobs, adds mypy --strict for the new Python
packages (compliance/{services,repositories,domain,schemas}), and
tsc --noEmit for admin-compliance + developer-portal.
Per-language conventions documented in AGENTS.python.md, AGENTS.go.md,
AGENTS.typescript.md at the repo root — layering, tooling, and explicit
"what you may NOT do" lists. Root CLAUDE.md is prepended with the six
non-negotiable rules. Each of the 10 services gets a README.md.
scripts/check-loc.sh enforces soft 300 / hard 500 and surfaces the
current baseline of 205 hard + 161 soft violations so Phases 1-4 can
drain it incrementally. CI gates only CHANGED files in PRs so the
legacy baseline does not block unrelated work.
## Deprecation sweep
47 files. Pydantic V1 regex= -> pattern= (2 sites), class Config ->
ConfigDict in source_policy_router.py (schemas.py intentionally skipped;
it is the Phase 1 Step 3 split target). datetime.utcnow() ->
datetime.now(timezone.utc) everywhere including SQLAlchemy default=
callables. All DB columns already declare timezone=True, so this is a
latent-bug fix at the Python side, not a schema change.
DeprecationWarning count dropped from 158 to 35.
## Phase 1 Step 1 — Contract test harness
tests/contracts/test_openapi_baseline.py diffs the live FastAPI /openapi.json
against tests/contracts/openapi.baseline.json on every test run. Fails on
removed paths, removed status codes, or new required request body fields.
Regenerate only via tests/contracts/regenerate_baseline.py after a
consumer-updated contract change. This is the safety harness for all
subsequent refactor commits.
## Phase 1 Step 2 — models.py split (1466 -> 85 LOC shim)
compliance/db/models.py is decomposed into seven sibling aggregate modules
following the existing repo pattern (dsr_models.py, vvt_models.py, ...):
regulation_models.py (134) — Regulation, Requirement
control_models.py (279) — Control, Mapping, Evidence, Risk
ai_system_models.py (141) — AISystem, AuditExport
service_module_models.py (176) — ServiceModule, ModuleRegulation, ModuleRisk
audit_session_models.py (177) — AuditSession, AuditSignOff
isms_governance_models.py (323) — ISMSScope, Context, Policy, Objective, SoA
isms_audit_models.py (468) — Finding, CAPA, MgmtReview, InternalAudit,
AuditTrail, Readiness
models.py becomes an 85-line re-export shim in dependency order so
existing imports continue to work unchanged. Schema is byte-identical:
__tablename__, column definitions, relationship strings, back_populates,
cascade directives all preserved.
All new sibling files are under the 500-line hard cap; largest is
isms_audit_models.py at 468. No file in compliance/db/ now exceeds
the hard cap.
## Phase 1 Step 3 — infrastructure only
backend-compliance/compliance/{schemas,domain,repositories}/ packages
are created as landing zones with docstrings. compliance/domain/
exports DomainError / NotFoundError / ConflictError / ValidationError /
PermissionError — the base classes services will use to raise
domain-level errors instead of HTTPException.
PHASE1_RUNBOOK.md at backend-compliance/PHASE1_RUNBOOK.md documents
the nine-step execution plan for Phase 1: snapshot baseline,
characterization tests, split models.py (this commit), split schemas.py
(next), extract services, extract repositories, mypy --strict, coverage.
## Verification
backend-compliance/.venv-phase1: uv python install 3.12 + pip -r requirements.txt
PYTHONPATH=. pytest compliance/tests/ tests/contracts/
-> 173 passed, 0 failed, 35 warnings, OpenAPI 360/484 unchanged
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
638 lines
22 KiB
Python
638 lines
22 KiB
Python
"""
|
|
FastAPI routes for Audit Sessions & Sign-off functionality.
|
|
|
|
Sprint 3 Phase 3: Auditor-Verbesserungen
|
|
|
|
Endpoints:
|
|
- /audit/sessions: Manage audit sessions
|
|
- /audit/checklist: Audit checklist with sign-off
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Optional, List
|
|
from uuid import uuid4
|
|
import hashlib
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import func
|
|
|
|
from classroom_engine.database import get_db
|
|
|
|
from ..db.models import (
|
|
AuditSessionDB, AuditSignOffDB, AuditResultEnum, AuditSessionStatusEnum,
|
|
RequirementDB, RegulationDB, ControlMappingDB
|
|
)
|
|
from .schemas import (
|
|
CreateAuditSessionRequest, AuditSessionResponse, AuditSessionSummary, AuditSessionDetailResponse,
|
|
SignOffRequest, SignOffResponse,
|
|
AuditChecklistItem, AuditChecklistResponse, AuditStatistics,
|
|
PaginationMeta,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/audit", tags=["compliance-audit"])
|
|
|
|
|
|
# ============================================================================
|
|
# Audit Sessions
|
|
# ============================================================================
|
|
|
|
@router.post("/sessions", response_model=AuditSessionResponse)
|
|
async def create_audit_session(
|
|
request: CreateAuditSessionRequest,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Create a new audit session for structured compliance reviews.
|
|
|
|
An audit session groups requirements for systematic review by an auditor.
|
|
"""
|
|
# Get total requirements count based on filters
|
|
query = db.query(RequirementDB)
|
|
if request.regulation_codes:
|
|
reg_ids = 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()
|
|
|
|
# Create the session
|
|
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,
|
|
)
|
|
|
|
db.add(session)
|
|
db.commit()
|
|
db.refresh(session)
|
|
|
|
return AuditSessionResponse(
|
|
id=session.id,
|
|
name=session.name,
|
|
description=session.description,
|
|
auditor_name=session.auditor_name,
|
|
auditor_email=session.auditor_email,
|
|
auditor_organization=session.auditor_organization,
|
|
status=session.status.value,
|
|
regulation_ids=session.regulation_ids,
|
|
total_items=session.total_items,
|
|
completed_items=session.completed_items,
|
|
compliant_count=session.compliant_count,
|
|
non_compliant_count=session.non_compliant_count,
|
|
completion_percentage=session.completion_percentage,
|
|
created_at=session.created_at,
|
|
started_at=session.started_at,
|
|
completed_at=session.completed_at,
|
|
)
|
|
|
|
|
|
@router.get("/sessions", response_model=List[AuditSessionSummary])
|
|
async def list_audit_sessions(
|
|
status: Optional[str] = None,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
List all audit sessions, optionally filtered by status.
|
|
"""
|
|
query = db.query(AuditSessionDB)
|
|
|
|
if status:
|
|
try:
|
|
status_enum = AuditSessionStatusEnum(status)
|
|
query = query.filter(AuditSessionDB.status == status_enum)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid status: {status}. Valid values: draft, in_progress, completed, archived"
|
|
)
|
|
|
|
sessions = query.order_by(AuditSessionDB.created_at.desc()).all()
|
|
|
|
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,
|
|
)
|
|
for s in sessions
|
|
]
|
|
|
|
|
|
@router.get("/sessions/{session_id}", response_model=AuditSessionDetailResponse)
|
|
async def get_audit_session(
|
|
session_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get detailed information about a specific audit session.
|
|
"""
|
|
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
|
|
|
|
# Get sign-off statistics
|
|
signoffs = 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,
|
|
)
|
|
|
|
return AuditSessionDetailResponse(
|
|
id=session.id,
|
|
name=session.name,
|
|
description=session.description,
|
|
auditor_name=session.auditor_name,
|
|
auditor_email=session.auditor_email,
|
|
auditor_organization=session.auditor_organization,
|
|
status=session.status.value,
|
|
regulation_ids=session.regulation_ids,
|
|
total_items=session.total_items,
|
|
completed_items=session.completed_items,
|
|
compliant_count=session.compliant_count,
|
|
non_compliant_count=session.non_compliant_count,
|
|
completion_percentage=session.completion_percentage,
|
|
created_at=session.created_at,
|
|
started_at=session.started_at,
|
|
completed_at=session.completed_at,
|
|
statistics=stats,
|
|
)
|
|
|
|
|
|
@router.put("/sessions/{session_id}/start")
|
|
async def start_audit_session(
|
|
session_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Start an audit session (change status from draft to in_progress).
|
|
"""
|
|
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
|
|
|
|
if session.status != AuditSessionStatusEnum.DRAFT:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Session cannot be started. Current status: {session.status.value}"
|
|
)
|
|
|
|
session.status = AuditSessionStatusEnum.IN_PROGRESS
|
|
session.started_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
|
|
return {"success": True, "message": "Audit session started", "status": "in_progress"}
|
|
|
|
|
|
@router.put("/sessions/{session_id}/complete")
|
|
async def complete_audit_session(
|
|
session_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Complete an audit session (change status from in_progress to completed).
|
|
"""
|
|
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
|
|
|
|
if session.status != AuditSessionStatusEnum.IN_PROGRESS:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Session cannot be completed. Current status: {session.status.value}"
|
|
)
|
|
|
|
session.status = AuditSessionStatusEnum.COMPLETED
|
|
session.completed_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
|
|
return {"success": True, "message": "Audit session completed", "status": "completed"}
|
|
|
|
|
|
@router.put("/sessions/{session_id}/archive")
|
|
async def archive_audit_session(
|
|
session_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Archive a completed audit session.
|
|
"""
|
|
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
|
|
|
|
if session.status != AuditSessionStatusEnum.COMPLETED:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Only completed sessions can be archived. Current status: {session.status.value}"
|
|
)
|
|
|
|
session.status = AuditSessionStatusEnum.ARCHIVED
|
|
db.commit()
|
|
|
|
return {"success": True, "message": "Audit session archived", "status": "archived"}
|
|
|
|
|
|
@router.delete("/sessions/{session_id}")
|
|
async def delete_audit_session(
|
|
session_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Delete an audit session and all its sign-offs.
|
|
|
|
Only draft sessions can be deleted.
|
|
"""
|
|
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
|
|
|
|
if session.status not in [AuditSessionStatusEnum.DRAFT, AuditSessionStatusEnum.ARCHIVED]:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Cannot delete session with status: {session.status.value}. Archive it first."
|
|
)
|
|
|
|
# Delete all sign-offs first (cascade should handle this, but be explicit)
|
|
db.query(AuditSignOffDB).filter(AuditSignOffDB.session_id == session_id).delete()
|
|
|
|
# Delete the session
|
|
db.delete(session)
|
|
db.commit()
|
|
|
|
return {"success": True, "message": f"Audit session {session_id} deleted"}
|
|
|
|
|
|
# ============================================================================
|
|
# Audit Checklist & Sign-off
|
|
# ============================================================================
|
|
|
|
@router.get("/checklist/{session_id}", response_model=AuditChecklistResponse)
|
|
async def get_audit_checklist(
|
|
session_id: str,
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(50, ge=1, le=200),
|
|
status_filter: Optional[str] = None,
|
|
regulation_filter: Optional[str] = None,
|
|
search: Optional[str] = None,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get the audit checklist for a session with pagination.
|
|
|
|
Returns requirements with their current sign-off status.
|
|
"""
|
|
# Get the session
|
|
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
|
|
|
|
# Build base query for requirements
|
|
query = db.query(RequirementDB).join(RegulationDB)
|
|
|
|
# Apply session's regulation filter
|
|
if session.regulation_ids:
|
|
query = query.filter(RegulationDB.code.in_(session.regulation_ids))
|
|
|
|
# Apply additional filters
|
|
if regulation_filter:
|
|
query = query.filter(RegulationDB.code == regulation_filter)
|
|
|
|
if search:
|
|
search_term = f"%{search}%"
|
|
query = query.filter(
|
|
(RequirementDB.title.ilike(search_term)) |
|
|
(RequirementDB.article.ilike(search_term)) |
|
|
(RequirementDB.description.ilike(search_term))
|
|
)
|
|
|
|
# Get total count before pagination
|
|
total_count = query.count()
|
|
|
|
# Apply pagination
|
|
requirements = (
|
|
query
|
|
.order_by(RegulationDB.code, RequirementDB.article)
|
|
.offset((page - 1) * page_size)
|
|
.limit(page_size)
|
|
.all()
|
|
)
|
|
|
|
# Get existing sign-offs for these requirements
|
|
req_ids = [r.id for r in requirements]
|
|
signoffs = (
|
|
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}
|
|
|
|
# Get control mappings counts
|
|
mapping_counts = (
|
|
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(mapping_counts)
|
|
|
|
# Build checklist items
|
|
items = []
|
|
for req in requirements:
|
|
signoff = signoff_map.get(req.id)
|
|
|
|
# Apply status filter if specified
|
|
if status_filter:
|
|
if status_filter == "pending" and signoff is not None:
|
|
continue
|
|
elif status_filter != "pending" and (signoff is None or signoff.result.value != status_filter):
|
|
continue
|
|
|
|
item = 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(req.id, 0),
|
|
implementation_status=req.implementation_status,
|
|
priority=req.priority,
|
|
)
|
|
items.append(item)
|
|
|
|
# Calculate statistics
|
|
all_signoffs = db.query(AuditSignOffDB).filter(AuditSignOffDB.session_id == session_id).all()
|
|
stats = AuditStatistics(
|
|
total=session.total_items,
|
|
compliant=sum(1 for s in all_signoffs if s.result == AuditResultEnum.COMPLIANT),
|
|
compliant_with_notes=sum(1 for s in all_signoffs if s.result == AuditResultEnum.COMPLIANT_WITH_NOTES),
|
|
non_compliant=sum(1 for s in all_signoffs if s.result == AuditResultEnum.NON_COMPLIANT),
|
|
not_applicable=sum(1 for s in all_signoffs if s.result == AuditResultEnum.NOT_APPLICABLE),
|
|
pending=session.total_items - len(all_signoffs),
|
|
completion_percentage=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,
|
|
),
|
|
statistics=stats,
|
|
)
|
|
|
|
|
|
@router.put("/checklist/{session_id}/items/{requirement_id}/sign-off", response_model=SignOffResponse)
|
|
async def sign_off_item(
|
|
session_id: str,
|
|
requirement_id: str,
|
|
request: SignOffRequest,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Sign off on a specific requirement in an audit session.
|
|
|
|
If sign=True, creates a digital signature (SHA-256 hash).
|
|
"""
|
|
# Validate session exists and is in progress
|
|
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail=f"Audit session {session_id} not found")
|
|
|
|
if session.status not in [AuditSessionStatusEnum.DRAFT, AuditSessionStatusEnum.IN_PROGRESS]:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Cannot sign off items in session with status: {session.status.value}"
|
|
)
|
|
|
|
# Validate requirement exists
|
|
requirement = db.query(RequirementDB).filter(RequirementDB.id == requirement_id).first()
|
|
if not requirement:
|
|
raise HTTPException(status_code=404, detail=f"Requirement {requirement_id} not found")
|
|
|
|
# Map string result to enum
|
|
try:
|
|
result_enum = AuditResultEnum(request.result)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid result: {request.result}. Valid values: compliant, compliant_notes, non_compliant, not_applicable, pending"
|
|
)
|
|
|
|
# Check if sign-off already exists
|
|
signoff = (
|
|
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:
|
|
# Update existing sign-off
|
|
signoff.result = result_enum
|
|
signoff.notes = request.notes
|
|
signoff.updated_at = datetime.now(timezone.utc)
|
|
else:
|
|
# Create new sign-off
|
|
signoff = AuditSignOffDB(
|
|
id=str(uuid4()),
|
|
session_id=session_id,
|
|
requirement_id=requirement_id,
|
|
result=result_enum,
|
|
notes=request.notes,
|
|
)
|
|
db.add(signoff)
|
|
|
|
# Create digital signature if requested
|
|
signature = None
|
|
if request.sign:
|
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
data = f"{result_enum.value}|{requirement_id}|{session.auditor_name}|{timestamp}"
|
|
signature = hashlib.sha256(data.encode()).hexdigest()
|
|
signoff.signature_hash = signature
|
|
signoff.signed_at = datetime.now(timezone.utc)
|
|
signoff.signed_by = session.auditor_name
|
|
|
|
# Update session statistics
|
|
if was_new:
|
|
session.completed_items += 1
|
|
|
|
# Update compliant/non-compliant counts
|
|
if old_result != result_enum:
|
|
if old_result == AuditResultEnum.COMPLIANT or old_result == 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 == AuditResultEnum.COMPLIANT or result_enum == AuditResultEnum.COMPLIANT_WITH_NOTES:
|
|
session.compliant_count += 1
|
|
elif result_enum == AuditResultEnum.NON_COMPLIANT:
|
|
session.non_compliant_count += 1
|
|
|
|
# Auto-start session if this is the first sign-off
|
|
if session.status == AuditSessionStatusEnum.DRAFT:
|
|
session.status = AuditSessionStatusEnum.IN_PROGRESS
|
|
session.started_at = datetime.now(timezone.utc)
|
|
|
|
db.commit()
|
|
db.refresh(signoff)
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
@router.get("/checklist/{session_id}/items/{requirement_id}", response_model=SignOffResponse)
|
|
async def get_sign_off(
|
|
session_id: str,
|
|
requirement_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get the current sign-off status for a specific requirement.
|
|
"""
|
|
signoff = (
|
|
db.query(AuditSignOffDB)
|
|
.filter(AuditSignOffDB.session_id == session_id)
|
|
.filter(AuditSignOffDB.requirement_id == requirement_id)
|
|
.first()
|
|
)
|
|
|
|
if not signoff:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"No sign-off found for requirement {requirement_id} in session {session_id}"
|
|
)
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# PDF Report Generation
|
|
# ============================================================================
|
|
|
|
@router.get("/sessions/{session_id}/report/pdf")
|
|
async def generate_audit_pdf_report(
|
|
session_id: str,
|
|
language: str = Query("de", pattern="^(de|en)$"),
|
|
include_signatures: bool = Query(True),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Generate a PDF report for an audit session.
|
|
|
|
Parameters:
|
|
- session_id: The audit session ID
|
|
- language: Output language ('de' or 'en'), default 'de'
|
|
- include_signatures: Include digital signature verification section
|
|
|
|
Returns:
|
|
- PDF file as streaming response
|
|
"""
|
|
from fastapi.responses import StreamingResponse
|
|
import io
|
|
from ..services.audit_pdf_generator import AuditPDFGenerator
|
|
|
|
# Validate session exists
|
|
session = db.query(AuditSessionDB).filter(AuditSessionDB.id == session_id).first()
|
|
if not session:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Audit session {session_id} not found"
|
|
)
|
|
|
|
try:
|
|
generator = AuditPDFGenerator(db)
|
|
pdf_bytes, filename = generator.generate(
|
|
session_id=session_id,
|
|
language=language,
|
|
include_signatures=include_signatures,
|
|
)
|
|
|
|
return StreamingResponse(
|
|
io.BytesIO(pdf_bytes),
|
|
media_type="application/pdf",
|
|
headers={
|
|
"Content-Disposition": f"attachment; filename={filename}",
|
|
}
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to generate PDF report: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to generate PDF report: {str(e)}"
|
|
)
|