Files
leocrm/app/core/approval.py
T

161 lines
5.0 KiB
Python
Raw Normal View History

"""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
)
request_metadata: Mapped[dict[str, Any]] = mapped_column(
"metadata", 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,
request_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