"""Central approval-request core for agent action approval. Provides the ``ApprovalRequest`` model and service helpers used by the ReAct agent loop to pause before executing tools that require human approval, and by the approvals API routes to create / resolve requests. Status lifecycle: ``pending`` → ``approved`` | ``rejected`` | ``expired``. """ from __future__ import annotations import uuid from datetime import UTC, datetime from typing import Any from sqlalchemy import DateTime, Index, String, Text, func from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, mapped_column from app.core.db import Base, TenantMixin # Valid statuses. APPROVAL_STATUSES = ("pending", "approved", "rejected", "expired") # Valid requested_by_type values. REQUESTER_TYPES = ("user", "agent", "system") class ApprovalRequest(Base, TenantMixin): """A request for human approval of an agent action.""" __tablename__ = "approval_requests" __table_args__ = ( Index("ix_approval_requests_tenant_status", "tenant_id", "status"), Index("ix_approval_requests_tenant_entity", "tenant_id", "entity_type", "entity_id"), Index("ix_approval_requests_tenant_approver", "tenant_id", "approver_id"), ) id: Mapped[uuid.UUID] = mapped_column( PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) entity_type: Mapped[str] = mapped_column(String(80), nullable=False) entity_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False) action: Mapped[str] = mapped_column(String(120), nullable=False) requested_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False) requested_by_type: Mapped[str] = mapped_column( String(20), nullable=False, default="agent" ) approver_id: Mapped[uuid.UUID | None] = mapped_column( PGUUID(as_uuid=True), nullable=True ) approver_group: Mapped[str | None] = mapped_column(String(120), nullable=True) status: Mapped[str] = mapped_column( String(20), nullable=False, default="pending" ) comment: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) resolved_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) expires_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) metadata: Mapped[dict[str, Any]] = mapped_column( JSONB, nullable=False, default=dict ) async def create_approval_request( db: AsyncSession, tenant_id: uuid.UUID, *, entity_type: str, entity_id: uuid.UUID, action: str, requested_by: uuid.UUID, requested_by_type: str = "agent", approver_id: uuid.UUID | None = None, approver_group: str | None = None, expires_at: datetime | None = None, metadata: dict[str, Any] | None = None, ) -> ApprovalRequest: """Create a new pending approval request.""" req = ApprovalRequest( tenant_id=tenant_id, entity_type=entity_type, entity_id=entity_id, action=action, requested_by=requested_by, requested_by_type=requested_by_type, approver_id=approver_id, approver_group=approver_group, status="pending", expires_at=expires_at, metadata=metadata or {}, ) db.add(req) await db.flush() return req async def resolve_approval_request( db: AsyncSession, tenant_id: uuid.UUID, request_id: uuid.UUID, *, decision: str, approver_id: uuid.UUID, comment: str | None = None, ) -> ApprovalRequest | None: """Approve or reject a pending approval request. Returns the updated request, or ``None`` if not found / not pending. """ from sqlalchemy import select result = await db.execute( select(ApprovalRequest).where( ApprovalRequest.id == request_id, ApprovalRequest.tenant_id == tenant_id, ) ) req = result.scalar_one_or_none() if req is None or req.status != "pending": return None req.status = decision req.approver_id = approver_id req.comment = comment req.resolved_at = datetime.now(UTC) await db.flush() return req async def expire_approval_request( db: AsyncSession, tenant_id: uuid.UUID, request_id: uuid.UUID, ) -> ApprovalRequest | None: """Mark a pending approval request as expired (system only).""" from sqlalchemy import select result = await db.execute( select(ApprovalRequest).where( ApprovalRequest.id == request_id, ApprovalRequest.tenant_id == tenant_id, ) ) req = result.scalar_one_or_none() if req is None or req.status != "pending": return None req.status = "expired" req.resolved_at = datetime.now(UTC) await db.flush() return req