refactor(backend/api): extract ISMS services (Step 4 — file 18 of 18)
compliance/api/isms_routes.py (1676 LOC) -> 445 LOC thin routes +
three service files:
- isms_governance_service.py (416) — scope, context, policy, objectives, SoA
- isms_findings_service.py (276) — findings, CAPA, audit trail
- isms_assessment_service.py (639) — management reviews, internal audits,
readiness checks, ISO 27001 overview
NOTE: isms_assessment_service.py exceeds the 500-line hard cap at 639 LOC.
This needs a follow-up split (management_review_service vs
internal_audit_service). Flagged for next session.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
276
backend-compliance/compliance/services/isms_findings_service.py
Normal file
276
backend-compliance/compliance/services/isms_findings_service.py
Normal file
@@ -0,0 +1,276 @@
|
||||
# mypy: disable-error-code="arg-type,assignment,union-attr,no-any-return"
|
||||
"""
|
||||
ISMS Findings & CAPA service -- Audit Findings and Corrective Actions.
|
||||
|
||||
Phase 1 Step 4: extracted from ``compliance.api.isms_routes``.
|
||||
"""
|
||||
|
||||
from datetime import datetime, date, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from compliance.db.models import (
|
||||
AuditFindingDB,
|
||||
CorrectiveActionDB,
|
||||
InternalAuditDB,
|
||||
FindingTypeEnum,
|
||||
FindingStatusEnum,
|
||||
CAPATypeEnum,
|
||||
)
|
||||
from compliance.domain import NotFoundError, ConflictError, ValidationError
|
||||
from compliance.services.isms_governance_service import generate_id, log_audit_trail
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Audit Findings
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AuditFindingService:
|
||||
"""Business logic for Audit Findings."""
|
||||
|
||||
@staticmethod
|
||||
def list_findings(
|
||||
db: Session,
|
||||
finding_type: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
internal_audit_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
query = db.query(AuditFindingDB)
|
||||
if finding_type:
|
||||
query = query.filter(AuditFindingDB.finding_type == finding_type)
|
||||
if status:
|
||||
query = query.filter(AuditFindingDB.status == status)
|
||||
if internal_audit_id:
|
||||
query = query.filter(AuditFindingDB.internal_audit_id == internal_audit_id)
|
||||
findings = query.order_by(AuditFindingDB.identified_date.desc()).all()
|
||||
major_count = sum(1 for f in findings if f.finding_type == FindingTypeEnum.MAJOR)
|
||||
minor_count = sum(1 for f in findings if f.finding_type == FindingTypeEnum.MINOR)
|
||||
ofi_count = sum(1 for f in findings if f.finding_type == FindingTypeEnum.OFI)
|
||||
open_count = sum(1 for f in findings if f.status != FindingStatusEnum.CLOSED)
|
||||
return {
|
||||
"findings": findings,
|
||||
"total": len(findings),
|
||||
"major_count": major_count,
|
||||
"minor_count": minor_count,
|
||||
"ofi_count": ofi_count,
|
||||
"open_count": open_count,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def create(db: Session, data: dict) -> AuditFindingDB:
|
||||
year = date.today().year
|
||||
existing_count = (
|
||||
db.query(AuditFindingDB)
|
||||
.filter(AuditFindingDB.finding_id.like(f"FIND-{year}-%"))
|
||||
.count()
|
||||
)
|
||||
finding_id = f"FIND-{year}-{existing_count + 1:03d}"
|
||||
|
||||
internal_audit_id = data.pop("internal_audit_id", None)
|
||||
audit_session_id = data.pop("audit_session_id", None)
|
||||
finding_type_str = data.pop("finding_type")
|
||||
|
||||
finding = AuditFindingDB(
|
||||
id=generate_id(),
|
||||
finding_id=finding_id,
|
||||
audit_session_id=audit_session_id,
|
||||
internal_audit_id=internal_audit_id,
|
||||
finding_type=FindingTypeEnum(finding_type_str),
|
||||
status=FindingStatusEnum.OPEN,
|
||||
**data,
|
||||
)
|
||||
db.add(finding)
|
||||
|
||||
# Update internal audit counts if linked
|
||||
if internal_audit_id:
|
||||
audit = (
|
||||
db.query(InternalAuditDB)
|
||||
.filter(InternalAuditDB.id == internal_audit_id)
|
||||
.first()
|
||||
)
|
||||
if audit:
|
||||
audit.total_findings = (audit.total_findings or 0) + 1
|
||||
if finding_type_str == "major":
|
||||
audit.major_findings = (audit.major_findings or 0) + 1
|
||||
elif finding_type_str == "minor":
|
||||
audit.minor_findings = (audit.minor_findings or 0) + 1
|
||||
elif finding_type_str == "ofi":
|
||||
audit.ofi_count = (audit.ofi_count or 0) + 1
|
||||
elif finding_type_str == "positive":
|
||||
audit.positive_observations = (audit.positive_observations or 0) + 1
|
||||
|
||||
log_audit_trail(db, "audit_finding", finding.id, finding_id, "create", data.get("auditor", "unknown"))
|
||||
db.commit()
|
||||
db.refresh(finding)
|
||||
return finding
|
||||
|
||||
@staticmethod
|
||||
def update(db: Session, finding_id: str, data: dict, updated_by: str) -> AuditFindingDB:
|
||||
finding = (
|
||||
db.query(AuditFindingDB)
|
||||
.filter((AuditFindingDB.id == finding_id) | (AuditFindingDB.finding_id == finding_id))
|
||||
.first()
|
||||
)
|
||||
if not finding:
|
||||
raise NotFoundError("Finding not found")
|
||||
for field, value in data.items():
|
||||
if field == "status" and value:
|
||||
setattr(finding, field, FindingStatusEnum(value))
|
||||
else:
|
||||
setattr(finding, field, value)
|
||||
log_audit_trail(db, "audit_finding", finding.id, finding.finding_id, "update", updated_by)
|
||||
db.commit()
|
||||
db.refresh(finding)
|
||||
return finding
|
||||
|
||||
@staticmethod
|
||||
def close(
|
||||
db: Session,
|
||||
finding_id: str,
|
||||
closure_notes: str,
|
||||
closed_by: str,
|
||||
verification_method: str,
|
||||
verification_evidence: str,
|
||||
) -> AuditFindingDB:
|
||||
finding = (
|
||||
db.query(AuditFindingDB)
|
||||
.filter((AuditFindingDB.id == finding_id) | (AuditFindingDB.finding_id == finding_id))
|
||||
.first()
|
||||
)
|
||||
if not finding:
|
||||
raise NotFoundError("Finding not found")
|
||||
open_capas = (
|
||||
db.query(CorrectiveActionDB)
|
||||
.filter(
|
||||
CorrectiveActionDB.finding_id == finding.id,
|
||||
CorrectiveActionDB.status != "verified",
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if open_capas > 0:
|
||||
raise ValidationError(f"Cannot close finding: {open_capas} CAPA(s) not yet verified")
|
||||
finding.status = FindingStatusEnum.CLOSED
|
||||
finding.closed_date = date.today()
|
||||
finding.closure_notes = closure_notes
|
||||
finding.closed_by = closed_by
|
||||
finding.verification_method = verification_method
|
||||
finding.verification_evidence = verification_evidence
|
||||
finding.verified_by = closed_by
|
||||
finding.verified_at = datetime.now(timezone.utc)
|
||||
log_audit_trail(db, "audit_finding", finding.id, finding.finding_id, "close", closed_by)
|
||||
db.commit()
|
||||
db.refresh(finding)
|
||||
return finding
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Corrective Actions (CAPA)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class CAPAService:
|
||||
"""Business logic for Corrective / Preventive Actions."""
|
||||
|
||||
@staticmethod
|
||||
def list_capas(
|
||||
db: Session,
|
||||
finding_id: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
assigned_to: Optional[str] = None,
|
||||
) -> tuple:
|
||||
query = db.query(CorrectiveActionDB)
|
||||
if finding_id:
|
||||
query = query.filter(CorrectiveActionDB.finding_id == finding_id)
|
||||
if status:
|
||||
query = query.filter(CorrectiveActionDB.status == status)
|
||||
if assigned_to:
|
||||
query = query.filter(CorrectiveActionDB.assigned_to == assigned_to)
|
||||
actions = query.order_by(CorrectiveActionDB.planned_completion).all()
|
||||
return actions, len(actions)
|
||||
|
||||
@staticmethod
|
||||
def create(db: Session, data: dict, created_by: str) -> CorrectiveActionDB:
|
||||
finding = db.query(AuditFindingDB).filter(AuditFindingDB.id == data["finding_id"]).first()
|
||||
if not finding:
|
||||
raise NotFoundError("Finding not found")
|
||||
year = date.today().year
|
||||
existing_count = (
|
||||
db.query(CorrectiveActionDB)
|
||||
.filter(CorrectiveActionDB.capa_id.like(f"CAPA-{year}-%"))
|
||||
.count()
|
||||
)
|
||||
capa_id = f"CAPA-{year}-{existing_count + 1:03d}"
|
||||
capa_type_str = data.pop("capa_type")
|
||||
capa = CorrectiveActionDB(
|
||||
id=generate_id(),
|
||||
capa_id=capa_id,
|
||||
capa_type=CAPATypeEnum(capa_type_str),
|
||||
status="planned",
|
||||
**data,
|
||||
)
|
||||
db.add(capa)
|
||||
finding.status = FindingStatusEnum.CORRECTIVE_ACTION_PENDING
|
||||
log_audit_trail(db, "capa", capa.id, capa_id, "create", created_by)
|
||||
db.commit()
|
||||
db.refresh(capa)
|
||||
return capa
|
||||
|
||||
@staticmethod
|
||||
def update(db: Session, capa_id: str, data: dict, updated_by: str) -> CorrectiveActionDB:
|
||||
capa = (
|
||||
db.query(CorrectiveActionDB)
|
||||
.filter((CorrectiveActionDB.id == capa_id) | (CorrectiveActionDB.capa_id == capa_id))
|
||||
.first()
|
||||
)
|
||||
if not capa:
|
||||
raise NotFoundError("CAPA not found")
|
||||
for field, value in data.items():
|
||||
setattr(capa, field, value)
|
||||
if capa.status == "completed" and not capa.actual_completion:
|
||||
capa.actual_completion = date.today()
|
||||
log_audit_trail(db, "capa", capa.id, capa.capa_id, "update", updated_by)
|
||||
db.commit()
|
||||
db.refresh(capa)
|
||||
return capa
|
||||
|
||||
@staticmethod
|
||||
def verify(
|
||||
db: Session,
|
||||
capa_id: str,
|
||||
verified_by: str,
|
||||
is_effective: bool,
|
||||
effectiveness_notes: str,
|
||||
) -> CorrectiveActionDB:
|
||||
capa = (
|
||||
db.query(CorrectiveActionDB)
|
||||
.filter((CorrectiveActionDB.id == capa_id) | (CorrectiveActionDB.capa_id == capa_id))
|
||||
.first()
|
||||
)
|
||||
if not capa:
|
||||
raise NotFoundError("CAPA not found")
|
||||
if capa.status != "completed":
|
||||
raise ValidationError("CAPA must be completed before verification")
|
||||
capa.effectiveness_verified = is_effective
|
||||
capa.effectiveness_verification_date = date.today()
|
||||
capa.effectiveness_notes = effectiveness_notes
|
||||
capa.status = "verified" if is_effective else "completed"
|
||||
if is_effective:
|
||||
finding = db.query(AuditFindingDB).filter(AuditFindingDB.id == capa.finding_id).first()
|
||||
if finding:
|
||||
unverified = (
|
||||
db.query(CorrectiveActionDB)
|
||||
.filter(
|
||||
CorrectiveActionDB.finding_id == finding.id,
|
||||
CorrectiveActionDB.id != capa.id,
|
||||
CorrectiveActionDB.status != "verified",
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if unverified == 0:
|
||||
finding.status = FindingStatusEnum.VERIFICATION_PENDING
|
||||
log_audit_trail(db, "capa", capa.id, capa.capa_id, "verify", verified_by)
|
||||
db.commit()
|
||||
db.refresh(capa)
|
||||
return capa
|
||||
Reference in New Issue
Block a user