From 4b75ffd70cd9ce1ae9aba4cafa10f1133157a171 Mon Sep 17 00:00:00 2001 From: Christopher Gondek Date: Thu, 9 Oct 2025 15:30:15 +0200 Subject: [PATCH] feat: add admin endpoints for chatbot tools --- modules/datamodels/datamodelChatbot.py | 68 ++++++++ modules/features/chatBot/service.py | 207 +++++++++++++++++++++++ modules/routes/routeChatbot.py | 225 ++++++++++++++++++++++++- 3 files changed, 499 insertions(+), 1 deletion(-) diff --git a/modules/datamodels/datamodelChatbot.py b/modules/datamodels/datamodelChatbot.py index 0ced3503..d4bb2f3a 100644 --- a/modules/datamodels/datamodelChatbot.py +++ b/modules/datamodels/datamodelChatbot.py @@ -68,6 +68,74 @@ class DeleteResponse(BaseModel, ModelMixin): thread_id: str = Field(..., description="Deleted thread ID") +# Tool Management Models +class ToolInfo(BaseModel, ModelMixin): + """Information about a chatbot tool""" + + id: str = Field(..., description="Tool UUID") + tool_id: str = Field( + ..., description="Tool identifier (e.g., 'shared.tavily_search')" + ) + name: str = Field(..., description="Tool function name") + label: str = Field(..., description="Display label for the tool") + category: str = Field(..., description="Tool category (shared or customer)") + description: str = Field(..., description="Tool description") + is_active: bool = Field(..., description="Whether the tool is active") + date_created: float = Field(..., description="Creation timestamp") + date_updated: float = Field(..., description="Last update timestamp") + + +class ToolListResponse(BaseModel, ModelMixin): + """Response model for listing all tools""" + + tools: List[ToolInfo] = Field(..., description="List of available tools") + + +class GrantToolRequest(BaseModel, ModelMixin): + """Request model for granting a tool to a user""" + + user_id: str = Field(..., description="User ID to grant the tool to") + tool_id: str = Field(..., description="Tool UUID from tools table") + + +class GrantToolResponse(BaseModel, ModelMixin): + """Response model after granting a tool""" + + message: str = Field(..., description="Confirmation message") + user_id: str = Field(..., description="User ID") + tool_id: str = Field(..., description="Tool UUID") + + +class RevokeToolRequest(BaseModel, ModelMixin): + """Request model for revoking a tool from a user""" + + user_id: str = Field(..., description="User ID to revoke the tool from") + tool_id: str = Field(..., description="Tool UUID from tools table") + + +class RevokeToolResponse(BaseModel, ModelMixin): + """Response model after revoking a tool""" + + message: str = Field(..., description="Confirmation message") + user_id: str = Field(..., description="User ID") + tool_id: str = Field(..., description="Tool UUID") + + +class UpdateToolRequest(BaseModel, ModelMixin): + """Request model for updating a tool's label and description""" + + label: Optional[str] = Field(None, description="New label for the tool") + description: Optional[str] = Field(None, description="New description for the tool") + + +class UpdateToolResponse(BaseModel, ModelMixin): + """Response model after updating a tool""" + + message: str = Field(..., description="Confirmation message") + tool_id: str = Field(..., description="Tool UUID") + updated_fields: List[str] = Field(..., description="List of updated field names") + + # Register model labels for internationalization register_model_labels( "MessageItem", diff --git a/modules/features/chatBot/service.py b/modules/features/chatBot/service.py index 510018c3..9c2f7aa6 100644 --- a/modules/features/chatBot/service.py +++ b/modules/features/chatBot/service.py @@ -683,3 +683,210 @@ async def delete_thread_for_user( ) logger.info(f"Successfully deleted thread {thread_id} for user {user.id}") + + +# Tool Management Functions + + +async def get_all_tools(*, session: AsyncSession) -> List[dict]: + """Get all tools from the database. + + Args: + session: The database session for querying. + + Returns: + List of tool dictionaries with all tool information. + """ + from modules.features.chatBot.database import Tool + + logger.info("Fetching all tools from database") + + stmt = select(Tool).order_by(Tool.category, Tool.name) + result = await session.execute(stmt) + tools = result.scalars().all() + + tool_list = [] + for tool in tools: + tool_dict = { + "id": str(tool.id), + "tool_id": tool.tool_id, + "name": tool.name, + "label": tool.label, + "category": tool.category, + "description": tool.description, + "is_active": tool.is_active, + "date_created": tool.date_created.timestamp(), + "date_updated": tool.date_updated.timestamp(), + } + tool_list.append(tool_dict) + + logger.info(f"Retrieved {len(tool_list)} tools from database") + return tool_list + + +async def grant_tool_to_user( + *, user_id: str, tool_id: str, session: AsyncSession +) -> None: + """Grant a tool to a user. + + Args: + user_id: The user ID to grant the tool to. + tool_id: The tool UUID from the tools table. + session: The database session for querying/updating. + + Raises: + ValueError: If the tool doesn't exist, is not active, or user already has the tool. + """ + from modules.features.chatBot.database import Tool, UserToolMapping + import uuid + + logger.info(f"Granting tool {tool_id} to user {user_id}") + + # Convert tool_id string to UUID + try: + tool_uuid = uuid.UUID(tool_id) + except ValueError: + raise ValueError(f"Invalid tool ID format: {tool_id}") + + # Check if tool exists and is active + stmt = select(Tool).where(Tool.id == tool_uuid) + result = await session.execute(stmt) + tool = result.scalar_one_or_none() + + if tool is None: + raise ValueError(f"Tool with ID {tool_id} does not exist") + + if not tool.is_active: + raise ValueError( + f"Cannot grant inactive tool '{tool.label}' (tool_id: {tool.tool_id}). " + f"Please activate the tool first before granting it to users." + ) + + # Check if user already has this tool + stmt = select(UserToolMapping).where( + UserToolMapping.user_id == user_id, UserToolMapping.tool_id == tool_uuid + ) + result = await session.execute(stmt) + existing_mapping = result.scalar_one_or_none() + + if existing_mapping is not None: + raise ValueError( + f"User {user_id} already has access to tool '{tool.label}' (tool_id: {tool.tool_id})" + ) + + # Create new mapping + new_mapping = UserToolMapping( + user_id=user_id, + tool_id=tool_uuid, + is_active=True, + ) + + session.add(new_mapping) + await session.commit() + + logger.info(f"Successfully granted tool {tool_id} ({tool.label}) to user {user_id}") + + +async def revoke_tool_from_user( + *, user_id: str, tool_id: str, session: AsyncSession +) -> None: + """Revoke a tool from a user by deleting the mapping. + + Args: + user_id: The user ID to revoke the tool from. + tool_id: The tool UUID from the tools table. + session: The database session for deleting. + + Raises: + ValueError: If the mapping doesn't exist. + """ + from modules.features.chatBot.database import UserToolMapping + import uuid + + logger.info(f"Revoking tool {tool_id} from user {user_id}") + + # Convert tool_id string to UUID + try: + tool_uuid = uuid.UUID(tool_id) + except ValueError: + raise ValueError(f"Invalid tool ID format: {tool_id}") + + # Delete the mapping + stmt = delete(UserToolMapping).where( + UserToolMapping.user_id == user_id, UserToolMapping.tool_id == tool_uuid + ) + result = await session.execute(stmt) + await session.commit() + + # Check if any rows were deleted + if result.rowcount == 0: + raise ValueError( + f"User {user_id} does not have access to tool {tool_id}, or the mapping does not exist" + ) + + logger.info(f"Successfully revoked tool {tool_id} from user {user_id}") + + +async def update_tool( + *, + tool_id: str, + label: Optional[str], + description: Optional[str], + session: AsyncSession, +) -> List[str]: + """Update a tool's label and/or description. + + Args: + tool_id: The tool UUID to update. + label: Optional new label for the tool. + description: Optional new description for the tool. + session: The database session for updating. + + Returns: + List of updated field names. + + Raises: + ValueError: If the tool doesn't exist or no fields provided to update. + """ + from modules.features.chatBot.database import Tool + import uuid + + logger.info(f"Updating tool {tool_id}") + + # Validate that at least one field is provided + if label is None and description is None: + raise ValueError("At least one field (label or description) must be provided") + + # Convert tool_id string to UUID + try: + tool_uuid = uuid.UUID(tool_id) + except ValueError: + raise ValueError(f"Invalid tool ID format: {tool_id}") + + # Check if tool exists + stmt = select(Tool).where(Tool.id == tool_uuid) + result = await session.execute(stmt) + tool = result.scalar_one_or_none() + + if tool is None: + raise ValueError(f"Tool with ID {tool_id} does not exist") + + # Build update values + update_values = {"date_updated": datetime.now(timezone.utc)} + updated_fields = [] + + if label is not None: + update_values["label"] = label + updated_fields.append("label") + + if description is not None: + update_values["description"] = description + updated_fields.append("description") + + # Update the tool + stmt = update(Tool).where(Tool.id == tool_uuid).values(**update_values) + await session.execute(stmt) + await session.commit() + + logger.info(f"Successfully updated tool {tool_id}, fields: {updated_fields}") + return updated_fields diff --git a/modules/routes/routeChatbot.py b/modules/routes/routeChatbot.py index 1c2b5ed6..b9fbc092 100644 --- a/modules/routes/routeChatbot.py +++ b/modules/routes/routeChatbot.py @@ -12,7 +12,7 @@ from modules.features.chatBot.database import get_async_db_session from modules.features.chatBot.service import ( get_or_create_thread_for_user, ) -from modules.datamodels.datamodelUam import User +from modules.datamodels.datamodelUam import User, UserPrivilege from modules.datamodels.datamodelChatbot import ( ChatMessageRequest, MessageItem, @@ -22,6 +22,14 @@ from modules.datamodels.datamodelChatbot import ( ThreadDetail, RenameThreadRequest, DeleteResponse, + ToolListResponse, + ToolInfo, + GrantToolRequest, + GrantToolResponse, + RevokeToolRequest, + RevokeToolResponse, + UpdateToolRequest, + UpdateToolResponse, ) from modules.security.auth import getCurrentUser, limiter from modules.features.chatBot import service as chat_service @@ -311,3 +319,218 @@ async def delete_thread( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete thread: {type(e).__name__}: {str(e) or 'No error message provided'}", ) + + +# Tool Management Endpoints + + +@router.get("/tools", response_model=ToolListResponse) +@limiter.limit("30/minute") +async def get_all_tools( + *, + request: Request, + currentUser: User = Depends(getCurrentUser), + session: AsyncSession = Depends(get_async_db_session), +) -> ToolListResponse: + """ + Get all available chatbot tools. + Only accessible to system administrators. + """ + try: + # Check SYSADMIN permission + if currentUser.privilege != UserPrivilege.SYSADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only system administrators can view tools", + ) + + # Get all tools from service + tools_data = await chat_service.get_all_tools(session=session) + + # Convert to ToolInfo objects + tools = [ToolInfo(**tool) for tool in tools_data] + + logger.info(f"User {currentUser.id} retrieved {len(tools)} tools") + + return ToolListResponse(tools=tools) + + except HTTPException: + raise + except Exception as e: + logger.error( + f"Error retrieving tools: {type(e).__name__}: {str(e)}", exc_info=True + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to retrieve tools: {type(e).__name__}: {str(e) or 'No error message provided'}", + ) + + +@router.post("/tools/grant", response_model=GrantToolResponse) +@limiter.limit("10/minute") +async def grant_tool_to_user( + *, + request: Request, + grant_request: GrantToolRequest, + currentUser: User = Depends(getCurrentUser), + session: AsyncSession = Depends(get_async_db_session), +) -> GrantToolResponse: + """ + Grant a tool to a user. + Only accessible to system administrators. + """ + try: + # Check SYSADMIN permission + if currentUser.privilege != UserPrivilege.SYSADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only system administrators can grant tools", + ) + + # Grant the tool + await chat_service.grant_tool_to_user( + user_id=grant_request.user_id, + tool_id=grant_request.tool_id, + session=session, + ) + + logger.info( + f"User {currentUser.id} granted tool {grant_request.tool_id} to user {grant_request.user_id}" + ) + + return GrantToolResponse( + message=f"Tool successfully granted to user {grant_request.user_id}", + user_id=grant_request.user_id, + tool_id=grant_request.tool_id, + ) + + except ValueError as e: + logger.error(f"Validation error: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) or "Invalid request", + ) + except HTTPException: + raise + except Exception as e: + logger.error( + f"Error granting tool: {type(e).__name__}: {str(e)}", exc_info=True + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to grant tool: {type(e).__name__}: {str(e) or 'No error message provided'}", + ) + + +@router.delete("/tools/revoke", response_model=RevokeToolResponse) +@limiter.limit("10/minute") +async def revoke_tool_from_user( + *, + request: Request, + revoke_request: RevokeToolRequest, + currentUser: User = Depends(getCurrentUser), + session: AsyncSession = Depends(get_async_db_session), +) -> RevokeToolResponse: + """ + Revoke a tool from a user. + Only accessible to system administrators. + """ + try: + # Check SYSADMIN permission + if currentUser.privilege != UserPrivilege.SYSADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only system administrators can revoke tools", + ) + + # Revoke the tool + await chat_service.revoke_tool_from_user( + user_id=revoke_request.user_id, + tool_id=revoke_request.tool_id, + session=session, + ) + + logger.info( + f"User {currentUser.id} revoked tool {revoke_request.tool_id} from user {revoke_request.user_id}" + ) + + return RevokeToolResponse( + message=f"Tool successfully revoked from user {revoke_request.user_id}", + user_id=revoke_request.user_id, + tool_id=revoke_request.tool_id, + ) + + except ValueError as e: + logger.error(f"Validation error: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) or "Invalid request", + ) + except HTTPException: + raise + except Exception as e: + logger.error( + f"Error revoking tool: {type(e).__name__}: {str(e)}", exc_info=True + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to revoke tool: {type(e).__name__}: {str(e) or 'No error message provided'}", + ) + + +@router.patch("/tools/{tool_id}", response_model=UpdateToolResponse) +@limiter.limit("10/minute") +async def update_tool( + *, + request: Request, + tool_id: str, + update_request: UpdateToolRequest, + currentUser: User = Depends(getCurrentUser), + session: AsyncSession = Depends(get_async_db_session), +) -> UpdateToolResponse: + """ + Update a tool's label and/or description. + Only accessible to system administrators. + """ + try: + # Check SYSADMIN permission + if currentUser.privilege != UserPrivilege.SYSADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only system administrators can update tools", + ) + + # Update the tool + updated_fields = await chat_service.update_tool( + tool_id=tool_id, + label=update_request.label, + description=update_request.description, + session=session, + ) + + logger.info( + f"User {currentUser.id} updated tool {tool_id}, fields: {updated_fields}" + ) + + return UpdateToolResponse( + message="Tool successfully updated", + tool_id=tool_id, + updated_fields=updated_fields, + ) + + except ValueError as e: + logger.error(f"Validation error: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) or "Invalid request", + ) + except HTTPException: + raise + except Exception as e: + logger.error( + f"Error updating tool: {type(e).__name__}: {str(e)}", exc_info=True + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update tool: {type(e).__name__}: {str(e) or 'No error message provided'}", + )