From 72f666aca16384d52a5409c2df4d9503bf682ad9 Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Wed, 7 May 2025 11:58:59 -0300 Subject: [PATCH] feat(agent): add agent folder management routes and functionality --- src/api/agent_routes.py | 264 +++++++++++++++++++++++++++++++--- src/models/models.py | 26 ++++ src/schemas/schemas.py | 25 +++- src/services/agent_service.py | 185 +++++++++++++++++++++++- 4 files changed, 475 insertions(+), 25 deletions(-) diff --git a/src/api/agent_routes.py b/src/api/agent_routes.py index 4e222be3..36c43b2e 100644 --- a/src/api/agent_routes.py +++ b/src/api/agent_routes.py @@ -1,7 +1,7 @@ -from fastapi import APIRouter, Depends, HTTPException, status, Header +from fastapi import APIRouter, Depends, HTTPException, status, Header, Query from sqlalchemy.orm import Session from src.config.database import get_db -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional import uuid from src.core.jwt_middleware import ( get_jwt_token, @@ -10,6 +10,9 @@ from src.core.jwt_middleware import ( from src.schemas.schemas import ( Agent, AgentCreate, + AgentFolder, + AgentFolderCreate, + AgentFolderUpdate, ) from src.services import ( agent_service, @@ -65,6 +68,243 @@ router = APIRouter( ) +# Rotas para pastas de agentes +@router.post( + "/folders", response_model=AgentFolder, status_code=status.HTTP_201_CREATED +) +async def create_folder( + folder: AgentFolderCreate, + db: Session = Depends(get_db), + payload: dict = Depends(get_jwt_token), +): + """Cria uma nova pasta para organizar agentes""" + # Verifica se o usuário tem acesso ao cliente da pasta + await verify_user_client(payload, db, folder.client_id) + + return agent_service.create_agent_folder( + db, folder.client_id, folder.name, folder.description + ) + + +@router.get("/folders", response_model=List[AgentFolder]) +async def read_folders( + x_client_id: uuid.UUID = Header(..., alias="x-client-id"), + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), + payload: dict = Depends(get_jwt_token), +): + """Lista as pastas de agentes de um cliente""" + # Verifica se o usuário tem acesso aos dados deste cliente + await verify_user_client(payload, db, x_client_id) + + return agent_service.get_agent_folders_by_client(db, x_client_id, skip, limit) + + +@router.get("/folders/{folder_id}", response_model=AgentFolder) +async def read_folder( + folder_id: uuid.UUID, + x_client_id: uuid.UUID = Header(..., alias="x-client-id"), + db: Session = Depends(get_db), + payload: dict = Depends(get_jwt_token), +): + """Obtém os detalhes de uma pasta específica""" + # Verifica se o usuário tem acesso aos dados deste cliente + await verify_user_client(payload, db, x_client_id) + + folder = agent_service.get_agent_folder(db, folder_id) + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Pasta não encontrada" + ) + + # Verifica se a pasta pertence ao cliente informado + if folder.client_id != x_client_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Pasta não pertence ao cliente informado", + ) + + return folder + + +@router.put("/folders/{folder_id}", response_model=AgentFolder) +async def update_folder( + folder_id: uuid.UUID, + folder_data: AgentFolderUpdate, + x_client_id: uuid.UUID = Header(..., alias="x-client-id"), + db: Session = Depends(get_db), + payload: dict = Depends(get_jwt_token), +): + """Atualiza uma pasta de agentes""" + # Verifica se o usuário tem acesso aos dados deste cliente + await verify_user_client(payload, db, x_client_id) + + # Verifica se a pasta existe + folder = agent_service.get_agent_folder(db, folder_id) + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Pasta não encontrada" + ) + + # Verifica se a pasta pertence ao cliente informado + if folder.client_id != x_client_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Pasta não pertence ao cliente informado", + ) + + # Atualiza a pasta + updated_folder = agent_service.update_agent_folder( + db, folder_id, folder_data.name, folder_data.description + ) + return updated_folder + + +@router.delete("/folders/{folder_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_folder( + folder_id: uuid.UUID, + x_client_id: uuid.UUID = Header(..., alias="x-client-id"), + db: Session = Depends(get_db), + payload: dict = Depends(get_jwt_token), +): + """Remove uma pasta de agentes""" + # Verifica se o usuário tem acesso aos dados deste cliente + await verify_user_client(payload, db, x_client_id) + + # Verifica se a pasta existe + folder = agent_service.get_agent_folder(db, folder_id) + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Pasta não encontrada" + ) + + # Verifica se a pasta pertence ao cliente informado + if folder.client_id != x_client_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Pasta não pertence ao cliente informado", + ) + + # Deleta a pasta + if not agent_service.delete_agent_folder(db, folder_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Pasta não encontrada" + ) + + +@router.get("/folders/{folder_id}/agents", response_model=List[Agent]) +async def read_folder_agents( + folder_id: uuid.UUID, + x_client_id: uuid.UUID = Header(..., alias="x-client-id"), + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), + payload: dict = Depends(get_jwt_token), +): + """Lista os agentes em uma pasta específica""" + # Verifica se o usuário tem acesso aos dados deste cliente + await verify_user_client(payload, db, x_client_id) + + # Verifica se a pasta existe + folder = agent_service.get_agent_folder(db, folder_id) + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Pasta não encontrada" + ) + + # Verifica se a pasta pertence ao cliente informado + if folder.client_id != x_client_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Pasta não pertence ao cliente informado", + ) + + # Lista os agentes da pasta + agents = agent_service.get_agents_by_folder(db, folder_id, skip, limit) + + # Adiciona URL do agent card quando necessário + for agent in agents: + if not agent.agent_card_url: + agent.agent_card_url = agent.agent_card_url_property + + return agents + + +@router.put("/{agent_id}/folder", response_model=Agent) +async def assign_agent_to_folder( + agent_id: uuid.UUID, + folder_id: Optional[uuid.UUID] = None, + x_client_id: uuid.UUID = Header(..., alias="x-client-id"), + db: Session = Depends(get_db), + payload: dict = Depends(get_jwt_token), +): + """Atribui um agente a uma pasta ou remove da pasta atual (se folder_id=None)""" + # Verifica se o usuário tem acesso aos dados deste cliente + await verify_user_client(payload, db, x_client_id) + + # Verifica se o agente existe + agent = agent_service.get_agent(db, agent_id) + if not agent: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Agente não encontrado" + ) + + # Verifica se o agente pertence ao cliente informado + if agent.client_id != x_client_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Agente não pertence ao cliente informado", + ) + + # Se folder_id for fornecido, verifica se a pasta existe e pertence ao mesmo cliente + if folder_id: + folder = agent_service.get_agent_folder(db, folder_id) + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Pasta não encontrada" + ) + + if folder.client_id != x_client_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Pasta não pertence ao cliente informado", + ) + + # Atribui o agente à pasta ou remove da pasta atual + updated_agent = agent_service.assign_agent_to_folder(db, agent_id, folder_id) + + if not updated_agent.agent_card_url: + updated_agent.agent_card_url = updated_agent.agent_card_url_property + + return updated_agent + + +# Modificação nas rotas existentes para suportar filtro por pasta +@router.get("/", response_model=List[Agent]) +async def read_agents( + x_client_id: uuid.UUID = Header(..., alias="x-client-id"), + skip: int = 0, + limit: int = 100, + folder_id: Optional[uuid.UUID] = Query(None, description="Filtrar por pasta"), + db: Session = Depends(get_db), + payload: dict = Depends(get_jwt_token), +): + # Verify if the user has access to this client's data + await verify_user_client(payload, db, x_client_id) + + # Get agents with optional folder filter + agents = agent_service.get_agents_by_client( + db, x_client_id, skip, limit, True, folder_id + ) + + for agent in agents: + if not agent.agent_card_url: + agent.agent_card_url = agent.agent_card_url_property + + return agents + + @router.post("/", response_model=Agent, status_code=status.HTTP_201_CREATED) async def create_agent( agent: AgentCreate, @@ -82,26 +322,6 @@ async def create_agent( return db_agent -@router.get("/", response_model=List[Agent]) -async def read_agents( - x_client_id: uuid.UUID = Header(..., alias="x-client-id"), - skip: int = 0, - limit: int = 100, - db: Session = Depends(get_db), - payload: dict = Depends(get_jwt_token), -): - # Verify if the user has access to this client's data - await verify_user_client(payload, db, x_client_id) - - agents = agent_service.get_agents_by_client(db, x_client_id, skip, limit) - - for agent in agents: - if not agent.agent_card_url: - agent.agent_card_url = agent.agent_card_url_property - - return agents - - @router.get("/{agent_id}", response_model=Agent) async def read_agent( agent_id: uuid.UUID, diff --git a/src/models/models.py b/src/models/models.py index f02f5747..91f479da 100644 --- a/src/models/models.py +++ b/src/models/models.py @@ -51,6 +51,23 @@ class User(Base): ) +class AgentFolder(Base): + __tablename__ = "agent_folders" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + client_id = Column(UUID(as_uuid=True), ForeignKey("clients.id", ondelete="CASCADE")) + name = Column(String, nullable=False) + description = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relação com o cliente + client = relationship("Client", backref="agent_folders") + + # Relação com os agentes + agents = relationship("Agent", back_populates="folder") + + class Agent(Base): __tablename__ = "agents" @@ -63,6 +80,12 @@ class Agent(Base): api_key = Column(String, nullable=True, default="") instruction = Column(Text) agent_card_url = Column(String, nullable=True) + # Nova coluna para a pasta - opcional (nullable=True) + folder_id = Column( + UUID(as_uuid=True), + ForeignKey("agent_folders.id", ondelete="SET NULL"), + nullable=True, + ) config = Column(JSON, default={}) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) @@ -74,6 +97,9 @@ class Agent(Base): ), ) + # Relação com a pasta + folder = relationship("AgentFolder", back_populates="agents") + @property def agent_card_url_property(self) -> str: """Virtual URL for the agent card""" diff --git a/src/schemas/schemas.py b/src/schemas/schemas.py index 26078572..fa15b866 100644 --- a/src/schemas/schemas.py +++ b/src/schemas/schemas.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, Field, validator, EmailStr, UUID4, ConfigDict from typing import Optional, Dict, Any, Union, List from datetime import datetime from uuid import UUID @@ -213,3 +213,26 @@ class Tool(ToolBase): class Config: from_attributes = True + + +# Schema para pasta de agentes +class AgentFolderBase(BaseModel): + name: str + description: Optional[str] = None + + +class AgentFolderCreate(AgentFolderBase): + client_id: UUID4 + + +class AgentFolderUpdate(AgentFolderBase): + pass + + +class AgentFolder(AgentFolderBase): + id: UUID4 + client_id: UUID4 + created_at: datetime + updated_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) diff --git a/src/services/agent_service.py b/src/services/agent_service.py index 47a737ee..8df60a1b 100644 --- a/src/services/agent_service.py +++ b/src/services/agent_service.py @@ -1,7 +1,7 @@ from sqlalchemy.orm import Session from sqlalchemy.exc import SQLAlchemyError from fastapi import HTTPException, status -from src.models.models import Agent +from src.models.models import Agent, AgentFolder from src.schemas.schemas import AgentCreate from typing import List, Optional, Dict, Any, Union from src.services.mcp_server_service import get_mcp_server @@ -89,11 +89,16 @@ def get_agents_by_client( skip: int = 0, limit: int = 100, active_only: bool = True, + folder_id: Optional[uuid.UUID] = None, ) -> List[Agent]: - """Search for agents by client with pagination""" + """Search for agents by client with pagination and optional folder filter""" try: query = db.query(Agent).filter(Agent.client_id == client_id) + # Filtra por pasta se especificado + if folder_id is not None: + query = query.filter(Agent.folder_id == folder_id) + agents = query.offset(skip).limit(limit).all() return agents @@ -604,3 +609,179 @@ def activate_agent(db: Session, agent_id: uuid.UUID) -> bool: status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Error reactivating agent", ) + + +# Funções para pastas de agentes +def create_agent_folder( + db: Session, client_id: uuid.UUID, name: str, description: Optional[str] = None +) -> AgentFolder: + """Cria uma nova pasta para organizar agentes""" + try: + folder = AgentFolder(client_id=client_id, name=name, description=description) + db.add(folder) + db.commit() + db.refresh(folder) + logger.info(f"Pasta de agentes criada com sucesso: {folder.id}") + return folder + except SQLAlchemyError as e: + db.rollback() + logger.error(f"Erro ao criar pasta de agentes: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erro ao criar pasta de agentes: {str(e)}", + ) + + +def get_agent_folder(db: Session, folder_id: uuid.UUID) -> Optional[AgentFolder]: + """Busca uma pasta de agentes pelo ID""" + try: + return db.query(AgentFolder).filter(AgentFolder.id == folder_id).first() + except SQLAlchemyError as e: + logger.error(f"Erro ao buscar pasta de agentes {folder_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Erro ao buscar pasta de agentes", + ) + + +def get_agent_folders_by_client( + db: Session, client_id: uuid.UUID, skip: int = 0, limit: int = 100 +) -> List[AgentFolder]: + """Lista as pastas de agentes de um cliente""" + try: + return ( + db.query(AgentFolder) + .filter(AgentFolder.client_id == client_id) + .offset(skip) + .limit(limit) + .all() + ) + except SQLAlchemyError as e: + logger.error(f"Erro ao listar pastas de agentes: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Erro ao listar pastas de agentes", + ) + + +def update_agent_folder( + db: Session, + folder_id: uuid.UUID, + name: Optional[str] = None, + description: Optional[str] = None, +) -> Optional[AgentFolder]: + """Atualiza uma pasta de agentes""" + try: + folder = get_agent_folder(db, folder_id) + if not folder: + return None + + if name is not None: + folder.name = name + if description is not None: + folder.description = description + + db.commit() + db.refresh(folder) + logger.info(f"Pasta de agentes atualizada: {folder_id}") + return folder + except SQLAlchemyError as e: + db.rollback() + logger.error(f"Erro ao atualizar pasta de agentes {folder_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Erro ao atualizar pasta de agentes", + ) + + +def delete_agent_folder(db: Session, folder_id: uuid.UUID) -> bool: + """Remove uma pasta de agentes e desvincula os agentes""" + try: + folder = get_agent_folder(db, folder_id) + if not folder: + return False + + # Desvincula os agentes da pasta (não deleta os agentes) + agents = db.query(Agent).filter(Agent.folder_id == folder_id).all() + for agent in agents: + agent.folder_id = None + + # Deleta a pasta + db.delete(folder) + db.commit() + logger.info(f"Pasta de agentes removida: {folder_id}") + return True + except SQLAlchemyError as e: + db.rollback() + logger.error(f"Erro ao remover pasta de agentes {folder_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Erro ao remover pasta de agentes", + ) + + +def assign_agent_to_folder( + db: Session, agent_id: uuid.UUID, folder_id: Optional[uuid.UUID] +) -> Optional[Agent]: + """Atribui um agente a uma pasta (ou remove da pasta se folder_id for None)""" + try: + agent = get_agent(db, agent_id) + if not agent: + return None + + # Se folder_id for None, remove o agente da pasta atual + if folder_id is None: + agent.folder_id = None + db.commit() + db.refresh(agent) + logger.info(f"Agente removido da pasta: {agent_id}") + return agent + + # Verifica se a pasta existe + folder = get_agent_folder(db, folder_id) + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Pasta não encontrada", + ) + + # Verifica se a pasta pertence ao mesmo cliente do agente + if folder.client_id != agent.client_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A pasta deve pertencer ao mesmo cliente do agente", + ) + + # Atribui o agente à pasta + agent.folder_id = folder_id + db.commit() + db.refresh(agent) + logger.info(f"Agente atribuído à pasta {folder_id}: {agent_id}") + return agent + except SQLAlchemyError as e: + db.rollback() + logger.error(f"Erro ao atribuir agente à pasta: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Erro ao atribuir agente à pasta", + ) + + +def get_agents_by_folder( + db: Session, folder_id: uuid.UUID, skip: int = 0, limit: int = 100 +) -> List[Agent]: + """Lista os agentes de uma pasta específica""" + try: + return ( + db.query(Agent) + .filter(Agent.folder_id == folder_id) + .offset(skip) + .limit(limit) + .all() + ) + except SQLAlchemyError as e: + logger.error(f"Erro ao listar agentes da pasta {folder_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Erro ao listar agentes da pasta", + )