Skip to content

feat: implement CRUD operations for tools with API views #2925

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/common/constants/permission_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ class PermissionConstants(Enum):

TOOL_CREATE = Permission(group=Group.TOOL, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN,
RoleConstants.USER])
TOOL_EDIT = Permission(group=Group.TOOL, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN,
RoleConstants.USER])
TOOL_READ = Permission(group=Group.TOOL, operate=Operate.READ, role_list=[RoleConstants.ADMIN,
RoleConstants.USER])
TOOL_DELETE = Permission(group=Group.TOOL, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN,
RoleConstants.USER])

def get_workspace_application_permission(self):
return lambda r, kwargs: Permission(group=self.value.group, operate=self.value.operate,
Expand Down
50 changes: 50 additions & 0 deletions apps/tools/api/tool.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# coding=utf-8
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter

from common.mixins.api_mixin import APIMixin
from common.result import ResultSerializer
Expand All @@ -11,10 +13,58 @@ def get_data(self):


class ToolCreateAPI(APIMixin):
@staticmethod
def get_parameters():
return [
OpenApiParameter(
name="workspace_id",
description="工作空间id",
type=OpenApiTypes.STR,
location='path',
required=True,
)
]

@staticmethod
def get_request():
return ToolCreateRequest

@staticmethod
def get_response():
return ToolCreateResponse


class ToolReadAPI(APIMixin):
@staticmethod
def get_parameters():
return [
OpenApiParameter(
name="workspace_id",
description="工作空间id",
type=OpenApiTypes.STR,
location='path',
required=True,
),
OpenApiParameter(
name="tool_id",
description="工具id",
type=OpenApiTypes.STR,
location='path',
required=True,
)
]

@staticmethod
def get_response():
return ToolCreateResponse


class ToolEditAPI(ToolReadAPI):

@staticmethod
def get_request():
return ToolCreateRequest


class ToolDeleteAPI(ToolReadAPI):
pass
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code looks mostly clean, but there are a few suggestions to enhance it:

  1. Use type instead of description: In Django REST Framework (DRF) schema generation tools like Swagger/OpenAPI, using type is recommended over description.

  2. Consistent naming: While not strictly necessary, consistency with method names can make the API more readable.

Here's the revised version:

from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter

from common.mixins.api_mixin import APIMixin
from common.result import ResultSerializer


class WorkflowToolsAPIDefinitions(APIMixin):

    class GetParametersMixin:
        WORKSPACE_ID_PARAM = (
            'workspace_id',
            OpenApiTypes.STR,
            '工作空间id',
            True,
            'path'
        )

        TOOL_CREATE_PARAMETER_GROUP_1 = [WORKSPACE_ID_PARAM]
        TOOL_EDIT_DELETE_PARAMETERS = [WORKSPACE_ID_PARAM]

    def __init__(self):
        super().__init__()
        self.get_parameters = {
            "create": WorkflowToolsAPIDefinitions.ToolCreateAPI.GET_PARAMETERS_MIXIN.TOOL_CREATE_PARAMETER_GROUP_1,
            "read": self.read_api_get_parameters_definition(),
            "edit": WorkflowToolsAPIDefinitions.ToolReadAPI_GET_PARAMETERS_MIXIN.TOOL_EDIT_DELETE_PARAMETERS,
            "delete": WorkflowToolsAPIDefinitions.ToolReadAPI_GET_PARAMETERS_MIXIN.TOOL_EDIT_DELETE_PARAMETERS,
        }

    @property
    def read_api_get_parameters_definition(self):
        tool_id_param_def = OpenApiParameter(
            name="tool_id",
            value_type=OpenApiTypes.STR,
            description="工具id",
            required=True,
            location='path'
        )
        return [WorkflowToolsAPIDefinitions.GetParametersMixin.WORKSPACE_ID_PARAM, tool_id.param]


class ToolCreateApi(WorkflowToolsAPIDefinitions, APIMixin):
    get_response = ResultSerializer


class ToolReadApi(ToolCreateApi):
    pass


class ToolEditApi(ToolReadApi):
    pass


class ToolDeleteApi(ToolReadApi):
    pass

Summary of Changes:

  • Used type in place of description for parameters.
  • Added a mixin (GetParametersMixin) to define parameter structures consistently across different methods.
  • Created instances for each endpoint class, which can be used to generate the appropriate Swagger schemas automatically. This approach avoids duplicate work and makes maintenance easier.

This setup allows you to easily extend or modify the API definitions without repeating logic or causing inconsistencies.

4 changes: 2 additions & 2 deletions apps/tools/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,13 @@ class Migration(migrations.Migration):
models.CharField(choices=[('INTERNAL', '内置'), ('PUBLIC', '公开')], default='PUBLIC', max_length=20,
verbose_name='函数类型')),
('template_id', models.UUIDField(default=None, null=True, verbose_name='模版id')),
('workspace_id', models.CharField(default='default', max_length=64, verbose_name='工作空间id')),
('workspace_id', models.CharField(default='default', max_length=64, verbose_name='工作空间id', db_index=True)),
('init_params', models.CharField(max_length=102400, null=True, verbose_name='初始化参数')),
('create_time', models.DateTimeField(auto_now_add=True, null=True, verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, null=True, verbose_name='修改时间')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.user',
verbose_name='用户id')),
('module_id',
('module',
models.ForeignKey(default='root', on_delete=django.db.models.deletion.CASCADE, to='tools.toolmodule',
verbose_name='模块id')),
],
Expand Down
4 changes: 2 additions & 2 deletions apps/tools/models/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ class Tool(models.Model):
tool_type = models.CharField(max_length=20, verbose_name='函数类型', choices=ToolType.choices,
default=ToolType.PUBLIC)
template_id = models.UUIDField(max_length=128, verbose_name="模版id", null=True, default=None)
module_id = models.ForeignKey(ToolModule, on_delete=models.CASCADE, verbose_name="模块id", default='root')
workspace_id = models.CharField(max_length=64, verbose_name="工作空间id", default="default")
module = models.ForeignKey(ToolModule, on_delete=models.CASCADE, verbose_name="模块id", default='root')
workspace_id = models.CharField(max_length=64, verbose_name="工作空间id", default="default", db_index=True)
init_params = models.CharField(max_length=102400, verbose_name="初始化参数", null=True)
create_time = models.DateTimeField(verbose_name="创建时间", auto_now_add=True, null=True)
update_time = models.DateTimeField(verbose_name="修改时间", auto_now=True, null=True)
Expand Down
47 changes: 45 additions & 2 deletions apps/tools/serializers/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import uuid_utils.compat as uuid
from django.core import validators
from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers

Expand Down Expand Up @@ -30,6 +31,17 @@ class ToolInputField(serializers.Serializer):
])


class InitField(serializers.Serializer):
field = serializers.CharField(required=True, label=_('field name'))
label = serializers.CharField(required=True, label=_('field label'))
required = serializers.BooleanField(required=True, label=_('required'))
input_type = serializers.CharField(required=True, label=_('input type'))
default_value = serializers.CharField(required=False, allow_null=True, allow_blank=True)
show_default_value = serializers.BooleanField(required=False, default=False)
props_info = serializers.DictField(required=False, default=dict)
attrs = serializers.DictField(required=False, default=dict)


class ToolCreateRequest(serializers.Serializer):
name = serializers.CharField(required=True, label=_('tool name'))

Expand All @@ -38,9 +50,10 @@ class ToolCreateRequest(serializers.Serializer):

code = serializers.CharField(required=True, label=_('tool content'))

input_field_list = serializers.ListField(child=ToolInputField(), required=False, label=_('input field list'))
input_field_list = serializers.ListField(child=ToolInputField(), required=False, default=list,
label=_('input field list'))

init_field_list = serializers.ListField(required=False, default=list, label=_('init field list'))
init_field_list = serializers.ListField(child=InitField(), required=False, default=list, label=_('init field list'))

is_active = serializers.BooleanField(required=False, label=_('Is active'))

Expand All @@ -50,6 +63,7 @@ class ToolCreateRequest(serializers.Serializer):
class ToolSerializer(serializers.Serializer):
class Create(serializers.Serializer):
user_id = serializers.UUIDField(required=True, label=_('user id'))
workspace_id = serializers.UUIDField(required=True, label=_('workspace id'))

def insert(self, instance, with_valid=True):
if with_valid:
Expand All @@ -66,3 +80,32 @@ def insert(self, instance, with_valid=True):
is_active=False)
tool.save()
return ToolModelSerializer(tool).data

class Operate(serializers.Serializer):
id = serializers.UUIDField(required=True, label=_('tool id'))
workspace_id = serializers.CharField(required=True, label=_('workspace id'))

def edit(self, instance, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
ToolCreateRequest(data=instance).is_valid(raise_exception=True)
if not QuerySet(Tool).filter(id=self.data.get('id')).exists():
raise serializers.ValidationError(_('Tool not found'))

edit_field_list = ['name', 'desc', 'code', 'icon', 'input_field_list', 'init_field_list', 'init_params',
'is_active']
edit_dict = {field: instance.get(field) for field in edit_field_list if (
field in instance and instance.get(field) is not None)}

QuerySet(Tool).filter(id=self.data.get('id')).update(**edit_dict)

return self.one()

def delete(self):
self.is_valid(raise_exception=True)
QuerySet(Tool).filter(id=self.data.get('id')).delete()

def one(self):
self.is_valid(raise_exception=True)
tool = QuerySet(Tool).filter(id=self.data.get('id')).first()
return ToolModelSerializer(tool).data
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The provided Django REST framework serializers seem mostly well-structured but has a few improvements that can be made:

  1. Null Default Values: For fields like default_value in the InitField, it's good practice to use null='allow_blank'. This allows for an empty string instead of requiring a value.

  2. Unique Constraint on Field Name: Adding unique constraint validation directly within the serializer might be more flexible and easier to maintain than using external checks after saving data.

  3. Error Handling: In general, having consistent error messages across all operations is helpful for debugging purposes.

  4. Use of Meta Class: Ensure proper usage of Django Rest Framework's Meta class for specifying the model used by the serializers.

Here are some specific improvement suggestions:

Updated Serializer Code

from django import forms
from django.core.exceptions import ValidationError
import uuid_utils.compat as uuid
from django.core.validators import MinValueValidator, MaxValueValidator
from rest_framework import parsers, renderers, status
from rest_framework.decorators import api_view, permission_classes, authentication_classes
from drf_yasg.utils import swagger_auto_schema
from drf_yasg.openapi import Response, Schema, Parameter

# Assuming tools module contains models.py with defined Tool and ToolModelSerializer classes
from common.tools.models import Tool

class InitField(serializers.Serializer):
    field = serializers.CharField(required=True, label=_('field name'), validators=[MinLengthValidator(5)])
    label = serializers.CharField(required=True, label=_('field label'))
    required = serializers.BooleanField(required=True, label=_('required'))
    input_type = serializers.CharField(required=True, label=_('input type'))
    default_value = serializers.CharField(required=False, null=True, allow_blank=True)
    show_default_value = serializers.BooleanField(default=False, required=False, label=_('show default value'))
    props_info = serializers.DictField(required=False, default=dict)
    attrs = serializers.DictField(required=False, default=dict)

class ToolCreateRequest(serializers.ModelSerializer):
    class Meta:
        model = Tool
        fields = [
            'name',
            'desc',
            'code',
            'icon'
        ]

    input_field_list = serializers.ListField(child=ToolInputField(), required=False)
    init_field_list = serializers.ListField(child=InitField(), required=False)


@api_view(['POST'])
@permission_classes([])
@authentication_classes([])
@swagger_auto_schema(
    request_body=ToolCreateRequest(),
    responses={status.HTTP_200_OK: Response(description="Success")},
    operation_summary="Create Tool",
    
)
def create_tool(request):
    serializer = ToolCreateRequest(data=request.data)
    try:
        validated_data = serializer.validated_data
        tool_instance = Tool.objects.create(**validated_data)
        
        # Additional logic or processing here
        tool_model_serializer = ToolModelSerializer(tool_instance)
        return Response(tool_model_serializer.data, status=status.HTTP_200_OK)
    except IntegrityError as e:
        return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)



@api_view(['PUT', 'DELETE']) # Assuming we will have these endpoints
@permission_classes([])
@authentication_classes([])
@swagger_auto_schema(
    methods=['put'],
    request_body=forms.ModelForm(fields=[]),
    manual_parameters=[
        Parameter(name='tool_id', in_='path', required=True, description='Tool ID'),
        Parameter(name='workspace_id', in_='query', required=True, description='Workspace ID')
    ],
    responses={
        **{str(status_code): Response(schema=Schema(title=title)) for status_code,title in {
            status.HTTP_200_OK: "Success",
            status.HTTP_204_NO_CONTENT:"No Content"
        } .items()}
    },
    operation_summary="Operate Tool",
    
)
def operate_tool(request, tool_id=None):
    if request.method == 'PUT':
        serializer = OperationSerializer(instance=Tool.objects.get(id=tool_id), data=request.data)
        serializer.is_valid(raise_exception=True)
        serializer.edit(with_valid=True)
        return Response(serializer.data, status=status.HTTP_200_OK)
    elif request.method == 'DELETE':
        serializer = OperationSerializer(data={'id': tool_id})
        serializer.is_valid(raise_exception=True)
        serializer.delete()
        return Response({}, status=status.HTTP_204_NO_CONTENT)


class OperationSerializer(serializers.Serializer):
    id = serializers.UUIDField(required=True)
    workspace_id = serializers.CharField(required=True)

    def edit(self, with_valid=True):
        if with_valid:
            self.is_valid(raise_exception=True)
            
            required_fields = ['name', 'desc', 'code', 'icon', 'input_field_list', 'init_field_list', 'is_active']
            optional_fields = {'init_params'}

            payload = {}
            for field in required_fields + list(optional_fields.intersection(set(request.POST.keys()))):
                if field in payload:
                    continue
                
                if with_valid and (payload.get(field) is None and not isinstance(payload.get(field))
                                     in [None,str,bool,int,float,bool,None]):
                    raise ValueError(f"Required parameter {field} missing")

                payload[str(field)] = request.POST.get(str(field))

            existing_tool = Tool.objects.get(pk=tool_id)
            existing_tool.name = payload.pop('name') if payload.get('name') else existing_tool.name
            existing_tool.desc = payload.pop('desc')if payload.get('desc')else existing_tool.desc
            existing_tool.code = payload.pop('code')if payload.get('code') is not None else existing_tool.code
            existing_tool.icon = payload.pop('icon')if payload.get('icon') is not None else existing_tool.icon  
            existing_tool.input_field_list = payload.pop('input_field_list') if payload.get('input_field_list') else existing_tool.input_field_list             
            existing_tool.init_field_list = payload.pop('init_field_list') if payload.get('init_field_list') else existing_tool.init_field_list           
            existing_tool.is_active = 'true'.lower() if payload.get('is_active').lower() in ('true','yes') else False            
            
            existing_tool.save()

            return OperationSerializer(existing_tool).data
            
    def delete(self):
        query_set = Tool.objects.filter(id=self.id)
        if query_set.count():
            query_set.delete()

These updates introduce new views and serializers (create_tool, operate_tool) to handle different HTTP methods. The OperationSerializer provides both editing and deletion functionality. It also includes parameters for validating and handling exceptions more effectively at runtime.

3 changes: 2 additions & 1 deletion apps/tools/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@

app_name = "tool"
urlpatterns = [
path('workspace/<str:workspace_id>/tool/create', views.ToolCreateView.as_view()),
path('workspace/<str:workspace_id>/tool', views.ToolView.Create.as_view()),
path('workspace/<str:workspace_id>/tool/<str:tool_id>', views.ToolView.Operate.as_view()),
]
72 changes: 56 additions & 16 deletions apps/tools/views/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,62 @@
from common.auth.authentication import has_permissions
from common.constants.permission_constants import PermissionConstants
from common.result import result
from tools.api.tool import ToolCreateAPI
from tools.api.tool import ToolCreateAPI, ToolEditAPI, ToolReadAPI, ToolDeleteAPI
from tools.serializers.tool import ToolSerializer


class ToolCreateView(APIView):
authentication_classes = [TokenAuth]

@extend_schema(methods=['POST'],
description=_('Create tool'),
operation_id=_('Create tool'),
request=ToolCreateAPI.get_request(),
responses=ToolCreateAPI.get_response(),
tags=[_('Tool')])
@has_permissions(PermissionConstants.TOOL_CREATE)
# @log(menu='Tool', operate="Create tool",
# get_operation_object=lambda r, k: r.data.get('name'))
def post(self, request: Request, workspace_id: str):
print(workspace_id)
return result.success(ToolSerializer.Create(data={'user_id': request.user.id}).insert(request.data))
class ToolView(APIView):
class Create(APIView):
authentication_classes = [TokenAuth]

@extend_schema(methods=['POST'],
description=_('Create tool'),
operation_id=_('Create tool'),
parameters=ToolCreateAPI.get_parameters(),
request=ToolCreateAPI.get_request(),
responses=ToolCreateAPI.get_response(),
tags=[_('Tool')])
@has_permissions(PermissionConstants.TOOL_CREATE.get_workspace_permission())
def post(self, request: Request, workspace_id: str):
return result.success(ToolSerializer.Create(
data={'user_id': request.user.id, 'workspace_id': workspace_id}
).insert(request.data))

class Operate(APIView):
authentication_classes = [TokenAuth]

@extend_schema(methods=['PUT'],
description=_('Update tool'),
operation_id=_('Update tool'),
parameters=ToolEditAPI.get_parameters(),
request=ToolEditAPI.get_request(),
responses=ToolEditAPI.get_response(),
tags=[_('Tool')])
@has_permissions(PermissionConstants.TOOL_EDIT.get_workspace_permission())
def put(self, request: Request, workspace_id: str, tool_id: str):
return result.success(ToolSerializer.Operate(
data={'id': tool_id, 'workspace_id': workspace_id}
).edit(request.data))

@extend_schema(methods=['GET'],
description=_('Update tool'),
operation_id=_('Update tool'),
parameters=ToolReadAPI.get_parameters(),
responses=ToolReadAPI.get_response(),
tags=[_('Tool')])
@has_permissions(PermissionConstants.TOOL_READ.get_workspace_permission())
def get(self, request: Request, workspace_id: str, tool_id: str):
return result.success(ToolSerializer.Operate(
data={'id': tool_id, 'workspace_id': workspace_id}
).one())

@extend_schema(methods=['DELETE'],
description=_('Delete tool'),
operation_id=_('Delete tool'),
parameters=ToolDeleteAPI.get_parameters(),
tags=[_('Tool')])
@has_permissions(PermissionConstants.TOOL_DELETE.get_workspace_permission())
def delete(self, request: Request, workspace_id: str, tool_id: str):
return result.success(ToolSerializer.Operate(
data={'id': tool_id, 'workspace_id': workspace_id}
).delete())
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The provided code refactor is mostly correct and well-organized. Here are some minor suggestions and improvements:

  1. Method Classification: Grouping the operations (create, update, read, delete) into Create, Operate classes makes it easier to manage each endpoint separately and provides a clear structure.

  2. Parameters Handling: The parameters attribute has been added where applicable. This is a good practice for handling input parameters in OpenAPI schema generation.

  3. Workspace ID Passivation: The workspace_id parameter is passed around explicitly in the serializer methods. While this may have performance implications if used excessively, it ensures that all necessary information is always available when accessing the data.

  4. Use of Lambdas: Using lambda functions can make the code concise, but ensure they remain readable and understandable. In your case, there aren't many complex expressions, so this approach works fine.

  5. Logging: Although not implemented in the snippet, consider adding logging middleware or logging calls at appropriate levels to help with development and debugging.

  6. Type Annotations: Adding type annotations to variables and function parameters can improve readability and maintainability.

Here's the refactored code with these suggestions:

# Import statements...
from tools.serializers.tool import (
    ToolSerializer,
    ToolCreateAPI,
    ToolEditAPI,
    ToolReadAPI,
    ToolDeleteAPI
)

class ToolView(APIView):
    class Create(APIView):
        authentication_classes = [TokenAuth]

        @extend_schema(methods=['POST'],
                       description=_('Create tool'),
                       operation_id=_('Create tool'),
                       parameters=[
                           Parameter(name='workspace_id', location='path', required=True, format=str),
                           Parameter(name='data', location='body', schema=ToolCreateAPI.get_request(), required=True)
                       ],
                       request=ToolCreateAPI.get_request(),
                       responses=ToolCreateAPI.get_response(),
                       tags=[_('Tool')])
        @has_permissions(PermissionConstants.TOOL_CREATE.get_workspace_permission())
        def post(self, request: Request, workspace_id: str):
            return result.success(
                ToolSerializer.Create(
                    data={'user_id': request.user.id, 'workspace_id': workspace_id}
                ).insert(request.data)
            )

    class Operate(APIView):
        authentication_classes = [TokenAuth]

        @extend_schema(
            methods=['PUT'],
            description=_('Update tool'),
            operation_id=_('Update tool'),
            parameters=[
                Parameter(name='workspace_id', location='path', required=True, format=str),
                Parameter(name='tool_id', location='path', required=True, format=str),
                Parameter(name='data', location='body', schema=ToolEditAPI.get_request(), required=True)
            ],
            request=ToolEditAPI.get_request(),
            responses=ToolEditAPI.get_response(),
            tags=[_('Tool')])
        @has_permissions(PermissionConstants.TOOL_EDIT.get_workspace_permission())
        def put(self, request: Request, workspace_id: str):
            return result.success(
                ToolSerializer.Operate(
                    data={'id': None, 'workspace_id': workspace_id, 'resource_uuid': request.data['id']}
                ).update(request.data)
            )

These changes focus on organizing the code and ensuring clarity, while maintaining functionality and adhering to the project's coding standards.

Loading