Skip to content

Commit da5678f

Browse files
committed
feat: add Appstore tool retrieval and store tool API endpoint
1 parent a5d046c commit da5678f

File tree

17 files changed

+468
-10
lines changed

17 files changed

+468
-10
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Django 5.2.4 on 2025-09-09 04:07
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('tools', '0002_alter_tool_tool_type'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='tool',
15+
name='template_id',
16+
field=models.CharField(db_index=True, default=None, max_length=128, null=True, verbose_name='模版id'),
17+
),
18+
migrations.AddField(
19+
model_name='tool',
20+
name='version',
21+
field=models.CharField(default=None, max_length=64, null=True, verbose_name='版本号'),
22+
),
23+
]

apps/tools/models/tool.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,12 @@ class Tool(AppModelMixin):
4848
default=ToolScope.WORKSPACE, db_index=True)
4949
tool_type = models.CharField(max_length=20, verbose_name='工具类型', choices=ToolType.choices,
5050
default=ToolType.CUSTOM, db_index=True)
51-
template_id = models.UUIDField(max_length=128, verbose_name="模版id", null=True, default=None, db_index=True)
51+
template_id = models.CharField(max_length=128, verbose_name="模版id", null=True, default=None, db_index=True)
5252
folder = models.ForeignKey(ToolFolder, on_delete=models.DO_NOTHING, verbose_name="文件夹id", default='default')
5353
workspace_id = models.CharField(max_length=64, verbose_name="工作空间id", default="default", db_index=True)
5454
init_params = models.CharField(max_length=102400, verbose_name="初始化参数", null=True)
5555
label = models.CharField(max_length=128, verbose_name="标签", null=True, db_index=True)
56+
version = models.CharField(max_length=64, verbose_name="版本号", null=True, default=None)
5657

5758
class Meta:
5859
db_table = "tool"

apps/tools/serializers/tool.py

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
import os
66
import pickle
77
import re
8+
import requests
9+
import tempfile
10+
import zipfile
811
from typing import Dict
912

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

129132

130133
class ToolExportModelSerializer(serializers.ModelSerializer):
@@ -705,6 +708,101 @@ def add(self, instance, with_valid=True):
705708
tool_type=ToolType.CUSTOM,
706709
folder_id=instance.get('folder_id', self.data.get('workspace_id')),
707710
template_id=internal_tool.id,
711+
label=internal_tool.label,
712+
is_active=False
713+
)
714+
tool.save()
715+
716+
# 自动授权给创建者
717+
UserResourcePermissionSerializer(data={
718+
'workspace_id': self.data.get('workspace_id'),
719+
'user_id': self.data.get('user_id'),
720+
'auth_target_type': AuthTargetType.TOOL.value
721+
}).auth_resource(str(tool_id))
722+
723+
return ToolModelSerializer(tool).data
724+
725+
class StoreTool(serializers.Serializer):
726+
user_id = serializers.UUIDField(required=True, label=_("User ID"))
727+
name = serializers.CharField(required=False, label=_("tool name"), allow_null=True, allow_blank=True)
728+
729+
def get_appstore_tools(self):
730+
self.is_valid(raise_exception=True)
731+
# 下载zip文件
732+
try:
733+
res = requests.get('https://apps-assets.fit2cloud.com/stable/maxkb.json.zip', timeout=5)
734+
res.raise_for_status()
735+
# 创建临时文件保存zip
736+
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as temp_zip:
737+
temp_zip.write(res.content)
738+
temp_zip_path = temp_zip.name
739+
740+
try:
741+
# 解压zip文件
742+
with zipfile.ZipFile(temp_zip_path, 'r') as zip_ref:
743+
# 获取zip中的第一个文件(假设只有一个json文件)
744+
json_filename = zip_ref.namelist()[0]
745+
json_content = zip_ref.read(json_filename)
746+
747+
# 将json转换为字典
748+
tool_store = json.loads(json_content.decode('utf-8'))
749+
tag_dict = {tag['name']: tag['key'] for tag in tool_store['additionalProperties']['tags']}
750+
filter_apps = []
751+
for tool in tool_store['apps']:
752+
if self.data.get('name', '') != '':
753+
if self.data.get('name').lower() not in tool.get('name', '').lower():
754+
continue
755+
versions = tool.get('versions', [])
756+
tool['label'] = tag_dict[tool.get('tags')[0]] if tool.get('tags') else ''
757+
tool['version'] = next(
758+
(version.get('name') for version in versions if version.get('downloadUrl') == tool['downloadUrl']),
759+
)
760+
filter_apps.append(tool)
761+
762+
tool_store['apps'] = filter_apps
763+
return tool_store
764+
finally:
765+
# 清理临时文件
766+
os.unlink(temp_zip_path)
767+
except requests.RequestException as e:
768+
maxkb_logger.error(f"fetch appstore tools error: {e}")
769+
return []
770+
771+
class AddStoreTool(serializers.Serializer):
772+
user_id = serializers.UUIDField(required=True, label=_("User ID"))
773+
workspace_id = serializers.CharField(required=True, label=_("workspace id"))
774+
tool_id = serializers.CharField(required=True, label=_("tool id"))
775+
776+
def add(self, instance: Dict, with_valid=True):
777+
if with_valid:
778+
self.is_valid(raise_exception=True)
779+
AddInternalToolRequest(data=instance).is_valid(raise_exception=True)
780+
781+
versions = instance.get('versions', [])
782+
download_url = instance.get('download_url')
783+
# 查找匹配的版本名称
784+
version_name = next(
785+
(version.get('name') for version in versions if version.get('downloadUrl') == download_url),
786+
)
787+
res = requests.get(download_url, timeout=5)
788+
tool_data = RestrictedUnpickler(io.BytesIO(res.content)).load().tool
789+
tool_id = uuid.uuid7()
790+
tool = Tool(
791+
id=tool_id,
792+
name=tool_data.get('name'),
793+
desc=tool_data.get('desc'),
794+
code=tool_data.get('code'),
795+
user_id=self.data.get('user_id'),
796+
icon=instance.get('icon', ''),
797+
workspace_id=self.data.get('workspace_id'),
798+
input_field_list=tool_data.get('input_field_list', []),
799+
init_field_list=tool_data.get('init_field_list', []),
800+
scope=ToolScope.WORKSPACE,
801+
tool_type=ToolType.CUSTOM,
802+
folder_id=instance.get('folder_id', self.data.get('workspace_id')),
803+
template_id=self.data.get('tool_id'),
804+
label=instance.get('label'),
805+
version=version_name,
708806
is_active=False
709807
)
710808
tool.save()
@@ -715,10 +813,50 @@ def add(self, instance, with_valid=True):
715813
'user_id': self.data.get('user_id'),
716814
'auth_target_type': AuthTargetType.TOOL.value
717815
}).auth_resource(str(tool_id))
816+
try:
817+
requests.get(instance.get('download_callback_url'), timeout=5)
818+
except Exception as e:
819+
maxkb_logger.error(f"callback appstore tool download error: {e}")
820+
return ToolModelSerializer(tool).data
718821

822+
class UpdateStoreTool(serializers.Serializer):
823+
user_id = serializers.UUIDField(required=True, label=_("User ID"))
824+
workspace_id = serializers.CharField(required=True, label=_("workspace id"))
825+
tool_id = serializers.UUIDField(required=True, label=_("tool id"))
826+
download_url = serializers.CharField(required=True, label=_("download url"))
827+
download_callback_url = serializers.CharField(required=True, label=_("download callback url"))
828+
icon = serializers.CharField(required=True, label=_("icon"), allow_null=True, allow_blank=True)
829+
versions = serializers.ListField(required=True, label=_("versions"), child=serializers.DictField())
830+
831+
def update_tool(self, with_valid=True):
832+
if with_valid:
833+
self.is_valid(raise_exception=True)
834+
tool = QuerySet(Tool).filter(id=self.data.get('tool_id')).first()
835+
if tool is None:
836+
raise AppApiException(500, _('Tool does not exist'))
837+
# 查找匹配的版本名称
838+
version_name = next(
839+
(version.get('name') for version in self.data.get('versions') if version.get('downloadUrl') == self.data.get('download_url')),
840+
)
841+
res = requests.get(self.data.get('download_url'), timeout=5)
842+
tool_data = RestrictedUnpickler(io.BytesIO(res.content)).load().tool
843+
tool.name = tool_data.get('name')
844+
tool.desc = tool_data.get('desc')
845+
tool.code = tool_data.get('code')
846+
tool.input_field_list = tool_data.get('input_field_list', [])
847+
tool.init_field_list = tool_data.get('init_field_list', [])
848+
tool.icon = self.data.get('icon', tool.icon)
849+
tool.version = version_name
850+
# tool.is_active = False
851+
tool.save()
852+
try:
853+
requests.get(self.data.get('download_callback_url'), timeout=5)
854+
except Exception as e:
855+
maxkb_logger.error(f"callback appstore tool download error: {e}")
719856
return ToolModelSerializer(tool).data
720857

721858

859+
722860
class ToolTreeSerializer(serializers.Serializer):
723861
class Query(serializers.Serializer):
724862
workspace_id = serializers.CharField(required=True, label=_('workspace id'))

apps/tools/sql/list_tool.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ from (select tool."id"::text,
1616
tool."update_time",
1717
tool.init_field_list,
1818
tool.input_field_list,
19+
tool.version,
1920
tool."is_active"
2021
from tool
2122
left join "user" on "user".id = user_id ${tool_query_set}
@@ -37,6 +38,7 @@ from (select tool."id"::text,
3738
tool_folder."update_time",
3839
'[]'::jsonb as init_field_list,
3940
'[]'::jsonb as input_field_list,
41+
'' as version,
4042
'true' as "is_active"
4143
from tool_folder
4244
left join "user" on "user".id = user_id ${folder_query_set}) temp

apps/tools/sql/list_tool_user.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ FROM (SELECT tool."id"::text,
1616
tool."update_time",
1717
tool.init_field_list,
1818
tool.input_field_list,
19+
tool.version,
1920
tool."is_active"
2021
FROM (SELECT tool.*
2122
FROM tool tool ${tool_query_set}
@@ -43,6 +44,7 @@ FROM (SELECT tool."id"::text,
4344
tool_folder."update_time",
4445
'[]'::jsonb AS init_field_list,
4546
'[]'::jsonb AS input_field_list,
47+
'' AS version,
4648
'true' AS "is_active"
4749
FROM tool_folder
4850
LEFT JOIN "user" ON "user".id = user_id ${folder_query_set}) temp

apps/tools/sql/list_tool_user_ee.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ FROM (SELECT tool."id"::text,
1616
tool."update_time",
1717
tool.init_field_list,
1818
tool.input_field_list,
19+
tool.version,
1920
tool."is_active"
2021
FROM (SELECT tool.*
2122
FROM tool tool ${tool_query_set}
@@ -53,6 +54,7 @@ FROM (SELECT tool."id"::text,
5354
tool_folder."update_time",
5455
'[]'::jsonb AS init_field_list,
5556
'[]'::jsonb AS input_field_list,
57+
'' AS version,
5658
'true' AS "is_active"
5759
FROM tool_folder
5860
LEFT JOIN "user" ON "user".id = user_id ${folder_query_set}) temp

apps/tools/urls.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# @formatter:off
77
urlpatterns = [
88
path('workspace/internal/tool', views.ToolView.InternalTool.as_view()),
9+
path('workspace/store/tool', views.ToolView.StoreTool.as_view()),
910
path('workspace/<str:workspace_id>/tool', views.ToolView.as_view()),
1011
path('workspace/<str:workspace_id>/tool/import', views.ToolView.Import.as_view()),
1112
path('workspace/<str:workspace_id>/tool/pylint', views.ToolView.Pylint.as_view()),
@@ -15,5 +16,7 @@
1516
path('workspace/<str:workspace_id>/tool/<str:tool_id>/edit_icon', views.ToolView.EditIcon.as_view()),
1617
path('workspace/<str:workspace_id>/tool/<str:tool_id>/export', views.ToolView.Export.as_view()),
1718
path('workspace/<str:workspace_id>/tool/<str:tool_id>/add_internal_tool', views.ToolView.AddInternalTool.as_view()),
19+
path('workspace/<str:workspace_id>/tool/<str:tool_id>/add_store_tool', views.ToolView.AddStoreTool.as_view()),
20+
path('workspace/<str:workspace_id>/tool/<str:tool_id>/update_store_tool', views.ToolView.UpdateStoreTool.as_view()),
1821
path('workspace/<str:workspace_id>/tool/<int:current_page>/<int:page_size>', views.ToolView.Page.as_view()),
1922
]

apps/tools/views/tool.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,3 +407,84 @@ def post(self, request: Request, tool_id: str, workspace_id: str):
407407
'user_id': request.user.id,
408408
'workspace_id': workspace_id
409409
}).add(request.data))
410+
411+
class StoreTool(APIView):
412+
authentication_classes = [TokenAuth]
413+
414+
@extend_schema(
415+
methods=['GET'],
416+
description=_("Get Appstore tools"),
417+
summary=_("Get Appstore tools"),
418+
operation_id=_("Get Appstore tools"), # type: ignore
419+
responses=GetInternalToolAPI.get_response(),
420+
tags=[_("Tool")] # type: ignore
421+
)
422+
def get(self, request: Request):
423+
return result.success(ToolSerializer.StoreTool(data={
424+
'user_id': request.user.id,
425+
'name': request.query_params.get('name', ''),
426+
}).get_appstore_tools())
427+
428+
class AddStoreTool(APIView):
429+
authentication_classes = [TokenAuth]
430+
431+
@extend_schema(
432+
methods=['POST'],
433+
description=_("Add Appstore tool"),
434+
summary=_("Add Appstore tool"),
435+
operation_id=_("Add Appstore tool"), # type: ignore
436+
parameters=AddInternalToolAPI.get_parameters(),
437+
request=AddInternalToolAPI.get_request(),
438+
responses=AddInternalToolAPI.get_response(),
439+
tags=[_("Tool")] # type: ignore
440+
)
441+
@has_permissions(
442+
PermissionConstants.TOOL_CREATE.get_workspace_permission(),
443+
PermissionConstants.TOOL_CREATE.get_workspace_permission_workspace_manage_role(),
444+
RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
445+
RoleConstants.USER.get_workspace_role(),
446+
)
447+
@log(
448+
menu='Tool', operate="Add Appstore tool",
449+
get_operation_object=lambda r, k: get_tool_operation_object(k.get('tool_id')),
450+
)
451+
def post(self, request: Request, tool_id: str, workspace_id: str):
452+
return result.success(ToolSerializer.AddStoreTool(data={
453+
'tool_id': tool_id,
454+
'user_id': request.user.id,
455+
'workspace_id': workspace_id,
456+
}).add(request.data))
457+
458+
class UpdateStoreTool(APIView):
459+
authentication_classes = [TokenAuth]
460+
461+
@extend_schema(
462+
methods=['POST'],
463+
description=_("Update Appstore tool"),
464+
summary=_("Update Appstore tool"),
465+
operation_id=_("Update Appstore tool"), # type: ignore
466+
parameters=AddInternalToolAPI.get_parameters(),
467+
request=AddInternalToolAPI.get_request(),
468+
responses=AddInternalToolAPI.get_response(),
469+
tags=[_("Tool")] # type: ignore
470+
)
471+
@has_permissions(
472+
PermissionConstants.TOOL_CREATE.get_workspace_permission(),
473+
PermissionConstants.TOOL_CREATE.get_workspace_permission_workspace_manage_role(),
474+
RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
475+
RoleConstants.USER.get_workspace_role(),
476+
)
477+
@log(
478+
menu='Tool', operate="Update Appstore tool",
479+
get_operation_object=lambda r, k: get_tool_operation_object(k.get('tool_id')),
480+
)
481+
def post(self, request: Request, tool_id: str, workspace_id: str):
482+
return result.success(ToolSerializer.UpdateStoreTool(data={
483+
'tool_id': tool_id,
484+
'user_id': request.user.id,
485+
'workspace_id': workspace_id,
486+
'download_url': request.data.get('download_url'),
487+
'download_callback_url': request.data.get('download_callback_url'),
488+
'icon': request.data.get('icon'),
489+
'versions': request.data.get('versions'),
490+
}).update_tool(request.data))

ui/src/api/system-shared/tool.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,24 @@ const addInternalTool: (
142142
return post(`${prefix}/${tool_id}/add_internal_tool`, param, undefined, loading)
143143
}
144144

145+
/**
146+
* 工具商店
147+
*/
148+
const addStoreTool: (
149+
tool_id: string,
150+
param: AddInternalToolParam,
151+
loading?: Ref<boolean>,
152+
) => Promise<Result<any>> = (tool_id, param, loading) => {
153+
return post(`${prefix}/${tool_id}/add_store_tool`, param, undefined, loading)
154+
}
155+
156+
const updateStoreTool: (
157+
tool_id: string,
158+
param: AddInternalToolParam,
159+
loading?: Ref<boolean>,
160+
) => Promise<Result<any>> = (tool_id, param, loading) => {
161+
return post(`${prefix}/${tool_id}/update_store_tool`, param, undefined, loading)
162+
}
145163

146164
export default {
147165
getToolList,
@@ -156,5 +174,7 @@ export default {
156174
exportTool,
157175
putToolIcon,
158176
delTool,
159-
addInternalTool
177+
addInternalTool,
178+
addStoreTool,
179+
updateStoreTool
160180
}

0 commit comments

Comments
 (0)