feat(05-01): template list/detail/deploy API + RBAC + integration tests
- Create shared/api/templates.py with templates_router
- GET /api/portal/templates: list active templates (any authenticated user)
- GET /api/portal/templates/{id}: get template detail (any authenticated user)
- POST /api/portal/templates/{id}/deploy: create Agent snapshot (tenant_admin only)
- customer_operator returns 403 on deploy (RBAC enforced)
- Export templates_router from shared/api/__init__.py
- Mount templates_router in gateway/main.py (Phase 5 section)
- 11 integration tests pass (list, detail, deploy, RBAC, 404, snapshot independence)
This commit is contained in:
@@ -46,6 +46,7 @@ from shared.api import (
|
||||
invitations_router,
|
||||
llm_keys_router,
|
||||
portal_router,
|
||||
templates_router,
|
||||
usage_router,
|
||||
webhook_router,
|
||||
)
|
||||
@@ -140,6 +141,11 @@ app.include_router(webhook_router)
|
||||
# ---------------------------------------------------------------------------
|
||||
app.include_router(invitations_router)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Register Phase 5 Employee Design routers
|
||||
# ---------------------------------------------------------------------------
|
||||
app.include_router(templates_router)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
|
||||
@@ -9,6 +9,7 @@ from shared.api.channels import channels_router
|
||||
from shared.api.invitations import invitations_router
|
||||
from shared.api.llm_keys import llm_keys_router
|
||||
from shared.api.portal import portal_router
|
||||
from shared.api.templates import templates_router
|
||||
from shared.api.usage import usage_router
|
||||
|
||||
__all__ = [
|
||||
@@ -19,4 +20,5 @@ __all__ = [
|
||||
"llm_keys_router",
|
||||
"usage_router",
|
||||
"invitations_router",
|
||||
"templates_router",
|
||||
]
|
||||
|
||||
186
packages/shared/shared/api/templates.py
Normal file
186
packages/shared/shared/api/templates.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
FastAPI template API router — agent template gallery and deploy endpoints.
|
||||
|
||||
Templates are global (not tenant-scoped): any authenticated portal user can
|
||||
browse them. Only tenant admins can deploy a template (creates an Agent snapshot).
|
||||
|
||||
Mounted at /api/portal in gateway/main.py alongside other portal routers.
|
||||
|
||||
Endpoints:
|
||||
GET /api/portal/templates — list active templates (all authenticated users)
|
||||
GET /api/portal/templates/{id} — get template detail (all authenticated users)
|
||||
POST /api/portal/templates/{id}/deploy — deploy template as agent (tenant admin only)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.api.portal import AgentResponse
|
||||
from shared.api.rbac import PortalCaller, get_portal_caller, require_tenant_admin
|
||||
from shared.db import get_session
|
||||
from shared.models.tenant import Agent, AgentTemplate
|
||||
from shared.rls import current_tenant_id
|
||||
|
||||
templates_router = APIRouter(prefix="/api/portal", tags=["templates"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
role: str
|
||||
description: str
|
||||
category: str
|
||||
persona: str
|
||||
system_prompt: str
|
||||
model_preference: str
|
||||
tool_assignments: list[Any]
|
||||
escalation_rules: list[Any]
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@classmethod
|
||||
def from_orm(cls, tmpl: AgentTemplate) -> "TemplateResponse":
|
||||
return cls(
|
||||
id=str(tmpl.id),
|
||||
name=tmpl.name,
|
||||
role=tmpl.role,
|
||||
description=tmpl.description,
|
||||
category=tmpl.category,
|
||||
persona=tmpl.persona,
|
||||
system_prompt=tmpl.system_prompt,
|
||||
model_preference=tmpl.model_preference,
|
||||
tool_assignments=tmpl.tool_assignments,
|
||||
escalation_rules=tmpl.escalation_rules,
|
||||
is_active=tmpl.is_active,
|
||||
sort_order=tmpl.sort_order,
|
||||
created_at=tmpl.created_at,
|
||||
)
|
||||
|
||||
|
||||
class TemplateDeployRequest(BaseModel):
|
||||
tenant_id: uuid.UUID
|
||||
|
||||
|
||||
class TemplateDeployResponse(BaseModel):
|
||||
agent: AgentResponse
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@templates_router.get("/templates", response_model=list[TemplateResponse])
|
||||
async def list_templates(
|
||||
caller: PortalCaller = Depends(get_portal_caller),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[TemplateResponse]:
|
||||
"""
|
||||
List all active agent templates.
|
||||
|
||||
Available to all authenticated portal users (any role).
|
||||
Templates are global — not tenant-scoped, no RLS needed.
|
||||
Returns templates ordered by sort_order ascending, then name.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(AgentTemplate)
|
||||
.where(AgentTemplate.is_active == True) # noqa: E712
|
||||
.order_by(AgentTemplate.sort_order.asc(), AgentTemplate.name.asc())
|
||||
)
|
||||
templates = result.scalars().all()
|
||||
return [TemplateResponse.from_orm(t) for t in templates]
|
||||
|
||||
|
||||
@templates_router.get("/templates/{template_id}", response_model=TemplateResponse)
|
||||
async def get_template(
|
||||
template_id: uuid.UUID,
|
||||
caller: PortalCaller = Depends(get_portal_caller),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TemplateResponse:
|
||||
"""
|
||||
Get a single active agent template by ID.
|
||||
|
||||
Returns 404 if the template does not exist or is inactive.
|
||||
Available to all authenticated portal users (any role).
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(AgentTemplate).where(
|
||||
AgentTemplate.id == template_id,
|
||||
AgentTemplate.is_active == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
tmpl = result.scalar_one_or_none()
|
||||
if tmpl is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
return TemplateResponse.from_orm(tmpl)
|
||||
|
||||
|
||||
@templates_router.post(
|
||||
"/templates/{template_id}/deploy",
|
||||
response_model=TemplateDeployResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def deploy_template(
|
||||
template_id: uuid.UUID,
|
||||
body: TemplateDeployRequest,
|
||||
caller: PortalCaller = Depends(get_portal_caller),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TemplateDeployResponse:
|
||||
"""
|
||||
Deploy a template as an independent Agent snapshot for a tenant.
|
||||
|
||||
Guard: tenant admin only (customer_operator gets 403).
|
||||
The deployed Agent is a point-in-time snapshot — subsequent changes to the
|
||||
template do not affect already-deployed agents.
|
||||
|
||||
Returns 404 if template not found. Returns 403 if caller lacks admin access
|
||||
to the specified tenant.
|
||||
"""
|
||||
# RBAC: tenant admin check (reuses require_tenant_admin from rbac.py)
|
||||
await require_tenant_admin(body.tenant_id, caller, session)
|
||||
|
||||
# Fetch template (404 if not found or inactive)
|
||||
result = await session.execute(
|
||||
select(AgentTemplate).where(AgentTemplate.id == template_id)
|
||||
)
|
||||
tmpl = result.scalar_one_or_none()
|
||||
if tmpl is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
|
||||
# Create Agent snapshot scoped to the target tenant
|
||||
token = current_tenant_id.set(body.tenant_id)
|
||||
try:
|
||||
agent = Agent(
|
||||
tenant_id=body.tenant_id,
|
||||
name=tmpl.name,
|
||||
role=tmpl.role,
|
||||
persona=tmpl.persona,
|
||||
system_prompt=tmpl.system_prompt,
|
||||
model_preference=tmpl.model_preference,
|
||||
tool_assignments=tmpl.tool_assignments,
|
||||
escalation_rules=tmpl.escalation_rules,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(agent)
|
||||
await session.commit()
|
||||
await session.refresh(agent)
|
||||
finally:
|
||||
current_tenant_id.reset(token)
|
||||
|
||||
return TemplateDeployResponse(agent=AgentResponse.from_orm(agent))
|
||||
Reference in New Issue
Block a user