Phase 1 Step 5 of PHASE1_RUNBOOK.md.
compliance/db/repository.py (1547 LOC) decomposed into seven sibling
per-aggregate repository modules:
regulation_repository.py (268) — Regulation + Requirement
control_repository.py (291) — Control + ControlMapping
evidence_repository.py (143)
risk_repository.py (148)
audit_export_repository.py (110)
service_module_repository.py (247)
audit_session_repository.py (478) — AuditSession + AuditSignOff
compliance/db/isms_repository.py (838 LOC) decomposed into two
sub-aggregate modules mirroring the models split:
isms_governance_repository.py (354) — Scope, Policy, Objective, SoA
isms_audit_repository.py (499) — Finding, CAPA, Review, Internal Audit,
Trail, Readiness
Both original files become thin re-export shims (37 and 25 LOC
respectively) so every existing import continues to work unchanged.
New code SHOULD import from the aggregate module directly.
All new sibling files under the 500-line hard cap; largest is
isms_audit_repository.py at 499 (on the edge; when Phase 1 Step 4
router->service extraction lands, the audit_session repo may split
further if growth exceeds 500).
Verified:
- 173/173 pytest compliance/tests/ tests/contracts/ pass
- OpenAPI 360 paths / 484 operations unchanged
- All repo files under 500 LOC
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
479 lines
16 KiB
Python
479 lines
16 KiB
Python
"""
|
|
Compliance repositories — extracted from compliance/db/repository.py.
|
|
|
|
Phase 1 Step 5: the monolithic repository module is decomposed per
|
|
aggregate. Every repository class is re-exported from
|
|
``compliance.db.repository`` for backwards compatibility.
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import datetime, date, timezone
|
|
from typing import List, Optional, Dict, Any, Tuple
|
|
|
|
from sqlalchemy.orm import Session as DBSession, selectinload, joinedload
|
|
from sqlalchemy import func, and_, or_
|
|
|
|
from compliance.db.models import (
|
|
RegulationDB, RequirementDB, ControlDB, ControlMappingDB,
|
|
EvidenceDB, RiskDB, AuditExportDB,
|
|
AuditSessionDB, AuditSignOffDB, AuditResultEnum, AuditSessionStatusEnum,
|
|
RegulationTypeEnum, ControlDomainEnum, ControlStatusEnum,
|
|
RiskLevelEnum, EvidenceStatusEnum, ExportStatusEnum,
|
|
ServiceModuleDB, ModuleRegulationMappingDB,
|
|
)
|
|
|
|
class AuditSessionRepository:
|
|
"""Repository for audit sessions (Sprint 3: Auditor-Verbesserungen)."""
|
|
|
|
def __init__(self, db: DBSession):
|
|
self.db = db
|
|
|
|
def create(
|
|
self,
|
|
name: str,
|
|
auditor_name: str,
|
|
description: Optional[str] = None,
|
|
auditor_email: Optional[str] = None,
|
|
regulation_ids: Optional[List[str]] = None,
|
|
) -> AuditSessionDB:
|
|
"""Create a new audit session."""
|
|
session = AuditSessionDB(
|
|
id=str(uuid.uuid4()),
|
|
name=name,
|
|
description=description,
|
|
auditor_name=auditor_name,
|
|
auditor_email=auditor_email,
|
|
regulation_ids=regulation_ids,
|
|
status=AuditSessionStatusEnum.DRAFT,
|
|
)
|
|
self.db.add(session)
|
|
self.db.commit()
|
|
self.db.refresh(session)
|
|
return session
|
|
|
|
def get_by_id(self, session_id: str) -> Optional[AuditSessionDB]:
|
|
"""Get audit session by ID with eager-loaded signoffs."""
|
|
return (
|
|
self.db.query(AuditSessionDB)
|
|
.options(
|
|
selectinload(AuditSessionDB.signoffs)
|
|
.selectinload(AuditSignOffDB.requirement)
|
|
)
|
|
.filter(AuditSessionDB.id == session_id)
|
|
.first()
|
|
)
|
|
|
|
def get_all(
|
|
self,
|
|
status: Optional[AuditSessionStatusEnum] = None,
|
|
limit: int = 50,
|
|
) -> List[AuditSessionDB]:
|
|
"""Get all audit sessions with optional status filter."""
|
|
query = self.db.query(AuditSessionDB)
|
|
if status:
|
|
query = query.filter(AuditSessionDB.status == status)
|
|
return query.order_by(AuditSessionDB.created_at.desc()).limit(limit).all()
|
|
|
|
def update_status(
|
|
self,
|
|
session_id: str,
|
|
status: AuditSessionStatusEnum,
|
|
) -> Optional[AuditSessionDB]:
|
|
"""Update session status and set appropriate timestamps."""
|
|
session = self.get_by_id(session_id)
|
|
if not session:
|
|
return None
|
|
|
|
session.status = status
|
|
if status == AuditSessionStatusEnum.IN_PROGRESS and not session.started_at:
|
|
session.started_at = datetime.now(timezone.utc)
|
|
elif status == AuditSessionStatusEnum.COMPLETED:
|
|
session.completed_at = datetime.now(timezone.utc)
|
|
|
|
session.updated_at = datetime.now(timezone.utc)
|
|
self.db.commit()
|
|
self.db.refresh(session)
|
|
return session
|
|
|
|
def update_progress(
|
|
self,
|
|
session_id: str,
|
|
total_items: Optional[int] = None,
|
|
completed_items: Optional[int] = None,
|
|
) -> Optional[AuditSessionDB]:
|
|
"""Update session progress counters."""
|
|
session = self.db.query(AuditSessionDB).filter(
|
|
AuditSessionDB.id == session_id
|
|
).first()
|
|
if not session:
|
|
return None
|
|
|
|
if total_items is not None:
|
|
session.total_items = total_items
|
|
if completed_items is not None:
|
|
session.completed_items = completed_items
|
|
|
|
session.updated_at = datetime.now(timezone.utc)
|
|
self.db.commit()
|
|
self.db.refresh(session)
|
|
return session
|
|
|
|
def start_session(self, session_id: str) -> Optional[AuditSessionDB]:
|
|
"""
|
|
Start an audit session:
|
|
- Set status to IN_PROGRESS
|
|
- Initialize total_items based on requirements count
|
|
"""
|
|
session = self.get_by_id(session_id)
|
|
if not session:
|
|
return None
|
|
|
|
# Count requirements for this session
|
|
query = self.db.query(func.count(RequirementDB.id))
|
|
if session.regulation_ids:
|
|
query = query.join(RegulationDB).filter(
|
|
RegulationDB.id.in_(session.regulation_ids)
|
|
)
|
|
total_requirements = query.scalar() or 0
|
|
|
|
session.status = AuditSessionStatusEnum.IN_PROGRESS
|
|
session.started_at = datetime.now(timezone.utc)
|
|
session.total_items = total_requirements
|
|
session.updated_at = datetime.now(timezone.utc)
|
|
|
|
self.db.commit()
|
|
self.db.refresh(session)
|
|
return session
|
|
|
|
def delete(self, session_id: str) -> bool:
|
|
"""Delete an audit session (cascades to signoffs)."""
|
|
session = self.db.query(AuditSessionDB).filter(
|
|
AuditSessionDB.id == session_id
|
|
).first()
|
|
if not session:
|
|
return False
|
|
|
|
self.db.delete(session)
|
|
self.db.commit()
|
|
return True
|
|
|
|
def get_statistics(self, session_id: str) -> Dict[str, Any]:
|
|
"""Get detailed statistics for an audit session."""
|
|
session = self.get_by_id(session_id)
|
|
if not session:
|
|
return {}
|
|
|
|
signoffs = session.signoffs or []
|
|
|
|
stats = {
|
|
"total": session.total_items or 0,
|
|
"completed": len([s for s in signoffs if s.result != AuditResultEnum.PENDING]),
|
|
"compliant": len([s for s in signoffs if s.result == AuditResultEnum.COMPLIANT]),
|
|
"compliant_with_notes": len([s for s in signoffs if s.result == AuditResultEnum.COMPLIANT_WITH_NOTES]),
|
|
"non_compliant": len([s for s in signoffs if s.result == AuditResultEnum.NON_COMPLIANT]),
|
|
"not_applicable": len([s for s in signoffs if s.result == AuditResultEnum.NOT_APPLICABLE]),
|
|
"pending": len([s for s in signoffs if s.result == AuditResultEnum.PENDING]),
|
|
"signed": len([s for s in signoffs if s.signature_hash]),
|
|
}
|
|
|
|
total = stats["total"] if stats["total"] > 0 else 1
|
|
stats["completion_percentage"] = round(
|
|
(stats["completed"] / total) * 100, 1
|
|
)
|
|
|
|
return stats
|
|
|
|
|
|
class AuditSignOffRepository:
|
|
"""Repository for audit sign-offs (Sprint 3: Auditor-Verbesserungen)."""
|
|
|
|
def __init__(self, db: DBSession):
|
|
self.db = db
|
|
|
|
def create(
|
|
self,
|
|
session_id: str,
|
|
requirement_id: str,
|
|
result: AuditResultEnum = AuditResultEnum.PENDING,
|
|
notes: Optional[str] = None,
|
|
) -> AuditSignOffDB:
|
|
"""Create a new sign-off for a requirement."""
|
|
signoff = AuditSignOffDB(
|
|
id=str(uuid.uuid4()),
|
|
session_id=session_id,
|
|
requirement_id=requirement_id,
|
|
result=result,
|
|
notes=notes,
|
|
)
|
|
self.db.add(signoff)
|
|
self.db.commit()
|
|
self.db.refresh(signoff)
|
|
return signoff
|
|
|
|
def get_by_id(self, signoff_id: str) -> Optional[AuditSignOffDB]:
|
|
"""Get sign-off by ID."""
|
|
return (
|
|
self.db.query(AuditSignOffDB)
|
|
.options(joinedload(AuditSignOffDB.requirement))
|
|
.filter(AuditSignOffDB.id == signoff_id)
|
|
.first()
|
|
)
|
|
|
|
def get_by_session_and_requirement(
|
|
self,
|
|
session_id: str,
|
|
requirement_id: str,
|
|
) -> Optional[AuditSignOffDB]:
|
|
"""Get sign-off by session and requirement ID."""
|
|
return (
|
|
self.db.query(AuditSignOffDB)
|
|
.filter(
|
|
and_(
|
|
AuditSignOffDB.session_id == session_id,
|
|
AuditSignOffDB.requirement_id == requirement_id,
|
|
)
|
|
)
|
|
.first()
|
|
)
|
|
|
|
def get_by_session(
|
|
self,
|
|
session_id: str,
|
|
result_filter: Optional[AuditResultEnum] = None,
|
|
) -> List[AuditSignOffDB]:
|
|
"""Get all sign-offs for a session."""
|
|
query = (
|
|
self.db.query(AuditSignOffDB)
|
|
.options(joinedload(AuditSignOffDB.requirement))
|
|
.filter(AuditSignOffDB.session_id == session_id)
|
|
)
|
|
if result_filter:
|
|
query = query.filter(AuditSignOffDB.result == result_filter)
|
|
return query.order_by(AuditSignOffDB.created_at).all()
|
|
|
|
def update(
|
|
self,
|
|
signoff_id: str,
|
|
result: Optional[AuditResultEnum] = None,
|
|
notes: Optional[str] = None,
|
|
sign: bool = False,
|
|
signed_by: Optional[str] = None,
|
|
) -> Optional[AuditSignOffDB]:
|
|
"""Update a sign-off with optional digital signature."""
|
|
signoff = self.db.query(AuditSignOffDB).filter(
|
|
AuditSignOffDB.id == signoff_id
|
|
).first()
|
|
if not signoff:
|
|
return None
|
|
|
|
if result is not None:
|
|
signoff.result = result
|
|
if notes is not None:
|
|
signoff.notes = notes
|
|
|
|
if sign and signed_by:
|
|
signoff.create_signature(signed_by)
|
|
|
|
signoff.updated_at = datetime.now(timezone.utc)
|
|
self.db.commit()
|
|
self.db.refresh(signoff)
|
|
|
|
# Update session progress
|
|
self._update_session_progress(signoff.session_id)
|
|
|
|
return signoff
|
|
|
|
def sign_off(
|
|
self,
|
|
session_id: str,
|
|
requirement_id: str,
|
|
result: AuditResultEnum,
|
|
notes: Optional[str] = None,
|
|
sign: bool = False,
|
|
signed_by: Optional[str] = None,
|
|
) -> AuditSignOffDB:
|
|
"""
|
|
Create or update a sign-off for a requirement.
|
|
This is the main method for auditors to record their findings.
|
|
"""
|
|
# Check if sign-off already exists
|
|
signoff = self.get_by_session_and_requirement(session_id, requirement_id)
|
|
|
|
if signoff:
|
|
# Update existing
|
|
signoff.result = result
|
|
if notes is not None:
|
|
signoff.notes = notes
|
|
if sign and signed_by:
|
|
signoff.create_signature(signed_by)
|
|
signoff.updated_at = datetime.now(timezone.utc)
|
|
else:
|
|
# Create new
|
|
signoff = AuditSignOffDB(
|
|
id=str(uuid.uuid4()),
|
|
session_id=session_id,
|
|
requirement_id=requirement_id,
|
|
result=result,
|
|
notes=notes,
|
|
)
|
|
if sign and signed_by:
|
|
signoff.create_signature(signed_by)
|
|
self.db.add(signoff)
|
|
|
|
self.db.commit()
|
|
self.db.refresh(signoff)
|
|
|
|
# Update session progress
|
|
self._update_session_progress(session_id)
|
|
|
|
return signoff
|
|
|
|
def _update_session_progress(self, session_id: str) -> None:
|
|
"""Update the session's completed_items count."""
|
|
completed = (
|
|
self.db.query(func.count(AuditSignOffDB.id))
|
|
.filter(
|
|
and_(
|
|
AuditSignOffDB.session_id == session_id,
|
|
AuditSignOffDB.result != AuditResultEnum.PENDING,
|
|
)
|
|
)
|
|
.scalar()
|
|
) or 0
|
|
|
|
session = self.db.query(AuditSessionDB).filter(
|
|
AuditSessionDB.id == session_id
|
|
).first()
|
|
if session:
|
|
session.completed_items = completed
|
|
session.updated_at = datetime.now(timezone.utc)
|
|
self.db.commit()
|
|
|
|
def get_checklist(
|
|
self,
|
|
session_id: str,
|
|
page: int = 1,
|
|
page_size: int = 50,
|
|
result_filter: Optional[AuditResultEnum] = None,
|
|
regulation_code: Optional[str] = None,
|
|
search: Optional[str] = None,
|
|
) -> Tuple[List[Dict[str, Any]], int]:
|
|
"""
|
|
Get audit checklist items for a session with pagination.
|
|
Returns requirements with their sign-off status.
|
|
"""
|
|
session = self.db.query(AuditSessionDB).filter(
|
|
AuditSessionDB.id == session_id
|
|
).first()
|
|
if not session:
|
|
return [], 0
|
|
|
|
# Base query for requirements
|
|
query = (
|
|
self.db.query(RequirementDB)
|
|
.options(
|
|
joinedload(RequirementDB.regulation),
|
|
selectinload(RequirementDB.control_mappings),
|
|
)
|
|
)
|
|
|
|
# Filter by session's regulation_ids if set
|
|
if session.regulation_ids:
|
|
query = query.filter(RequirementDB.regulation_id.in_(session.regulation_ids))
|
|
|
|
# Filter by regulation code
|
|
if regulation_code:
|
|
query = query.join(RegulationDB).filter(RegulationDB.code == regulation_code)
|
|
|
|
# Search
|
|
if search:
|
|
search_term = f"%{search}%"
|
|
query = query.filter(
|
|
or_(
|
|
RequirementDB.title.ilike(search_term),
|
|
RequirementDB.article.ilike(search_term),
|
|
)
|
|
)
|
|
|
|
# Get existing sign-offs for this session
|
|
signoffs_map = {}
|
|
signoffs = (
|
|
self.db.query(AuditSignOffDB)
|
|
.filter(AuditSignOffDB.session_id == session_id)
|
|
.all()
|
|
)
|
|
for s in signoffs:
|
|
signoffs_map[s.requirement_id] = s
|
|
|
|
# Filter by result if specified
|
|
if result_filter:
|
|
if result_filter == AuditResultEnum.PENDING:
|
|
# Requirements without sign-off or with pending status
|
|
signed_req_ids = [
|
|
s.requirement_id for s in signoffs
|
|
if s.result != AuditResultEnum.PENDING
|
|
]
|
|
if signed_req_ids:
|
|
query = query.filter(~RequirementDB.id.in_(signed_req_ids))
|
|
else:
|
|
# Requirements with specific result
|
|
matching_req_ids = [
|
|
s.requirement_id for s in signoffs
|
|
if s.result == result_filter
|
|
]
|
|
if matching_req_ids:
|
|
query = query.filter(RequirementDB.id.in_(matching_req_ids))
|
|
else:
|
|
return [], 0
|
|
|
|
# Count and paginate
|
|
total = query.count()
|
|
requirements = (
|
|
query
|
|
.order_by(RequirementDB.article, RequirementDB.paragraph)
|
|
.offset((page - 1) * page_size)
|
|
.limit(page_size)
|
|
.all()
|
|
)
|
|
|
|
# Build checklist items
|
|
items = []
|
|
for req in requirements:
|
|
signoff = signoffs_map.get(req.id)
|
|
items.append({
|
|
"requirement_id": req.id,
|
|
"regulation_code": req.regulation.code if req.regulation else None,
|
|
"regulation_name": req.regulation.name if req.regulation else None,
|
|
"article": req.article,
|
|
"paragraph": req.paragraph,
|
|
"title": req.title,
|
|
"description": req.description,
|
|
"current_result": signoff.result.value if signoff else AuditResultEnum.PENDING.value,
|
|
"notes": signoff.notes if signoff else None,
|
|
"is_signed": bool(signoff.signature_hash) if signoff else False,
|
|
"signed_at": signoff.signed_at if signoff else None,
|
|
"signed_by": signoff.signed_by if signoff else None,
|
|
"evidence_count": len(req.control_mappings) if req.control_mappings else 0,
|
|
"controls_mapped": len(req.control_mappings) if req.control_mappings else 0,
|
|
})
|
|
|
|
return items, total
|
|
|
|
def delete(self, signoff_id: str) -> bool:
|
|
"""Delete a sign-off."""
|
|
signoff = self.db.query(AuditSignOffDB).filter(
|
|
AuditSignOffDB.id == signoff_id
|
|
).first()
|
|
if not signoff:
|
|
return False
|
|
|
|
session_id = signoff.session_id
|
|
self.db.delete(signoff)
|
|
self.db.commit()
|
|
|
|
# Update session progress
|
|
self._update_session_progress(session_id)
|
|
|
|
return True
|
|
|