Skip to content
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
23 changes: 23 additions & 0 deletions apps/tools/migrations/0003_alter_tool_template_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-09-09 04:07

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('tools', '0002_alter_tool_tool_type'),
]

operations = [
migrations.AlterField(
model_name='tool',
name='template_id',
field=models.CharField(db_index=True, default=None, max_length=128, null=True, verbose_name='模版id'),
),
migrations.AddField(
model_name='tool',
name='version',
field=models.CharField(default=None, max_length=64, null=True, verbose_name='版本号'),
),
]
3 changes: 2 additions & 1 deletion apps/tools/models/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,12 @@ class Tool(AppModelMixin):
default=ToolScope.WORKSPACE, db_index=True)
tool_type = models.CharField(max_length=20, verbose_name='工具类型', choices=ToolType.choices,
default=ToolType.CUSTOM, db_index=True)
template_id = models.UUIDField(max_length=128, verbose_name="模版id", null=True, default=None, db_index=True)
template_id = models.CharField(max_length=128, verbose_name="模版id", null=True, default=None, db_index=True)
folder = models.ForeignKey(ToolFolder, on_delete=models.DO_NOTHING, verbose_name="文件夹id", default='default')
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)
label = models.CharField(max_length=128, verbose_name="标签", null=True, db_index=True)
version = models.CharField(max_length=64, verbose_name="版本号", null=True, default=None)

class Meta:
db_table = "tool"
140 changes: 139 additions & 1 deletion apps/tools/serializers/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
import os
import pickle
import re
import requests
import tempfile
import zipfile
from typing import Dict

import uuid_utils.compat as uuid
Expand Down Expand Up @@ -124,7 +127,7 @@ class Meta:
model = Tool
fields = ['id', 'name', 'icon', 'desc', 'code', 'input_field_list', 'init_field_list', 'init_params',
'scope', 'is_active', 'user_id', 'template_id', 'workspace_id', 'folder_id', 'tool_type', 'label',
'create_time', 'update_time']
'version', 'create_time', 'update_time']


class ToolExportModelSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -705,6 +708,101 @@ def add(self, instance, with_valid=True):
tool_type=ToolType.CUSTOM,
folder_id=instance.get('folder_id', self.data.get('workspace_id')),
template_id=internal_tool.id,
label=internal_tool.label,
is_active=False
)
tool.save()

# 自动授权给创建者
UserResourcePermissionSerializer(data={
'workspace_id': self.data.get('workspace_id'),
'user_id': self.data.get('user_id'),
'auth_target_type': AuthTargetType.TOOL.value
}).auth_resource(str(tool_id))

return ToolModelSerializer(tool).data

class StoreTool(serializers.Serializer):
user_id = serializers.UUIDField(required=True, label=_("User ID"))
name = serializers.CharField(required=False, label=_("tool name"), allow_null=True, allow_blank=True)

def get_appstore_tools(self):
self.is_valid(raise_exception=True)
# 下载zip文件
try:
res = requests.get('https://apps-assets.fit2cloud.com/stable/maxkb.json.zip', timeout=5)
res.raise_for_status()
# 创建临时文件保存zip
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as temp_zip:
temp_zip.write(res.content)
temp_zip_path = temp_zip.name

try:
# 解压zip文件
with zipfile.ZipFile(temp_zip_path, 'r') as zip_ref:
# 获取zip中的第一个文件(假设只有一个json文件)
json_filename = zip_ref.namelist()[0]
json_content = zip_ref.read(json_filename)

# 将json转换为字典
tool_store = json.loads(json_content.decode('utf-8'))
tag_dict = {tag['name']: tag['key'] for tag in tool_store['additionalProperties']['tags']}
filter_apps = []
for tool in tool_store['apps']:
if self.data.get('name', '') != '':
if self.data.get('name').lower() not in tool.get('name', '').lower():
continue
versions = tool.get('versions', [])
tool['label'] = tag_dict[tool.get('tags')[0]] if tool.get('tags') else ''
tool['version'] = next(
(version.get('name') for version in versions if version.get('downloadUrl') == tool['downloadUrl']),
)
filter_apps.append(tool)

tool_store['apps'] = filter_apps
return tool_store
finally:
# 清理临时文件
os.unlink(temp_zip_path)
except requests.RequestException as e:
maxkb_logger.error(f"fetch appstore tools error: {e}")
return []

class AddStoreTool(serializers.Serializer):
user_id = serializers.UUIDField(required=True, label=_("User ID"))
workspace_id = serializers.CharField(required=True, label=_("workspace id"))
tool_id = serializers.CharField(required=True, label=_("tool id"))

def add(self, instance: Dict, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
AddInternalToolRequest(data=instance).is_valid(raise_exception=True)

versions = instance.get('versions', [])
download_url = instance.get('download_url')
# 查找匹配的版本名称
version_name = next(
(version.get('name') for version in versions if version.get('downloadUrl') == download_url),
)
res = requests.get(download_url, timeout=5)
tool_data = RestrictedUnpickler(io.BytesIO(res.content)).load().tool
tool_id = uuid.uuid7()
tool = Tool(
id=tool_id,
name=tool_data.get('name'),
desc=tool_data.get('desc'),
code=tool_data.get('code'),
user_id=self.data.get('user_id'),
icon=instance.get('icon', ''),
workspace_id=self.data.get('workspace_id'),
input_field_list=tool_data.get('input_field_list', []),
init_field_list=tool_data.get('init_field_list', []),
scope=ToolScope.WORKSPACE,
tool_type=ToolType.CUSTOM,
folder_id=instance.get('folder_id', self.data.get('workspace_id')),
template_id=self.data.get('tool_id'),
label=instance.get('label'),
version=version_name,
is_active=False
)
tool.save()
Expand All @@ -715,10 +813,50 @@ def add(self, instance, with_valid=True):
'user_id': self.data.get('user_id'),
'auth_target_type': AuthTargetType.TOOL.value
}).auth_resource(str(tool_id))
try:
requests.get(instance.get('download_callback_url'), timeout=5)
except Exception as e:
maxkb_logger.error(f"callback appstore tool download error: {e}")
return ToolModelSerializer(tool).data

class UpdateStoreTool(serializers.Serializer):
user_id = serializers.UUIDField(required=True, label=_("User ID"))
workspace_id = serializers.CharField(required=True, label=_("workspace id"))
tool_id = serializers.UUIDField(required=True, label=_("tool id"))
download_url = serializers.CharField(required=True, label=_("download url"))
download_callback_url = serializers.CharField(required=True, label=_("download callback url"))
icon = serializers.CharField(required=True, label=_("icon"), allow_null=True, allow_blank=True)
versions = serializers.ListField(required=True, label=_("versions"), child=serializers.DictField())

def update_tool(self, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
tool = QuerySet(Tool).filter(id=self.data.get('tool_id')).first()
if tool is None:
raise AppApiException(500, _('Tool does not exist'))
# 查找匹配的版本名称
version_name = next(
(version.get('name') for version in self.data.get('versions') if version.get('downloadUrl') == self.data.get('download_url')),
)
res = requests.get(self.data.get('download_url'), timeout=5)
tool_data = RestrictedUnpickler(io.BytesIO(res.content)).load().tool
tool.name = tool_data.get('name')
tool.desc = tool_data.get('desc')
tool.code = tool_data.get('code')
tool.input_field_list = tool_data.get('input_field_list', [])
tool.init_field_list = tool_data.get('init_field_list', [])
tool.icon = self.data.get('icon', tool.icon)
tool.version = version_name
# tool.is_active = False
tool.save()
try:
requests.get(self.data.get('download_callback_url'), timeout=5)
except Exception as e:
maxkb_logger.error(f"callback appstore tool download error: {e}")
return ToolModelSerializer(tool).data



class ToolTreeSerializer(serializers.Serializer):
class Query(serializers.Serializer):
workspace_id = serializers.CharField(required=True, label=_('workspace id'))
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The code provided seems correct overall but has several improvements can be made:

  1. Logging Configuration: Ensure that maxkb_logger is properly configured to handle log messages effectively. Consider setting up logging configurations at different levels such as DEBUG, INFO, WARNING, ERROR, and CRITICAL.

  2. Input Validation: Use Django REST Framework (DRF) validation more extensively within each serializer. This will help ensure data integrity before saving entities to the database.

  3. Error Handling and Logging: Enhance error handling by using custom exceptions or libraries like python-loguru. Also, consider using Python's built-in logging utilities for detailed and comprehensive logging throughout your application.

  4. Performance Improvements: Optimize network requests by adding headers for caching control (if-none-match, cache-control) where possible.

  5. Security Measures: Implement security best practices, such as input sanitization and escaping special characters when dealing with user inputs that could contain HTML or JavaScript payloads.

  6. Documentation: Add comprehensive documentation of APIs and their expected usage patterns. This can include endpoint descriptions, parameters, return values, examples, and any known limitations.

  7. Code Consistency: Ensure consistent coding style across all parts of the codebase, including formatting conventions for comments, spaces, braces, indentation, etc.

Here’s an example of how you might incorporate some of these suggestions into the code:

Updated Code Snippet

import io
from collections import defaultdict
import requests
import tempfile
import zipfile
from django.conf import settings
from django.core.exceptions import ValidationError
from drf_spectacular.utils import extend_schema, set_example_from_serializer
from rest_framework import serializers, status
from rest_framework.request import Request
from rest_framework.views import APIView
from .models import Tool, ToolScope, ToolType, UserResourcePermission
from .schemas.tools.schema import (
    ToolExportModelSerializer,
    ToolTreeSerializer,
)
from .services.tool_service import add_internal_tool_request
from .utils.auth_helper import auth_resource
from uuid_utils.compat import uuid
from ..exceptions.app_api_exception import AppApiException
import json
import zlib
import base64

# Configure logging
import logging
logger = logging.getLogger(__name__)
handler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)

class RestrictedUnpickler(pickle.Unpickler):
    def find_class(self, module, name):
        if __package__ == module or '.' not in file:
            return super(RestrictedUnpickler, self).find_class(module, name)
        raise Exception(f"Forbidden unpickling of non-standard classes from {module}.{name}")

@extend_schema(tags=['Tools'])
class AddTool(APIView):
    @set_example_from_serializer(AddToolSerializer)
    def post(self, request: Request):
        ...

By following these guidelines, you can improve the robustness, maintainability, and performance of your codebase while ensuring it adheres to industry standards for software development.

Expand Down
2 changes: 2 additions & 0 deletions apps/tools/sql/list_tool.sql
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ from (select tool."id"::text,
tool."update_time",
tool.init_field_list,
tool.input_field_list,
tool.version,
tool."is_active"
from tool
left join "user" on "user".id = user_id ${tool_query_set}
Expand All @@ -37,6 +38,7 @@ from (select tool."id"::text,
tool_folder."update_time",
'[]'::jsonb as init_field_list,
'[]'::jsonb as input_field_list,
'' as version,
'true' as "is_active"
from tool_folder
left join "user" on "user".id = user_id ${folder_query_set}) temp
Expand Down
2 changes: 2 additions & 0 deletions apps/tools/sql/list_tool_user.sql
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ FROM (SELECT tool."id"::text,
tool."update_time",
tool.init_field_list,
tool.input_field_list,
tool.version,
tool."is_active"
FROM (SELECT tool.*
FROM tool tool ${tool_query_set}
Expand Down Expand Up @@ -43,6 +44,7 @@ FROM (SELECT tool."id"::text,
tool_folder."update_time",
'[]'::jsonb AS init_field_list,
'[]'::jsonb AS input_field_list,
'' AS version,
'true' AS "is_active"
FROM tool_folder
LEFT JOIN "user" ON "user".id = user_id ${folder_query_set}) temp
Expand Down
2 changes: 2 additions & 0 deletions apps/tools/sql/list_tool_user_ee.sql
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ FROM (SELECT tool."id"::text,
tool."update_time",
tool.init_field_list,
tool.input_field_list,
tool.version,
tool."is_active"
FROM (SELECT tool.*
FROM tool tool ${tool_query_set}
Expand Down Expand Up @@ -53,6 +54,7 @@ FROM (SELECT tool."id"::text,
tool_folder."update_time",
'[]'::jsonb AS init_field_list,
'[]'::jsonb AS input_field_list,
'' AS version,
'true' AS "is_active"
FROM tool_folder
LEFT JOIN "user" ON "user".id = user_id ${folder_query_set}) temp
Expand Down
3 changes: 3 additions & 0 deletions apps/tools/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# @formatter:off
urlpatterns = [
path('workspace/internal/tool', views.ToolView.InternalTool.as_view()),
path('workspace/store/tool', views.ToolView.StoreTool.as_view()),
path('workspace/<str:workspace_id>/tool', views.ToolView.as_view()),
path('workspace/<str:workspace_id>/tool/import', views.ToolView.Import.as_view()),
path('workspace/<str:workspace_id>/tool/pylint', views.ToolView.Pylint.as_view()),
Expand All @@ -15,5 +16,7 @@
path('workspace/<str:workspace_id>/tool/<str:tool_id>/edit_icon', views.ToolView.EditIcon.as_view()),
path('workspace/<str:workspace_id>/tool/<str:tool_id>/export', views.ToolView.Export.as_view()),
path('workspace/<str:workspace_id>/tool/<str:tool_id>/add_internal_tool', views.ToolView.AddInternalTool.as_view()),
path('workspace/<str:workspace_id>/tool/<str:tool_id>/add_store_tool', views.ToolView.AddStoreTool.as_view()),
path('workspace/<str:workspace_id>/tool/<str:tool_id>/update_store_tool', views.ToolView.UpdateStoreTool.as_view()),
path('workspace/<str:workspace_id>/tool/<int:current_page>/<int:page_size>', views.ToolView.Page.as_view()),
]
81 changes: 81 additions & 0 deletions apps/tools/views/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,3 +407,84 @@ def post(self, request: Request, tool_id: str, workspace_id: str):
'user_id': request.user.id,
'workspace_id': workspace_id
}).add(request.data))

class StoreTool(APIView):
authentication_classes = [TokenAuth]

@extend_schema(
methods=['GET'],
description=_("Get Appstore tools"),
summary=_("Get Appstore tools"),
operation_id=_("Get Appstore tools"), # type: ignore
responses=GetInternalToolAPI.get_response(),
tags=[_("Tool")] # type: ignore
)
def get(self, request: Request):
return result.success(ToolSerializer.StoreTool(data={
'user_id': request.user.id,
'name': request.query_params.get('name', ''),
}).get_appstore_tools())

class AddStoreTool(APIView):
authentication_classes = [TokenAuth]

@extend_schema(
methods=['POST'],
description=_("Add Appstore tool"),
summary=_("Add Appstore tool"),
operation_id=_("Add Appstore tool"), # type: ignore
parameters=AddInternalToolAPI.get_parameters(),
request=AddInternalToolAPI.get_request(),
responses=AddInternalToolAPI.get_response(),
tags=[_("Tool")] # type: ignore
)
@has_permissions(
PermissionConstants.TOOL_CREATE.get_workspace_permission(),
PermissionConstants.TOOL_CREATE.get_workspace_permission_workspace_manage_role(),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
RoleConstants.USER.get_workspace_role(),
)
@log(
menu='Tool', operate="Add Appstore tool",
get_operation_object=lambda r, k: get_tool_operation_object(k.get('tool_id')),
)
def post(self, request: Request, tool_id: str, workspace_id: str):
return result.success(ToolSerializer.AddStoreTool(data={
'tool_id': tool_id,
'user_id': request.user.id,
'workspace_id': workspace_id,
}).add(request.data))

class UpdateStoreTool(APIView):
authentication_classes = [TokenAuth]

@extend_schema(
methods=['POST'],
description=_("Update Appstore tool"),
summary=_("Update Appstore tool"),
operation_id=_("Update Appstore tool"), # type: ignore
parameters=AddInternalToolAPI.get_parameters(),
request=AddInternalToolAPI.get_request(),
responses=AddInternalToolAPI.get_response(),
tags=[_("Tool")] # type: ignore
)
@has_permissions(
PermissionConstants.TOOL_CREATE.get_workspace_permission(),
PermissionConstants.TOOL_CREATE.get_workspace_permission_workspace_manage_role(),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
RoleConstants.USER.get_workspace_role(),
)
@log(
menu='Tool', operate="Update Appstore tool",
get_operation_object=lambda r, k: get_tool_operation_object(k.get('tool_id')),
)
def post(self, request: Request, tool_id: str, workspace_id: str):
return result.success(ToolSerializer.UpdateStoreTool(data={
'tool_id': tool_id,
'user_id': request.user.id,
'workspace_id': workspace_id,
'download_url': request.data.get('download_url'),
'download_callback_url': request.data.get('download_callback_url'),
'icon': request.data.get('icon'),
'versions': request.data.get('versions'),
}).update_tool(request.data))
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There are no obvious irregularities in the provided code. However, here are some optimizations and considerations:

  1. Avoid Hardcoding Values: Instead of hard-coding permissions and roles like PermissionConstants.TOOL_CREATE and RoleConstants, consider using a more dynamic approach to retrieve these values or define them centrally.

  2. Use Decorators Efficiently: The use of decorators for authentication (@TokenAuth) and logging can make the code cleaner and easier to manage.

  3. Error Handling: Consider adding better error handling to ensure robustness. For example, you might want to handle invalid requests more gracefully by returning appropriate HTTP status codes.

  4. Response Serialization: Ensure that responses are serialized properly to match expected API formats. Error messages or additional headers should also be included where necessary.

  5. Data Validation: Use Django REST Framework's serializer methods to validate input data before processing it. This helps prevent database errors due to invalid data.

  6. Logging Details: Include more detailed information in logs. Logging timestamps, user IDs, and other relevant context can help with debugging and auditing.

  7. Security: Double-check security aspects such as input validation (e.g., URL schemes, SQL injection) and CSRF protection if applicable.

Here is a revised version with some added comments:

from rest_framework.authentication import TokenAuth
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiResponses

class StoreTool(APIView):
    authentication_classes = [TokenAuth]

    @extend_schema(
        methods=['GET'],
        description=_("Get Appstore tools"),
        summary=_("Get Appstore tools"),
        operation_id=_("Get Appstore tools"),  # type: ignore
        responses=GetInternalToolAPI.get_response(),
        tags=[_("Tool")]  # type: ignore
    )
    def get(self, request: Request):
        name_param = request.query_params.get('name', '')
        return result.success(ToolSerializer.StoreTool(data={
            'user_id': request.user.id,
            'name': name_param,
        }).get_appstore_tools())

class AddStoreTool(APIView):
    authentication_classes = [TokenAuth]

    @extend_schema(
        methods=['POST'],
        description=_("Add Appstore tool"),
        summary=_("Add Appstore tool"),
        operation_id=_("Add Appstore tool"),  # type: ignore
        parameters=AddInternalToolAPI.get_parameters(),
        request=AddInternalToolAPI.get_request(),
        responses=AddInternalToolAPI.get_response(),
        tags=[_("Tool")]  # type: ignore
    )
    @has_permissions(
        PermissionConstants.TOOL_CREATE.get_workspace_permission(),
        PermissionConstants.TOOL_CREATE.get_workspace_permission_workspace_manage_role(),
        RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
        RoleConstants.USER.get_workspace_role(),    
    )
    @log(
        menu='Tool', 
        operate="Add Appstore tool",
        get_operation_object=lambda r, k: get_tool_operation_object(k.get('tool_id')),
    )
    def post(self, request: Request, tool_id: str, workspace_id: str):
        tool_data = {
            'tool_id': tool_id,
            'user_id': request.user.id,
            'workspace_id': workspace_id,
        }
        tool_data.update(request.data)
        download_url = tool_data.get('download_url')
        download_callback_url = tool_data.get('download_callback_url')
        icon = tool_data.get('icon')
        versions = tool_data.get('versions')

        return result.success(ToolSerializer.AddStoreTool(data=tool_data).add(tool_data))

class UpdateStoreTool(APIView):
    authentication_classes = [TokenAuth]

    @extend_schema(
        methods=['POST'],
        description=_("Update Appstore tool"),
        summary=_("Update Appstore tool"),
        operation_id=_("Update Appstore tool"),  # type: ignore
        parameters=AddInternalToolAPI.get_parameters(),
        request=AddInternalToolAPI.get_request(),
        responses=AddInternalToolAPI.get_response(),
        tags=[_("Tool")]  # type: ignore
    )
    @has_permissions(
        PermissionConstants.TOOL_CREATE.get_workspace_permission(),
        PermissionConstants.TOOL_CREATE.get_workspace_permission_workspace_manage_role(),
        RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
        RoleConstants.USER.get_workspace_role(),    
    )
    @log(
        menu='Tool', 
        operate="Update Appstore tool",
        get_operation_object=lambda r, k: get_tool_operation_object(k.get('tool_id')),
    )
    def post(self, request: Request, tool_id: str, workspace_id: str):
        tool_data = {
            'tool_id': tool_id,
            'user_id': request.user.id,
            'workspace_id': workspace_id,
        }
        tool_data.update(request.data)

        return result.success(ToolSerializer.UpdateStoreTool(data=tool_data).update_tool(request.data))

This revision includes minor improvements such as clearer variable names and improved readability.

22 changes: 21 additions & 1 deletion ui/src/api/system-shared/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,24 @@ const addInternalTool: (
return post(`${prefix}/${tool_id}/add_internal_tool`, param, undefined, loading)
}

/**
* 工具商店
*/
const addStoreTool: (
tool_id: string,
param: AddInternalToolParam,
loading?: Ref<boolean>,
) => Promise<Result<any>> = (tool_id, param, loading) => {
return post(`${prefix}/${tool_id}/add_store_tool`, param, undefined, loading)
}

const updateStoreTool: (
tool_id: string,
param: AddInternalToolParam,
loading?: Ref<boolean>,
) => Promise<Result<any>> = (tool_id, param, loading) => {
return post(`${prefix}/${tool_id}/update_store_tool`, param, undefined, loading)
}

export default {
getToolList,
Expand All @@ -156,5 +174,7 @@ export default {
exportTool,
putToolIcon,
delTool,
addInternalTool
addInternalTool,
addStoreTool,
updateStoreTool
}
Loading
Loading