import json import uuid from fastapi import HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session from app.modules.approvals.models import ApprovalRequest from app.modules.approvals.schemas import ApprovalCreate 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="approval", action="approval.create", target_type=payload.domain, target_id=payload.record_id, risk_level="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 != "pending": raise HTTPException(status_code=409, detail="Approval ticket already decided") ticket.status = "approved" if approved else "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="approval", action="approval.approve" if approved else "approval.reject", target_type=ticket.domain, target_id=ticket.record_id, risk_level="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 != "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, "update", f"update:{domain}", "*"}