import json import uuid from fastapi import HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session from app.modules.approvals.constants import ApprovalActionValue, ApprovalStatus from app.modules.approvals.models import ApprovalRequest from app.modules.approvals.schemas import ApprovalCreate from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService class ApprovalService: """Create, decide, and validate approval tickets for guarded actions.""" def __init__(self, db: Session): self.db = db self.audit = AuditService(db) def create(self, payload: ApprovalCreate) -> ApprovalRequest: ticket = ApprovalRequest( ticket_id=f"APR-{uuid.uuid4().hex[:12].upper()}", domain=payload.domain, record_id=payload.record_id, action=payload.action, applicant=payload.applicant, reason=payload.reason, payload=json.dumps(payload.payload, ensure_ascii=False, default=str), ) self.db.add(ticket) self.db.commit() self.db.refresh(ticket) self.audit.log( AuditLogCreate( actor=payload.applicant, source=AuditSource.APPROVAL, action=AuditAction.APPROVAL_CREATE, target_type=payload.domain, target_id=payload.record_id, risk_level=AuditRiskLevel.MEDIUM, request_payload=payload.model_dump(), response_payload={"ticket_id": ticket.ticket_id, "status": ticket.status}, ) ) return ticket def list(self, status_filter: str | None = None, limit: int = 100) -> list[ApprovalRequest]: stmt = select(ApprovalRequest).order_by(ApprovalRequest.id.desc()).limit(min(limit, 500)) if status_filter: stmt = stmt.where(ApprovalRequest.status == status_filter) return list(self.db.execute(stmt).scalars()) def get_by_ticket(self, ticket_id: str) -> ApprovalRequest: ticket = self.db.execute( select(ApprovalRequest).where(ApprovalRequest.ticket_id == ticket_id) ).scalar_one_or_none() if ticket is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Approval ticket not found", ) return ticket def decide( self, ticket_id: str, approver: str, approved: bool, comment: str | None, ) -> ApprovalRequest: ticket = self.get_by_ticket(ticket_id) if ticket.status != ApprovalStatus.PENDING: raise HTTPException(status_code=409, detail="Approval ticket already decided") ticket.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED ticket.approver = approver ticket.decision_comment = comment from datetime import datetime ticket.decided_at = datetime.utcnow() self.db.commit() self.db.refresh(ticket) self.audit.log( AuditLogCreate( actor=approver, source=AuditSource.APPROVAL, action=AuditAction.APPROVAL_APPROVE if approved else AuditAction.APPROVAL_REJECT, target_type=ticket.domain, target_id=ticket.record_id, risk_level=AuditRiskLevel.HIGH, request_payload={"ticket_id": ticket_id, "comment": comment}, response_payload={"status": ticket.status}, ) ) return ticket def is_approved_for( self, ticket_id: str, domain: str, record_id: str | int | None, action: str, ) -> bool: ticket = self.get_by_ticket(ticket_id) if ticket.status != ApprovalStatus.APPROVED: return False if ticket.domain != domain: return False if ticket.record_id and record_id is not None and str(ticket.record_id) != str(record_id): return False return ticket.action in { action, ApprovalActionValue.UPDATE, f"{ApprovalActionValue.UPDATE}:{domain}", ApprovalActionValue.WILDCARD, }