Files
leocrm/app/routes/approvals.py
T
Agent Zero 680557087e feat(F): F-TEST + F-DOC + F-UI-TRIG — Phase F complete!
- F-TEST: tests/test_phase_f_agents.py (1425 lines, 45 tests, all pass) — ReAct Loop, Permissions, Approvals, Skills, Context Builder, Data Policy, Transparency, Workstream, Budget
- F-DOC: docs/api-documentation.md (Phase F endpoints), docs/plugin-development-guide.md (Agent chapter 32), docs/test-strategy.md (Phase F test conventions)
- F-UI-TRIG: trigger_dispatcher dispatches agents on ui.*/context.* events (already implemented in F-PROACTIVE)
- Bug fix: approval.py metadata reserved attribute renamed to request_metadata
- PROGRESS.md: Phase F marked done, ~155/223 tasks done (70%)
2026-08-17 19:36:42 +02:00

306 lines
9.5 KiB
Python

"""API routes for approval requests — /api/v1/approvals.
Endpoints: create, list (filterable), get detail, approve, reject, expire.
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.approval import (
APPROVAL_STATUSES,
ApprovalRequest,
create_approval_request,
expire_approval_request,
resolve_approval_request,
)
from app.core.db import get_db
from app.deps import get_current_user, require_permission
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/approvals", tags=["approvals"])
# ─── Schemas ───
class ApprovalCreate(BaseModel):
"""Create an approval request."""
entity_type: str = Field(..., min_length=1, max_length=80)
entity_id: str = Field(..., min_length=1)
action: str = Field(..., min_length=1, max_length=120)
requested_by: str | None = None
requested_by_type: str = Field("agent", pattern="^(user|agent|system)$")
approver_id: str | None = None
approver_group: str | None = Field(None, max_length=120)
expires_at: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ApprovalResolve(BaseModel):
"""Approve or reject an approval request."""
comment: str | None = Field(None, max_length=2000)
class ApprovalResponse(BaseModel):
"""Approval request response."""
id: str
tenant_id: str
entity_type: str
entity_id: str
action: str
requested_by: str
requested_by_type: str
approver_id: str | None = None
approver_group: str | None = None
status: str
comment: str | None = None
created_at: str | None = None
resolved_at: str | None = None
expires_at: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ApprovalListResponse(BaseModel):
"""Paginated approval request list."""
items: list[ApprovalResponse]
total: int
# ─── Helpers ───
def _to_response(r: ApprovalRequest) -> ApprovalResponse:
return ApprovalResponse(
id=str(r.id),
tenant_id=str(r.tenant_id),
entity_type=r.entity_type,
entity_id=str(r.entity_id),
action=r.action,
requested_by=str(r.requested_by),
requested_by_type=r.requested_by_type,
approver_id=str(r.approver_id) if r.approver_id else None,
approver_group=r.approver_group,
status=r.status,
comment=r.comment,
created_at=r.created_at.isoformat() if r.created_at else None,
resolved_at=r.resolved_at.isoformat() if r.resolved_at else None,
expires_at=r.expires_at.isoformat() if r.expires_at else None,
metadata=r.request_metadata or {},
)
def _parse_uuid(value: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(value)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail=f"Invalid {field}") from None
# ─── Endpoints ───
@router.post(
"",
dependencies=[Depends(require_permission("approvals:write"))],
response_model=ApprovalResponse,
status_code=201,
)
async def create_approval(
data: ApprovalCreate,
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Create a new approval request."""
tenant_id = uuid.UUID(current_user["tenant_id"])
entity_id = _parse_uuid(data.entity_id, "entity_id")
requested_by = (
_parse_uuid(data.requested_by, "requested_by")
if data.requested_by
else uuid.UUID(current_user["user_id"])
)
approver_id = _parse_uuid(data.approver_id, "approver_id") if data.approver_id else None
expires_at = None
if data.expires_at:
try:
expires_at = datetime.fromisoformat(data.expires_at.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(status_code=400, detail="Invalid expires_at") from None
req = await create_approval_request(
db,
tenant_id,
entity_type=data.entity_type,
entity_id=entity_id,
action=data.action,
requested_by=requested_by,
requested_by_type=data.requested_by_type,
approver_id=approver_id,
approver_group=data.approver_group,
expires_at=expires_at,
metadata=data.metadata,
)
await db.commit()
return _to_response(req)
@router.get(
"",
dependencies=[Depends(require_permission("approvals:read"))],
response_model=ApprovalListResponse,
)
async def list_approvals(
status: str | None = Query(None),
entity_type: str | None = Query(None),
entity_id: str | None = Query(None),
requested_by: str | None = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List approval requests with optional filters."""
tenant_id = uuid.UUID(current_user["tenant_id"])
query = select(ApprovalRequest).where(ApprovalRequest.tenant_id == tenant_id)
count_query = (
select(__import__("sqlalchemy").func.count())
.select_from(ApprovalRequest)
.where(ApprovalRequest.tenant_id == tenant_id)
)
if status:
query = query.where(ApprovalRequest.status == status)
count_query = count_query.where(ApprovalRequest.status == status)
if entity_type:
query = query.where(ApprovalRequest.entity_type == entity_type)
count_query = count_query.where(ApprovalRequest.entity_type == entity_type)
if entity_id:
eid = _parse_uuid(entity_id, "entity_id")
query = query.where(ApprovalRequest.entity_id == eid)
count_query = count_query.where(ApprovalRequest.entity_id == eid)
if requested_by:
rid = _parse_uuid(requested_by, "requested_by")
query = query.where(ApprovalRequest.requested_by == rid)
count_query = count_query.where(ApprovalRequest.requested_by == rid)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(
query.order_by(ApprovalRequest.created_at.desc()).limit(limit).offset(offset)
)
items = list(result.scalars().all())
return ApprovalListResponse(items=[_to_response(r) for r in items], total=total)
@router.get(
"/{request_id}",
dependencies=[Depends(require_permission("approvals:read"))],
response_model=ApprovalResponse,
)
async def get_approval(
request_id: str,
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get a single approval request by ID."""
tenant_id = uuid.UUID(current_user["tenant_id"])
rid = _parse_uuid(request_id, "request_id")
result = await db.execute(
select(ApprovalRequest).where(
ApprovalRequest.id == rid,
ApprovalRequest.tenant_id == tenant_id,
)
)
req = result.scalar_one_or_none()
if req is None:
raise HTTPException(status_code=404, detail="Approval request not found")
return _to_response(req)
@router.post(
"/{request_id}/approve",
dependencies=[Depends(require_permission("approvals:approve"))],
response_model=ApprovalResponse,
)
async def approve_approval(
request_id: str,
body: ApprovalResolve,
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Approve a pending approval request."""
tenant_id = uuid.UUID(current_user["tenant_id"])
rid = _parse_uuid(request_id, "request_id")
req = await resolve_approval_request(
db,
tenant_id,
rid,
decision="approved",
approver_id=uuid.UUID(current_user["user_id"]),
comment=body.comment,
)
if req is None:
raise HTTPException(status_code=404, detail="Approval request not found or not pending")
await db.commit()
return _to_response(req)
@router.post(
"/{request_id}/reject",
dependencies=[Depends(require_permission("approvals:approve"))],
response_model=ApprovalResponse,
)
async def reject_approval(
request_id: str,
body: ApprovalResolve,
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Reject a pending approval request."""
tenant_id = uuid.UUID(current_user["tenant_id"])
rid = _parse_uuid(request_id, "request_id")
req = await resolve_approval_request(
db,
tenant_id,
rid,
decision="rejected",
approver_id=uuid.UUID(current_user["user_id"]),
comment=body.comment,
)
if req is None:
raise HTTPException(status_code=404, detail="Approval request not found or not pending")
await db.commit()
return _to_response(req)
@router.post(
"/{request_id}/expire",
dependencies=[Depends(require_permission("approvals:write"))],
response_model=ApprovalResponse,
)
async def expire_approval(
request_id: str,
current_user: dict[str, Any] = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Mark a pending approval request as expired (system only)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
rid = _parse_uuid(request_id, "request_id")
req = await expire_approval_request(db, tenant_id, rid)
if req is None:
raise HTTPException(status_code=404, detail="Approval request not found or not pending")
await db.commit()
return _to_response(req)