feat: add admin endpoints for chatbot tools

This commit is contained in:
Christopher Gondek 2025-10-09 15:30:15 +02:00
parent 0c5d0f957f
commit 4b75ffd70c
3 changed files with 499 additions and 1 deletions

View file

@ -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",

View file

@ -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

View file

@ -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'}",
)