From 85fd1395ef3db1d8ae190c0bc3b9aa45ade5c901 Mon Sep 17 00:00:00 2001 From: v_xugzhou <941071842@qq.com> Date: Thu, 13 Mar 2025 17:00:09 +0800 Subject: [PATCH 01/61] =?UTF-8?q?fix:=20=E5=86=B3=E7=AD=96=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E5=85=83=E6=A0=BC=E5=80=BC=E4=B8=BA0=E6=97=B6?= =?UTF-8?q?=E6=9C=AA=E5=B1=95=E7=A4=BA=E9=97=AE=E9=A2=98=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20--ignore=20#=20Reviewed,=20transaction=20id:=2033937?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DecisionTable/components/TableCell/index.vue | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/DecisionTable/components/TableCell/index.vue b/frontend/src/components/DecisionTable/components/TableCell/index.vue index 0851c680..02a043f3 100644 --- a/frontend/src/components/DecisionTable/components/TableCell/index.vue +++ b/frontend/src/components/DecisionTable/components/TableCell/index.vue @@ -24,9 +24,9 @@
- {{ cellText || cell.column.tips || $t('请输入') }} + {{ viewText }}
Date: Wed, 19 Mar 2025 14:16:45 +0800 Subject: [PATCH 02/61] =?UTF-8?q?feat:=20=E5=87=AD=E8=AF=81=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=BC=98=E5=8C=96=20#130=20(#131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 凭证功能优化 #130 * feat: 凭证功能优化 #130 * feat: 凭证功能优化 #130 * feat: 凭证功能优化 #130 * feat: 凭证功能优化 #130 --- .../commands/data/api-resources.yml | 24 ++++ bkflow/apigw/serializers/credential.py | 14 +++ .../collections/uniform_api/v2_0_0.py | 31 ++++- bkflow/space/configs.py | 33 ++++- bkflow/space/serializers.py | 4 + bkflow/space/urls.py | 2 + bkflow/space/views.py | 118 ++++++++++++++++-- env.py | 3 + module_settings.py | 1 + 9 files changed, 211 insertions(+), 19 deletions(-) diff --git a/bkflow/apigw/management/commands/data/api-resources.yml b/bkflow/apigw/management/commands/data/api-resources.yml index 630a81b9..10ae1c39 100644 --- a/bkflow/apigw/management/commands/data/api-resources.yml +++ b/bkflow/apigw/management/commands/data/api-resources.yml @@ -559,6 +559,30 @@ paths: userVerifiedRequired: false disabledStages: [ ] descriptionEn: delete a template + /space/{space_id}/create_credential/: + post: + operationId: create_credential + description: 创建凭证 + tags: [ ] + responses: + default: + description: '' + x-bk-apigateway-resource: + isPublic: true + allowApplyPermission: true + matchSubpath: false + backend: + type: HTTP + method: post + path: /{env.api_sub_path}apigw/space/{space_id}/create_credential/ + matchSubpath: false + timeout: 0 + upstreams: { } + transformHeaders: { } + authConfig: + userVerifiedRequired: false + disabledStages: [ ] + descriptionEn: create a credential /grant_apigw_permissions_to_app: post: operationId: grant_apigw_permissions_to_app diff --git a/bkflow/apigw/serializers/credential.py b/bkflow/apigw/serializers/credential.py index aa86fbc8..5b22e058 100644 --- a/bkflow/apigw/serializers/credential.py +++ b/bkflow/apigw/serializers/credential.py @@ -20,9 +20,23 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers +from bkflow.space.credential import BkAppCredential + class CreateCredentialSerializer(serializers.Serializer): name = serializers.CharField(help_text=_("凭证名称"), max_length=32, required=True) desc = serializers.CharField(help_text=_("凭证描述"), max_length=32, required=False) type = serializers.CharField(help_text=_("凭证类型"), max_length=32, required=True) content = serializers.JSONField(help_text=_("凭证内容"), required=True) + + +class UpdateCredentialSerializer(serializers.Serializer): + name = serializers.CharField(help_text=_("凭证名称"), max_length=32, required=False) + desc = serializers.CharField(help_text=_("凭证描述"), max_length=32, required=False) + type = serializers.CharField(help_text=_("凭证类型"), max_length=32, required=False) + content = serializers.JSONField(help_text=_("凭证内容"), required=False) + + def validate_content(self, value): + content_ser = BkAppCredential.BkAppSerializer(data=value) + content_ser.is_valid(raise_exception=True) + return value diff --git a/bkflow/pipeline_plugins/components/collections/uniform_api/v2_0_0.py b/bkflow/pipeline_plugins/components/collections/uniform_api/v2_0_0.py index a7d4159f..95a27a63 100644 --- a/bkflow/pipeline_plugins/components/collections/uniform_api/v2_0_0.py +++ b/bkflow/pipeline_plugins/components/collections/uniform_api/v2_0_0.py @@ -125,9 +125,13 @@ def _dispatch_schedule_trigger(self, data, parent_data, callback_data=None): resp_data_path: str = api_data.pop("response_data_path", None) # 获取空间相关配置信息 interface_client = InterfaceModuleClient() - space_infos_result = interface_client.get_space_infos( - {"space_id": space_id, "config_names": "uniform_api,credential"} + scope_type, scope_id = parent_data.get_one_of_inputs("task_scope_type"), parent_data.get_one_of_inputs( + "task_scope_value" ) + space_infos_params = {"space_id": space_id, "config_names": "uniform_api,credential"} + if scope_type and scope_id: + space_infos_params["scope"] = f"{scope_type}_{scope_id}" + space_infos_result = interface_client.get_space_infos(space_infos_params) if not space_infos_result["result"]: message = handle_plain_log( "[uniform_api error] get apigw credential failed: {}".format(space_infos_result["message"]) @@ -155,9 +159,13 @@ def _dispatch_schedule_trigger(self, data, parent_data, callback_data=None): credential_data = space_configs.get("credential") if credential_data: app_code, app_secret = credential_data["bk_app_code"], credential_data["bk_app_secret"] - else: + elif settings.USE_BKFLOW_CREDENTIAL: app_code, app_secret = settings.APP_CODE, settings.SECRET_KEY - + else: + message = "不存在调用凭证" + self.logger.error(message) + data.outputs.ex_data = message + return False client = UniformAPIClient() headers = client.gen_default_apigw_header(app_code=app_code, app_secret=app_secret, username=operator) try: @@ -245,7 +253,13 @@ def _dispatch_schedule_polling(self, data, parent_data, callback_data=None): # 获取空间相关配置信息 interface_client = InterfaceModuleClient() - space_infos_result = interface_client.get_space_infos({"space_id": space_id, "config_names": "credential"}) + scope_type, scope_id = parent_data.get_one_of_inputs("task_scope_type"), parent_data.get_one_of_inputs( + "task_scope_value" + ) + space_infos_params = {"space_id": space_id, "config_names": "credential"} + if scope_type and scope_id: + space_infos_params["scope"] = f"{scope_type}_{scope_id}" + space_infos_result = interface_client.get_space_infos(space_infos_params) if not space_infos_result["result"]: message = handle_plain_log( "[uniform_api error] get apigw credential failed: {}".format(space_infos_result["message"]) @@ -258,8 +272,13 @@ def _dispatch_schedule_polling(self, data, parent_data, callback_data=None): credential_data = space_configs.get("credential") if credential_data: app_code, app_secret = credential_data["bk_app_code"], credential_data["bk_app_secret"] - else: + elif settings.USE_BKFLOW_CREDENTIAL: app_code, app_secret = settings.APP_CODE, settings.SECRET_KEY + else: + message = "不存在调用凭证" + self.logger.error(message) + data.outputs.ex_data = message + return False client = UniformAPIClient() headers = client.gen_default_apigw_header(app_code=app_code, app_secret=app_secret, username=operator) diff --git a/bkflow/space/configs.py b/bkflow/space/configs.py index e0c4f6ea..6f98c8e6 100644 --- a/bkflow/space/configs.py +++ b/bkflow/space/configs.py @@ -68,6 +68,7 @@ class BaseSpaceConfig(metaclass=SpaceConfigMeta): default_value = None # 默认值 choices = None # 配置值可选项列表,适用于 TEXT 类型 example = None # 配置值示例 + is_mix_type = False @classmethod def to_dict(cls): @@ -79,6 +80,7 @@ def to_dict(cls): "default_value": cls.default_value, "choices": cls.choices, "example": cls.example, + "is_mix_type": cls.is_mix_type, } @classmethod @@ -300,7 +302,36 @@ def validate(cls, value: str): class ApiGatewayCredentialConfig(BaseSpaceConfig): name = "api_gateway_credential_name" - desc = _("API_GATEWAY使用的凭证名称") + desc = _("API_GATEWAY使用的凭证配置") + example = {"default": "{default_credential_name}", "{scope_type}_{scope_id}": "{credential_name}"} + value_type = SpaceConfigValueType.TEXT.value + is_mix_type = True + + SCHEMA = { + "type": "object", + "patternProperties": { + "^[^{]+_[^{]+$": {"type": "string"}, + }, + "additionalProperties": False, + } + + @classmethod + def validate(cls, value): + if isinstance(value, str): + return True + if isinstance(value, dict): + try: + jsonschema.validate(value, cls.SCHEMA) + except jsonschema.ValidationError as e: + raise ValidationError(f"[validate api_gateway_credential error]: {str(e)}") + else: + raise ValidationError( + ( + "[validate api_gateway_credential error]: " + "api_gateway_credential only support string or list of json: " + f"{cls.example}" + ) + ) class SpacePluginConfig(BaseSpaceConfig): diff --git a/bkflow/space/serializers.py b/bkflow/space/serializers.py index 4c2ac9aa..f2422347 100644 --- a/bkflow/space/serializers.py +++ b/bkflow/space/serializers.py @@ -60,6 +60,10 @@ class SpaceConfigBaseQuerySerializer(serializers.Serializer): space_id = serializers.IntegerField(help_text=_("空间ID")) +class CredentialBaseQuerySerializer(serializers.Serializer): + space_id = serializers.IntegerField(help_text=_("空间ID")) + + class SpaceConfigBatchApplySerializer(serializers.Serializer): space_id = serializers.IntegerField(help_text=_("空间ID")) configs = serializers.DictField(help_text=_("空间配置")) diff --git a/bkflow/space/urls.py b/bkflow/space/urls.py index e23c7963..296408cd 100644 --- a/bkflow/space/urls.py +++ b/bkflow/space/urls.py @@ -22,6 +22,7 @@ from rest_framework.routers import DefaultRouter from bkflow.space.views import ( + CredentialConfigAdminViewSet, CredentialViewSet, SpaceConfigAdminViewSet, SpaceInternalViewSet, @@ -35,6 +36,7 @@ admin_router = DefaultRouter() admin_router.register(r"space_config", SpaceConfigAdminViewSet, basename="space_config") +admin_router.register(r"credential_config", CredentialConfigAdminViewSet, basename="credential_config") urlpatterns = [ url(r"^", include(router.urls)), diff --git a/bkflow/space/views.py b/bkflow/space/views.py index 7a1c913c..f2994c77 100644 --- a/bkflow/space/views.py +++ b/bkflow/space/views.py @@ -22,6 +22,7 @@ import django_filters from blueapps.account.decorators import login_exempt from django.conf import settings +from django.db import DatabaseError from django.db.models import Q from django.utils.decorators import method_decorator from django_filters.rest_framework import DjangoFilterBackend, FilterSet @@ -34,6 +35,10 @@ from webhook.base_models import Scope from webhook.signals import event_broadcast_signal +from bkflow.apigw.serializers.credential import ( + CreateCredentialSerializer, + UpdateCredentialSerializer, +) from bkflow.apigw.serializers.space import CreateSpaceSerializer from bkflow.constants import WebhookScopeType from bkflow.exceptions import APIRequestError @@ -48,6 +53,7 @@ ) from bkflow.space.permissions import SpaceExemptionPermission, SpaceSuperuserPermission from bkflow.space.serializers import ( + CredentialBaseQuerySerializer, CredentialSerializer, SpaceConfigBaseQuerySerializer, SpaceConfigBatchApplySerializer, @@ -182,27 +188,29 @@ def broadcast_task_events(self, request, *args, **kwargs): event_broadcast_signal.send(sender=data["event"], scopes=scopes, extra_info=data.get("extra_info")) return Response("success") + def get_credential_config(self, config, space_id, scope="default"): + try: + if isinstance(config, dict) and config.get(scope): + # 如果是分 scope 配置则多一层提取 + config = config.get(scope) + value = Credential.objects.get(space_id=space_id, name=config, type=CredentialType.BK_APP.value).value + except (Credential.DoesNotExist, SpaceConfigDefaultValueNotExists) as e: + logger.exception("CredentialViewSet 获取空间下的凭证异常, space_id={}, err={}, ".format(space_id, e)) + value = {} + return value + @action(detail=False, methods=["GET"]) def get_space_infos(self, request, *args, **kwargs): data = request.query_params configs = {} for config_name in data.get("config_names", "").split(","): if config_name == "credential": - try: - api_gateway_credential_name = SpaceConfig.get_config( - data["space_id"], ApiGatewayCredentialConfig.name - ) - value = Credential.objects.get( - space_id=data["space_id"], name=api_gateway_credential_name, type=CredentialType.BK_APP.value - ).value - except (Credential.DoesNotExist, SpaceConfigDefaultValueNotExists) as e: - logger.exception("CredentialViewSet 获取空间下的凭证异常, space_id={}, err={}, ".format(data["space_id"], e)) - value = {} + value = SpaceConfig.get_config(data["space_id"], ApiGatewayCredentialConfig.name) + scope = data.get("scope", "default") + value = self.get_credential_config(config=value, space_id=data["space_id"], scope=scope) else: value = SpaceConfig.get_config(space_id=data["space_id"], config_name=config_name) - configs[config_name] = value - infos = { "configs": configs, } @@ -259,3 +267,89 @@ def get_all_space_configs(self, request, *args, **kwargs): return Response( SpaceConfig.objects.get_space_config_info(space_id=ser.validated_data["space_id"], simplified=False) ) + + +class CredentialConfigAdminViewSet(ModelViewSet, SimpleGenericViewSet): + """ + 凭证接口 + """ + + queryset = Credential.objects.all() + serializer_class = CredentialSerializer + permission_classes = [AdminPermission | SpaceSuperuserPermission] + pagination_class = BKFLOWDefaultPagination + + def get_object(self): + serializer = CredentialBaseQuerySerializer(data=self.request.query_params) + serializer.is_valid(raise_exception=True) + space_id = serializer.validated_data.get("space_id") + + pk = self.kwargs.get(self.lookup_field) + obj = self.queryset.get(pk=pk, space_id=space_id) + return obj + + def get_queryset(self): + queryset = super().get_queryset() + serializer = CredentialBaseQuerySerializer(data=self.request.query_params) + serializer.is_valid(raise_exception=True) + space_id = serializer.validated_data.get("space_id") + queryset = queryset.filter(space_id=space_id, is_deleted=False) + return queryset + + def create(self, request, *args, **kwargs): + credential_serializer = CreateCredentialSerializer(data=request.data) + credential_serializer.is_valid(raise_exception=True) + credential_data = credential_serializer.validated_data + + serializer = CredentialBaseQuerySerializer(data=self.request.query_params) + serializer.is_valid(raise_exception=True) + space_id = serializer.validated_data.get("space_id") + try: + credential = Credential.create_credential( + space_id=space_id, + name=credential_data["name"], + type=credential_data["type"], + content=credential_data["content"], + creator=request.user.username, + desc=credential_data.get("desc"), + ) + except DatabaseError as e: + errMsg = f"创建凭证失败 {str(e)}" + logger.error(errMsg) + return Response(errMsg, status=500) + response_serializer = CredentialSerializer(credential) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + + def partial_update(self, request, *args, **kwargs): + try: + instance = self.get_object() + except Credential.DoesNotExist as e: + errMsg = f"更新凭证不存在 {str(e)}" + logger.error(errMsg) + return Response(errMsg, status=404) + + serializer = UpdateCredentialSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + for attr, value in serializer.validated_data.items(): + setattr(instance, attr, value) + + try: + instance.save(update_fields=serializer.validated_data.keys()) + except DatabaseError as e: + errMsg = f"更新凭证失败 {str(e)}" + logger.error(errMsg) + return Response(errMsg, status=500) + # 序列化更新后的对象 + response_serializer = CredentialSerializer(instance) + return Response(response_serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request, *args, **kwargs): + try: + instance = self.get_object() + instance.hard_delete() + except Credential.DoesNotExist as e: + errMsg = f"删除凭证不存在 {str(e)}" + logger.error(errMsg) + return Response(errMsg, status=404) + return Response() diff --git a/env.py b/env.py index f766a8f2..8fdf18ce 100644 --- a/env.py +++ b/env.py @@ -140,3 +140,6 @@ # 系统空间插件列表 SPACE_PLUGIN_LIST_STR = os.getenv("SPACE_PLUGIN_LIST_STR", "") # 逗号分隔的字符串 + +# 是否支持API插件使用 BKFLOW 凭证 +USE_BKFLOW_CREDENTIAL = os.getenv("USE_BKFLOW_CREDENTIAL", False) # 默认关闭使用 diff --git a/module_settings.py b/module_settings.py index 0f3c0103..60491d3f 100644 --- a/module_settings.py +++ b/module_settings.py @@ -186,6 +186,7 @@ def check_engine_admin_permission(request, *args, **kwargs): NODE_LOG_DATA_SOURCE_CONFIG = env.NODE_LOG_DATA_SOURCE_CONFIG PAASV3_APIGW_API_TOKEN = env.PAASV3_APIGW_API_TOKEN LOG_PERSISTENT_DAYS = env.LOG_PERSISTENT_DAYS + USE_BKFLOW_CREDENTIAL = env.USE_BKFLOW_CREDENTIAL elif env.BKFLOW_MODULE_TYPE == BKFLOWModuleType.interface.value: From 71ee2ebbbf6ed2654a5616e61e7f4cc31684f468 Mon Sep 17 00:00:00 2001 From: v_xugzhou <941071842@qq.com> Date: Thu, 20 Mar 2025 10:01:04 +0800 Subject: [PATCH 03/61] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=87=AD?= =?UTF-8?q?=E8=AF=81=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=20--story=3D12241?= =?UTF-8?q?3461=20#=20Reviewed,=20transaction=20id:=2035072?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/assets/fonts/bksops-icon.eot | Bin 50112 -> 50416 bytes frontend/src/assets/fonts/bksops-icon.svg | 6 + frontend/src/assets/fonts/bksops-icon.ttf | Bin 49944 -> 50248 bytes frontend/src/assets/fonts/bksops-icon.woff | Bin 29372 -> 29560 bytes .../src/components/layout/NavigationMenu.vue | 6 + frontend/src/config/i18n/cn.js | 5 + frontend/src/config/i18n/en.js | 5 + frontend/src/scss/iconfont.scss | 6 + frontend/src/store/index.js | 2 + .../src/store/modules/credentialConfig.js | 25 ++ .../Space/Credential/CredentialDialog.vue | 163 +++++++++++++ .../views/admin/Space/Credential/index.vue | 222 ++++++++++++++++++ frontend/src/views/admin/Space/index.vue | 3 + 13 files changed, 443 insertions(+) create mode 100644 frontend/src/store/modules/credentialConfig.js create mode 100644 frontend/src/views/admin/Space/Credential/CredentialDialog.vue create mode 100644 frontend/src/views/admin/Space/Credential/index.vue diff --git a/frontend/src/assets/fonts/bksops-icon.eot b/frontend/src/assets/fonts/bksops-icon.eot index 9139444a8f7887d59ac9b527d70cd0b1d48dda2d..ab913e369aaef728fc5579982ba5670081ff199f 100644 GIT binary patch delta 656 zcmXw0QAkr^6#mY=^Uirw+3q&mO4;rJ249n8QYnJ*EM zS#o>Og474`MJl*Hh%GRZz@V4#A&AUM2%VHK^=9^O*~7sGe~dU~s{}{m?pv3(PPnOmoIdTARsotO0zQ7ElUy zM>IfNsLg&w4b*yQjXVKa4tU;jrXymgt3xFE zRBo}9e<>lcqbnq?7ZR#J=Jl#xZ);sJSZ6tIIVARBz(JB<$#T0e!+~nJ;bW?sZ8ir~ zzgw|anf3P{ypmdGgZLm!}B5T zo#b>%``;JOL_T**4u{lDE79nqNMtG+Exg!*jpA|j%B3e9<@Kk_@5w-U+SNyMtwx*i Q+Q8MY5@*b9)8`)(Qp& zevkB=%Cv7^jtem`h)rQ&Sa2dEH8Dl&fPDi{?E@e-%K!>+xw1V3viAV7N=9x;#d6{Q z^MU4FVPIfhl9QjDsFdfvj)6hs07xu1v7&(SKf^a5TLP$FAulmEbpnS}1~&u4_80~R zrq+V|;u8BmeN7AuJD)Hxa2ifdVN_Oq$-oFy48*R7XY$7L+k9o + + + + + + diff --git a/frontend/src/assets/fonts/bksops-icon.ttf b/frontend/src/assets/fonts/bksops-icon.ttf index c02f44462ea23d257596c591ca190856129a3884..dac4b89fa7c31c8db615dcf788fe1e581cfabecc 100644 GIT binary patch delta 651 zcmXw0T}V@57=GS!w&UhW#hvZcR*E~@jLbyOek9GBuoc7xUL|-F!=Ej1I+($NV%-#7 z*wFMs3sNDe3!#e>B>uplE;8sQybvNY6T&9dP2H{aon;?9Jn#Fw&-;EK?>BRoe{+wY zaAoHJx(VP)e=?R@Uwm~6Fy01g2I9jvhH{IKE(7u!u&|aGj18Rmkc%+y4nrWpgvzk+ zg~hB6cOp5Kc`lXod7A*gG(6fLOIOc~1J*sJZ6#xw6i4{Y;$zI;FcM1+PM91C5m?Ru z+?~{DdTg@gwE(QLx;Z?#=DJV-v6x}ysprhju*G+=+yqWn z+0pw7Q}wh!-CCZO`3Ib7Dr1!D8p;*$4)EounQn`G#j@7dO-<&F7l`5AnQme`ZnO6feQvQb zs5dBX>IpR;ce?{_cW0B|-()#qIVARXKxvrIXSoB^!j3vP;bE$i-CXMnc%7Y)-$i46mQ>D2_)v$RTlQ$xa$Yh8&Id;(dz$nbol~eKE+rlN=7I`1Qrv z$frKZZkPHfAB{eYL}sH=wh~ydUEZn|fBBfBf+noICj%AeK-*gFtguyFAGsD*eXWPw HrnlfPPvxAH delta 346 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 delta 9669 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 response.data); + }, + createCredential({}, params) { + return axios.post(`api/space/admin/credential_config/?space_id=${params.space_id}`, params).then(response => response.data); + }, + updateCredential({}, params) { + return axios.patch(`api/space/admin/credential_config/${params.id}/?space_id=${params.space_id}`, params).then(response => response.data); + }, + deleteCredential({}, params) { + return axios.delete(`api/space/admin/credential_config/${params.id}/?space_id=${params.space_id}`, params).then(response => response.data); + }, + }, +}; + diff --git a/frontend/src/views/admin/Space/Credential/CredentialDialog.vue b/frontend/src/views/admin/Space/Credential/CredentialDialog.vue new file mode 100644 index 00000000..6a8dd03f --- /dev/null +++ b/frontend/src/views/admin/Space/Credential/CredentialDialog.vue @@ -0,0 +1,163 @@ + + diff --git a/frontend/src/views/admin/Space/Credential/index.vue b/frontend/src/views/admin/Space/Credential/index.vue new file mode 100644 index 00000000..c2f869e5 --- /dev/null +++ b/frontend/src/views/admin/Space/Credential/index.vue @@ -0,0 +1,222 @@ + + + diff --git a/frontend/src/views/admin/Space/index.vue b/frontend/src/views/admin/Space/index.vue index 47fadea4..1e7194eb 100644 --- a/frontend/src/views/admin/Space/index.vue +++ b/frontend/src/views/admin/Space/index.vue @@ -13,6 +13,7 @@ import TaskList from './Task/index.vue'; import SpaceConfigList from './SpaceConfig/index.vue'; import DecisionTable from './DecisionTable/index.vue'; + import CredentialList from './Credential/index.vue'; import { mapState } from 'vuex'; export default { @@ -22,6 +23,7 @@ TaskList, SpaceConfigList, DecisionTable, + CredentialList, }, data() { const { activeTab = 'template' } = this.$route.query; @@ -38,6 +40,7 @@ let component = tab === 'config' ? 'SpaceConfigList' : 'TaskList'; component = tab === 'decisionTable' ? 'DecisionTable' : component; component = tab === 'template' ? 'TemplateList' : component; + component = tab === 'credential' ? 'CredentialList' : component; return component; }, From cc2ee60570540e448afae6c91afd6c2606aee2a6 Mon Sep 17 00:00:00 2001 From: ZC-A <1483681501@qq.com> Date: Thu, 20 Mar 2025 14:45:06 +0800 Subject: [PATCH 04/61] =?UTF-8?q?feat:=20=E5=87=AD=E8=AF=81=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=BC=98=E5=8C=96=20#130?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bkflow/space/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bkflow/space/serializers.py b/bkflow/space/serializers.py index f2422347..c623588c 100644 --- a/bkflow/space/serializers.py +++ b/bkflow/space/serializers.py @@ -81,6 +81,7 @@ def to_representation(self, instance): data = super(CredentialSerializer, self).to_representation(instance) credential = CredentialDispatcher(credential_type=instance.type, data=instance.content) if credential: + data["content"] = credential.display_value() data["data"] = credential.display_value() else: data["data"] = {} From 25cfa3f19ac1033d825936c6cd1e11c9ab0dd3dc Mon Sep 17 00:00:00 2001 From: v_xugzhou <941071842@qq.com> Date: Thu, 20 Mar 2025 14:42:42 +0800 Subject: [PATCH 05/61] =?UTF-8?q?feat:=20=E7=A9=BA=E9=97=B4=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E7=BC=96=E8=BE=91=E6=94=AF=E6=8C=81=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=80=BC=E7=B1=BB=E5=9E=8B=20--ignore=20#=20Reviewed,=20transa?= =?UTF-8?q?ction=20id:=2035134?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/admin/Space/SpaceConfig/index.vue | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/frontend/src/views/admin/Space/SpaceConfig/index.vue b/frontend/src/views/admin/Space/SpaceConfig/index.vue index 13ef6300..de6af303 100644 --- a/frontend/src/views/admin/Space/SpaceConfig/index.vue +++ b/frontend/src/views/admin/Space/SpaceConfig/index.vue @@ -60,13 +60,27 @@ {{ selectedRow.desc }} + + + + + + + :class="{ 'code-form-item': isJsonValueType }">
{ if (!validator) return; // 检查值数据类型 - const { formType, formValue } = this.configFormData; - if (formType === 'json' && !tools.checkIsJSON(formValue)) { + const { formValue } = this.configFormData; + if (this.isJsonValueType && !tools.checkIsJSON(formValue)) { this.$bkMessage({ message: this.$t('数据格式不正确,应为JSON格式'), theme: 'error', @@ -290,14 +309,14 @@ try { this.editLoading = true; - const { id, name, value_type: valueType } = this.selectedRow; + const { id, name, value_type: valueType, is_mix_type: isMixType } = this.selectedRow; const data = { id, name, space_id: this.spaceId, - value_type: valueType, + value_type: isMixType ? this.localValueType : valueType, }; - if (formType === 'json') { + if (this.isJsonValueType) { data.json_value = JSON.parse(formValue); } else { data.text_value = formValue; From 485a4688ec6ccadc7dc4567ce1bd79b62ad3cb80 Mon Sep 17 00:00:00 2001 From: ZC-A <1483681501@qq.com> Date: Thu, 20 Mar 2025 15:48:53 +0800 Subject: [PATCH 06/61] =?UTF-8?q?feat:=20=E5=87=AD=E8=AF=81=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=BC=98=E5=8C=96=20#130?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bkflow/space/configs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bkflow/space/configs.py b/bkflow/space/configs.py index 6f98c8e6..820ee3a7 100644 --- a/bkflow/space/configs.py +++ b/bkflow/space/configs.py @@ -313,6 +313,8 @@ class ApiGatewayCredentialConfig(BaseSpaceConfig): "^[^{]+_[^{]+$": {"type": "string"}, }, "additionalProperties": False, + "required": ["default"], # 必须存在 default 配置 + "properties": {"default": {"type": "string"}}, } @classmethod From 44a237c12c49e3318d44c8f0f5b21311333933c9 Mon Sep 17 00:00:00 2001 From: ZC-A <1483681501@qq.com> Date: Thu, 20 Mar 2025 16:25:02 +0800 Subject: [PATCH 07/61] =?UTF-8?q?feat:=20=E5=87=AD=E8=AF=81=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=BC=98=E5=8C=96=20#130?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bkflow/space/serializers.py | 3 +++ bkflow/space/views.py | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/bkflow/space/serializers.py b/bkflow/space/serializers.py index c623588c..1f207bae 100644 --- a/bkflow/space/serializers.py +++ b/bkflow/space/serializers.py @@ -77,6 +77,9 @@ def validate_configs(self, configs): class CredentialSerializer(serializers.ModelSerializer): + create_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S") + update_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S") + def to_representation(self, instance): data = super(CredentialSerializer, self).to_representation(instance) credential = CredentialDispatcher(credential_type=instance.type, data=instance.content) diff --git a/bkflow/space/views.py b/bkflow/space/views.py index f2994c77..8e543c37 100644 --- a/bkflow/space/views.py +++ b/bkflow/space/views.py @@ -24,6 +24,7 @@ from django.conf import settings from django.db import DatabaseError from django.db.models import Q +from django.utils import timezone from django.utils.decorators import method_decorator from django_filters.rest_framework import DjangoFilterBackend, FilterSet from drf_yasg.utils import swagger_auto_schema @@ -334,8 +335,11 @@ def partial_update(self, request, *args, **kwargs): for attr, value in serializer.validated_data.items(): setattr(instance, attr, value) + instance.updated_by = request.user.username + instance.update_at = timezone.now() + updated_keys = list(serializer.validated_data.keys()) + ["updated_by", "update_at"] try: - instance.save(update_fields=serializer.validated_data.keys()) + instance.save(update_fields=updated_keys) except DatabaseError as e: errMsg = f"更新凭证失败 {str(e)}" logger.error(errMsg) From b763cdfc710aeec17f3f402b8f56ad77eb53f7fd Mon Sep 17 00:00:00 2001 From: ZC-A <1483681501@qq.com> Date: Thu, 20 Mar 2025 16:31:13 +0800 Subject: [PATCH 08/61] =?UTF-8?q?feat:=20=E5=87=AD=E8=AF=81=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=BC=98=E5=8C=96=20#130?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bkflow/space/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bkflow/space/views.py b/bkflow/space/views.py index 8e543c37..3574a319 100644 --- a/bkflow/space/views.py +++ b/bkflow/space/views.py @@ -24,7 +24,6 @@ from django.conf import settings from django.db import DatabaseError from django.db.models import Q -from django.utils import timezone from django.utils.decorators import method_decorator from django_filters.rest_framework import DjangoFilterBackend, FilterSet from drf_yasg.utils import swagger_auto_schema @@ -336,7 +335,6 @@ def partial_update(self, request, *args, **kwargs): setattr(instance, attr, value) instance.updated_by = request.user.username - instance.update_at = timezone.now() updated_keys = list(serializer.validated_data.keys()) + ["updated_by", "update_at"] try: instance.save(update_fields=updated_keys) From 6c2eda0b1dd17c4ab88b97513b0232fe07122ee0 Mon Sep 17 00:00:00 2001 From: ZC-A <57583928+ZC-A@users.noreply.github.com> Date: Fri, 21 Mar 2025 11:09:16 +0800 Subject: [PATCH 09/61] =?UTF-8?q?feat:=20=E5=87=AD=E8=AF=81=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=BC=98=E5=8C=96=20#130=20(#137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 凭证功能优化 #130 * feat: 凭证功能优化 #130 * feat: 凭证功能优化 #130 --- .../components/collections/uniform_api/v2_0_0.py | 6 ++++++ bkflow/space/views.py | 14 ++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/bkflow/pipeline_plugins/components/collections/uniform_api/v2_0_0.py b/bkflow/pipeline_plugins/components/collections/uniform_api/v2_0_0.py index 95a27a63..c12e5a41 100644 --- a/bkflow/pipeline_plugins/components/collections/uniform_api/v2_0_0.py +++ b/bkflow/pipeline_plugins/components/collections/uniform_api/v2_0_0.py @@ -131,6 +131,7 @@ def _dispatch_schedule_trigger(self, data, parent_data, callback_data=None): space_infos_params = {"space_id": space_id, "config_names": "uniform_api,credential"} if scope_type and scope_id: space_infos_params["scope"] = f"{scope_type}_{scope_id}" + self.logger.info(f"get_space_info params: {space_infos_params}") space_infos_result = interface_client.get_space_infos(space_infos_params) if not space_infos_result["result"]: message = handle_plain_log( @@ -159,7 +160,9 @@ def _dispatch_schedule_trigger(self, data, parent_data, callback_data=None): credential_data = space_configs.get("credential") if credential_data: app_code, app_secret = credential_data["bk_app_code"], credential_data["bk_app_secret"] + self.logger.info(f"using credential config app_code: {app_code}") elif settings.USE_BKFLOW_CREDENTIAL: + self.logger.info("using bkflow credential") app_code, app_secret = settings.APP_CODE, settings.SECRET_KEY else: message = "不存在调用凭证" @@ -259,6 +262,7 @@ def _dispatch_schedule_polling(self, data, parent_data, callback_data=None): space_infos_params = {"space_id": space_id, "config_names": "credential"} if scope_type and scope_id: space_infos_params["scope"] = f"{scope_type}_{scope_id}" + self.logger.info(f"get_space_info params: {space_infos_params}") space_infos_result = interface_client.get_space_infos(space_infos_params) if not space_infos_result["result"]: message = handle_plain_log( @@ -272,7 +276,9 @@ def _dispatch_schedule_polling(self, data, parent_data, callback_data=None): credential_data = space_configs.get("credential") if credential_data: app_code, app_secret = credential_data["bk_app_code"], credential_data["bk_app_secret"] + self.logger.info(f"using credential config app_code: {app_code}") elif settings.USE_BKFLOW_CREDENTIAL: + self.logger.info("using bkflow credential") app_code, app_secret = settings.APP_CODE, settings.SECRET_KEY else: message = "不存在调用凭证" diff --git a/bkflow/space/views.py b/bkflow/space/views.py index 3574a319..4956b2c4 100644 --- a/bkflow/space/views.py +++ b/bkflow/space/views.py @@ -180,6 +180,7 @@ class SpaceInternalViewSet(AdminModelViewSet): queryset = Space.objects.filter(is_deleted=False) serializer_class = SpaceSerializer permission_classes = [AdminPermission | AppInternalPermission] + CREDENTIAL_CONFIG_KEY = "default" @action(detail=False, methods=["POST"]) def broadcast_task_events(self, request, *args, **kwargs): @@ -188,12 +189,13 @@ def broadcast_task_events(self, request, *args, **kwargs): event_broadcast_signal.send(sender=data["event"], scopes=scopes, extra_info=data.get("extra_info")) return Response("success") - def get_credential_config(self, config, space_id, scope="default"): + def get_credential_config(self, config, space_id, scope=CREDENTIAL_CONFIG_KEY): try: - if isinstance(config, dict) and config.get(scope): - # 如果是分 scope 配置则多一层提取 - config = config.get(scope) - value = Credential.objects.get(space_id=space_id, name=config, type=CredentialType.BK_APP.value).value + if isinstance(config, dict): + credential_name = config.get(scope, config.get(self.CREDENTIAL_CONFIG_KEY)) + value = Credential.objects.get( + space_id=space_id, name=credential_name, type=CredentialType.BK_APP.value + ).value except (Credential.DoesNotExist, SpaceConfigDefaultValueNotExists) as e: logger.exception("CredentialViewSet 获取空间下的凭证异常, space_id={}, err={}, ".format(space_id, e)) value = {} @@ -206,7 +208,7 @@ def get_space_infos(self, request, *args, **kwargs): for config_name in data.get("config_names", "").split(","): if config_name == "credential": value = SpaceConfig.get_config(data["space_id"], ApiGatewayCredentialConfig.name) - scope = data.get("scope", "default") + scope = data.get("scope", self.CREDENTIAL_CONFIG_KEY) value = self.get_credential_config(config=value, space_id=data["space_id"], scope=scope) else: value = SpaceConfig.get_config(space_id=data["space_id"], config_name=config_name) From a6ce8b250b218385163341579491d4fbd4a62956 Mon Sep 17 00:00:00 2001 From: v_xugzhou <941071842@qq.com> Date: Fri, 21 Mar 2025 11:31:40 +0800 Subject: [PATCH 10/61] =?UTF-8?q?feat:=20=E7=A9=BA=E9=97=B4=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=BC=B9=E6=A1=86=E4=BA=A4=E4=BA=92=E4=BC=98=E5=8C=96?= =?UTF-8?q?=20--ignore=20#=20Reviewed,=20transaction=20id:=2035333?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/config/i18n/cn.js | 1 + frontend/src/config/i18n/en.js | 1 + frontend/src/views/admin/Space/SpaceConfig/index.vue | 11 +++++++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/src/config/i18n/cn.js b/frontend/src/config/i18n/cn.js index 874af310..2c487e84 100644 --- a/frontend/src/config/i18n/cn.js +++ b/frontend/src/config/i18n/cn.js @@ -928,6 +928,7 @@ const cn = { 凭证管理: '凭证管理', 内容: '内容', '凭证删除后不可恢复,确认删除?': '凭证删除后不可恢复,确认删除?', + 值类型: '值类型', }; export default cn; diff --git a/frontend/src/config/i18n/en.js b/frontend/src/config/i18n/en.js index c335f237..4472f0ac 100644 --- a/frontend/src/config/i18n/en.js +++ b/frontend/src/config/i18n/en.js @@ -928,6 +928,7 @@ const en = { 凭证管理: 'Credential Management', 内容: 'Content', '凭证删除后不可恢复,确认删除?': 'The voucher cannot be recovered once deleted. Are you sure you want to delete it?', + 值类型: 'Value Type', }; export default en; diff --git a/frontend/src/views/admin/Space/SpaceConfig/index.vue b/frontend/src/views/admin/Space/SpaceConfig/index.vue index de6af303..5e36a2cb 100644 --- a/frontend/src/views/admin/Space/SpaceConfig/index.vue +++ b/frontend/src/views/admin/Space/SpaceConfig/index.vue @@ -65,7 +65,8 @@ :label="$t('值类型')"> + :clearable="false" + @selected="configFormData.formValue = ''"> @@ -101,7 +102,7 @@ + :placeholder="inputPlaceholder" /> Date: Fri, 21 Mar 2025 14:13:15 +0800 Subject: [PATCH 11/61] =?UTF-8?q?feat:=20=E5=87=AD=E8=AF=81=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=BC=98=E5=8C=96=20#130=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 凭证功能优化 #130 * feat: 凭证功能优化 #130 --- bkflow/space/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bkflow/space/views.py b/bkflow/space/views.py index 4956b2c4..97fe620a 100644 --- a/bkflow/space/views.py +++ b/bkflow/space/views.py @@ -189,9 +189,12 @@ def broadcast_task_events(self, request, *args, **kwargs): event_broadcast_signal.send(sender=data["event"], scopes=scopes, extra_info=data.get("extra_info")) return Response("success") - def get_credential_config(self, config, space_id, scope=CREDENTIAL_CONFIG_KEY): + def get_credential_config(self, config, space_id, scope): try: + credential_name = config + # 如果是字符串直接查询对应凭证 否则提取对应 {scope_type}_{scope_id} 下的内容 if isinstance(config, dict): + # 提取过程中 需要考虑划分到其他没有配置凭证的 {scope_type}_{scope_id} 下的流程 使用 default 默认凭证 credential_name = config.get(scope, config.get(self.CREDENTIAL_CONFIG_KEY)) value = Credential.objects.get( space_id=space_id, name=credential_name, type=CredentialType.BK_APP.value From a8cf2f48afdb4a7442235ba380405f2360970340 Mon Sep 17 00:00:00 2001 From: v_xugzhou <941071842@qq.com> Date: Fri, 21 Mar 2025 17:17:38 +0800 Subject: [PATCH 12/61] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E7=AB=AF?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=88=9B=E5=BB=BA=E5=A1=AB=E5=8F=82=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E4=BC=98=E5=8C=96=20--story=3D122413467=20#=20Reviewe?= =?UTF-8?q?d,=20transaction=20id:=2035436?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RenderForm/tags/TagDateTimeRange.vue | 1 + frontend/src/config/i18n/cn.js | 2 + frontend/src/config/i18n/en.js | 2 + .../admin/Space/Template/CreateTaskDialog.vue | 206 -------------- .../Space/Template/CreateTaskSideslider.vue | 269 ++++++++++++++++++ .../src/views/admin/Space/Template/index.vue | 14 +- 6 files changed, 281 insertions(+), 213 deletions(-) delete mode 100644 frontend/src/views/admin/Space/Template/CreateTaskDialog.vue create mode 100644 frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue diff --git a/frontend/src/components/common/RenderForm/tags/TagDateTimeRange.vue b/frontend/src/components/common/RenderForm/tags/TagDateTimeRange.vue index 57505dc1..49ef7904 100644 --- a/frontend/src/components/common/RenderForm/tags/TagDateTimeRange.vue +++ b/frontend/src/components/common/RenderForm/tags/TagDateTimeRange.vue @@ -5,6 +5,7 @@ v-model="dateValue" :type="'datetimerange'" :disabled="!editable || disabled" + :transfer="true" :placeholder="placeholder"> {{validateInfo.message}} diff --git a/frontend/src/config/i18n/cn.js b/frontend/src/config/i18n/cn.js index 2c487e84..54a6a592 100644 --- a/frontend/src/config/i18n/cn.js +++ b/frontend/src/config/i18n/cn.js @@ -929,6 +929,8 @@ const cn = { 内容: '内容', '凭证删除后不可恢复,确认删除?': '凭证删除后不可恢复,确认删除?', 值类型: '值类型', + 表单模式: '表单模式', + json模式: 'json模式', }; export default cn; diff --git a/frontend/src/config/i18n/en.js b/frontend/src/config/i18n/en.js index 4472f0ac..32733d3d 100644 --- a/frontend/src/config/i18n/en.js +++ b/frontend/src/config/i18n/en.js @@ -929,6 +929,8 @@ const en = { 内容: 'Content', '凭证删除后不可恢复,确认删除?': 'The voucher cannot be recovered once deleted. Are you sure you want to delete it?', 值类型: 'Value Type', + 表单模式: 'Form Mode', + json模式: 'JSON Mode', }; export default en; diff --git a/frontend/src/views/admin/Space/Template/CreateTaskDialog.vue b/frontend/src/views/admin/Space/Template/CreateTaskDialog.vue deleted file mode 100644 index 94021797..00000000 --- a/frontend/src/views/admin/Space/Template/CreateTaskDialog.vue +++ /dev/null @@ -1,206 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue b/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue new file mode 100644 index 00000000..99b7b2bc --- /dev/null +++ b/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue @@ -0,0 +1,269 @@ + + + diff --git a/frontend/src/views/admin/Space/Template/index.vue b/frontend/src/views/admin/Space/Template/index.vue index 0b858ab7..47a73693 100644 --- a/frontend/src/views/admin/Space/Template/index.vue +++ b/frontend/src/views/admin/Space/Template/index.vue @@ -114,10 +114,10 @@ @searchClear="updateSearchSelect" />
- + @close="showCreateTaskSlider = false" /> Date: Wed, 26 Mar 2025 16:13:34 +0800 Subject: [PATCH 13/61] =?UTF-8?q?fix:=20=E7=94=BB=E5=B8=83=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E7=B2=98=E8=B4=B4bug=E4=BF=AE=E5=A4=8D=20--story=3D12?= =?UTF-8?q?2536895=20#=20Reviewed,=20transaction=20id:=2036208?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/ProcessCanvas/components/tools.vue | 7 +++++-- frontend/src/components/ProcessCanvas/index.vue | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ProcessCanvas/components/tools.vue b/frontend/src/components/ProcessCanvas/components/tools.vue index 7532c956..2640c51a 100644 --- a/frontend/src/components/ProcessCanvas/components/tools.vue +++ b/frontend/src/components/ProcessCanvas/components/tools.vue @@ -429,6 +429,8 @@ const { x: originX, y: originY } = this.selectionOriginPos; const { x: mouseX, y: mouseY } = this.pasteMousePos; const { line: lines, activities } = this.$store.state.template; + const ration = this.instance.zoom() + const { tx, ty } = this.instance.translate() const locationHash = {}; const selectCells = []; // 克隆生成的节点 @@ -439,6 +441,7 @@ const activity = utilsTools.deepClone(activities[id]); const extraData = { ...data, + id: nodeId, oldSouceId: id, }; if (activity) { @@ -446,8 +449,8 @@ } const nodeInstance = this.instance.addNode({ id: nodeId, - x: x + mouseX - originX, - y: y + mouseY - originY, + x: (mouseX - tx) / ration + (x - originX), + y: (mouseY - ty) / ration + (y - originY), ...node.size(), shape, data: extraData, diff --git a/frontend/src/components/ProcessCanvas/index.vue b/frontend/src/components/ProcessCanvas/index.vue index 467bdf19..71aaac95 100644 --- a/frontend/src/components/ProcessCanvas/index.vue +++ b/frontend/src/components/ProcessCanvas/index.vue @@ -18,6 +18,8 @@ @onFrameSelectToggle="isSelectionOpen = $event" @onFormatPosition="onFormatPosition" @onLocationMoveDone="onLocationMoveDone" + @onLocationChange="onLocationChange" + @onLineChange="onLineChange" @onDownloadCanvas="onDownloadCanvas" @onTogglePerspective="onTogglePerspective" /> Date: Wed, 26 Mar 2025 17:22:35 +0800 Subject: [PATCH 14/61] =?UTF-8?q?fix:=20=E8=8A=82=E7=82=B9=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E8=BE=93=E5=87=BA=E5=8F=98=E9=87=8F=E5=8B=BE=E9=80=89?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=90=8E=EF=BC=8C=E5=86=8D=E8=BF=9B=E8=A1=8C?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E4=B8=8D=E7=94=9F=E6=95=88=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20--bug=3D122536817=20#=20Reviewed,=20transa?= =?UTF-8?q?ction=20id:=2036238?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../template/TemplateEdit/NodeConfig/NodeConfig.vue | 13 ++++++++++++- .../TabGlobalVariables/VariableEdit.vue | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue b/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue index c7daf62b..31fcac35 100644 --- a/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue +++ b/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue @@ -73,7 +73,7 @@ :common="common" :constants="localConstants" @closeEditingPanel="isVariablePanelShow = false" - @onSaveEditing="isVariablePanelShow = false" /> + @onSaveEditing="onVariableSaveEditing" />
Date: Fri, 28 Mar 2025 14:43:38 +0800 Subject: [PATCH 15/61] =?UTF-8?q?fix:=20=E7=AC=AC=E4=B8=89=E6=96=B9?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E6=89=A7=E8=A1=8C=E8=AF=A6=E6=83=85=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E5=B1=95=E7=A4=BA=E9=97=AE=E9=A2=98=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20--ignore=20#=20Reviewed,=20transaction=20id:=2036605?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProcessCanvas/components/tools.vue | 4 +- .../views/task/TaskExecute/ExecuteInfo.vue | 44 ++++++++++++------- .../views/task/TaskExecute/TaskOperation.vue | 1 + .../TemplateEdit/NodeConfig/NodeConfig.vue | 12 ++--- 4 files changed, 36 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/ProcessCanvas/components/tools.vue b/frontend/src/components/ProcessCanvas/components/tools.vue index 2640c51a..aee6c1e7 100644 --- a/frontend/src/components/ProcessCanvas/components/tools.vue +++ b/frontend/src/components/ProcessCanvas/components/tools.vue @@ -429,8 +429,8 @@ const { x: originX, y: originY } = this.selectionOriginPos; const { x: mouseX, y: mouseY } = this.pasteMousePos; const { line: lines, activities } = this.$store.state.template; - const ration = this.instance.zoom() - const { tx, ty } = this.instance.translate() + const ration = this.instance.zoom(); + const { tx, ty } = this.instance.translate(); const locationHash = {}; const selectCells = []; // 克隆生成的节点 diff --git a/frontend/src/views/task/TaskExecute/ExecuteInfo.vue b/frontend/src/views/task/TaskExecute/ExecuteInfo.vue index 9308ffed..0e2da5ec 100644 --- a/frontend/src/views/task/TaskExecute/ExecuteInfo.vue +++ b/frontend/src/views/task/TaskExecute/ExecuteInfo.vue @@ -274,6 +274,12 @@ required: true, }, isShow: Boolean, + constants: { + type: Object, + default() { + return {}; + }, + }, gateways: { type: Object, default() { @@ -600,23 +606,27 @@ if (this.pluginCode === 'dmn_plugin') { outputsInfo.push(...outputs); } else if (this.isThirdPartyNode) { - const excludeList = []; - outputsInfo = outputs.filter((item) => { - if (!item.preset) { - excludeList.push(item); - } - return item.preset; - }); - excludeList.forEach((item) => { - const output = this.pluginOutputs.find(output => output.key === item.key); - if (output) { - const { name, key } = output; - const info = { - key, - name, - value: item.value, - }; - outputsInfo.push(info); + outputs.forEach((param) => { + // 判断preset是否为true + if (param.preset) { + outputsInfo.push(param); + } else { + // 判断key是否与插件配置项对应 + const output = this.pluginOutputs.find(output => output.key === param.key); + if (output) { + outputsInfo.push(param); + } else { + // 判断key是否变量 + const varKey = `\${${param.key}}`; + const varInfo = this.constants[varKey]; + let isHooked = false; + if (varInfo && varInfo.source_type === 'component_outputs') { + isHooked = this.nodeActivity.id in varInfo.source_info; + } + if (isHooked) { + outputsInfo.push(param); + } + } } }); } else if (islegacySubProcess) { diff --git a/frontend/src/views/task/TaskExecute/TaskOperation.vue b/frontend/src/views/task/TaskExecute/TaskOperation.vue index 8c771293..bb963415 100644 --- a/frontend/src/views/task/TaskExecute/TaskOperation.vue +++ b/frontend/src/views/task/TaskExecute/TaskOperation.vue @@ -132,6 +132,7 @@ :node-detail-config="nodeDetailConfig" :is-readonly="true" :is-show.sync="isShowConditionEdit" + :constants="pipelineData.constants" :gateways="pipelineData.gateways" :condition-data="conditionData" :space-id="spaceId" diff --git a/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue b/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue index 31fcac35..5840083f 100644 --- a/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue +++ b/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue @@ -983,14 +983,14 @@ }, // 变量编辑确认 onVariableSaveEditing(variable) { - this.isVariablePanelShow = false + this.isVariablePanelShow = false; - const { key } = this.variableData - if (!key || key === variable.key) return + const { key } = this.variableData; + if (!key || key === variable.key) return; - this.onHookChange('delete', this.variableData) - this.onHookChange('create', variable) - this.variableData = {} + this.onHookChange('delete', this.variableData); + this.onHookChange('create', variable); + this.variableData = {}; }, // 标准插件(子流程)选择面板切换插件(子流程) // isThirdParty 是否为第三方插件 From 1cb3412146cd7a651dfc2d8f32f630f7d23df130 Mon Sep 17 00:00:00 2001 From: ZC-A <57583928+ZC-A@users.noreply.github.com> Date: Mon, 31 Mar 2025 15:44:29 +0800 Subject: [PATCH 16/61] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=B8=85=E7=90=86=E5=8A=9F=E8=83=BD=20#141=20(#143)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 支持任务清理功能 #141 * feat: 支持任务清理功能 #141 * feat: 支持任务清理功能 #141 * feat: 支持任务清理功能 #141 * feat: 支持任务清理功能 #141 * feat: 支持任务清理功能 #141 * feat: 支持任务清理功能 #141 --- app_desc.yaml | 4 + bkflow/contrib/__init__.py | 19 +++ bkflow/contrib/expired_cleaner/__init__.py | 19 +++ bkflow/contrib/expired_cleaner/tasks.py | 22 ++++ bkflow/contrib/expired_cleaner/utils.py | 145 +++++++++++++++++++++ bkflow/task/celery/settings.py | 6 + env.py | 15 +++ module_settings.py | 25 +++- 8 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 bkflow/contrib/__init__.py create mode 100644 bkflow/contrib/expired_cleaner/__init__.py create mode 100644 bkflow/contrib/expired_cleaner/tasks.py create mode 100644 bkflow/contrib/expired_cleaner/utils.py diff --git a/app_desc.yaml b/app_desc.yaml index acb904ab..0cd7b77f 100644 --- a/app_desc.yaml +++ b/app_desc.yaml @@ -141,4 +141,8 @@ modules: beat: command: celery beat -A blueapps.core.celery -l info plan: 4C1G5R + replicas: 1 + clean-worker: + command: celery worker -A blueapps.core.celery -P threads -Q clean_task_${BKFLOW_MODULE_CODE} -n clean_worker@%h -c 100 -l info + plan: 4C4G5R replicas: 1 \ No newline at end of file diff --git a/bkflow/contrib/__init__.py b/bkflow/contrib/__init__.py new file mode 100644 index 00000000..c67ae4ea --- /dev/null +++ b/bkflow/contrib/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" diff --git a/bkflow/contrib/expired_cleaner/__init__.py b/bkflow/contrib/expired_cleaner/__init__.py new file mode 100644 index 00000000..c67ae4ea --- /dev/null +++ b/bkflow/contrib/expired_cleaner/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" diff --git a/bkflow/contrib/expired_cleaner/tasks.py b/bkflow/contrib/expired_cleaner/tasks.py new file mode 100644 index 00000000..aec18c29 --- /dev/null +++ b/bkflow/contrib/expired_cleaner/tasks.py @@ -0,0 +1,22 @@ +import logging +from datetime import datetime + +from celery import shared_task +from django.conf import settings +from django.utils import timezone + +from .utils import delete_expired_data + +logger = logging.getLogger("celery") + + +@shared_task +def clean_task(): + if not settings.ENABLE_CLEAN_TASK: + logger.info("clean task not enabled, exit....") + return + logger.info("clean task starts...") + current_execute_time = datetime.utcnow() + expired_time = current_execute_time - timezone.timedelta(days=settings.CLEAN_TASK_EXPIRED_DAYS) + logger.info(f"clean tasks before {expired_time}") + delete_expired_data(expired_time) diff --git a/bkflow/contrib/expired_cleaner/utils.py b/bkflow/contrib/expired_cleaner/utils.py new file mode 100644 index 00000000..a4d7ae75 --- /dev/null +++ b/bkflow/contrib/expired_cleaner/utils.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" + +import logging + +from django.conf import settings +from django.db import transaction +from pipeline.eri.models import ( + CallbackData, + ContextOutputs, + ContextValue, + Data, + ExecutionData, + ExecutionHistory, + Node, + Process, + Schedule, + State, +) + +from bkflow.task.models import ( + AutoRetryNodeStrategy, + TaskExecutionSnapshot, + TaskInstance, + TaskMockData, + TaskOperationRecord, + TaskSnapshot, + TimeoutNodeConfig, +) + +logger = logging.getLogger("celery") + + +def chunk_data(data, chunk_size, func, *args, **kwargs): + return [(func)(data[i : i + chunk_size], *args, **kwargs) for i in range(0, len(data), chunk_size)] + + +def get_expired_data(expired_time): + + tasks = TaskInstance.objects.filter(create_time__lt=expired_time).order_by("-id")[: settings.CLEAN_TASK_BATCH_NUM] + # 查询这段时间内的 task_instance_ids + if not tasks: + logger.info("no cleaning task, exit...") + return {}, {} + task_instance_ids = [instance.instance_id for instance in tasks] + logger.info(f"batch cleaning task_instances {task_instance_ids}") + task_ids = [instance.id for instance in tasks] + # 快照 id 可能为空 + snapshot_ids = [instance.snapshot_id for instance in tasks if instance.snapshot_id] + execution_snapshot_ids = [instance.execution_snapshot_id for instance in tasks if instance.execution_snapshot_id] + + # task_ids -> 其他任务关联资源 一对一 + context_value = ContextValue.objects.filter(pipeline_id__in=task_instance_ids) + context_outputs = ContextOutputs.objects.filter(pipeline_id__in=task_instance_ids) + + task_operation_record = TaskOperationRecord.objects.filter(instance_id__in=task_ids) + task_mock_data = TaskMockData.objects.filter(taskflow_id__in=task_ids) + + task_execution_snapshot = TaskExecutionSnapshot.objects.filter(id__in=execution_snapshot_ids) + task_snapshot = TaskSnapshot.objects.filter(id__in=snapshot_ids) + + # task_ids -> node_ids + node_query = ( + Process.objects.filter(root_pipeline_id__in=task_instance_ids) + .values_list("current_node_id", flat=True) + .distinct() + ) + node_ids = list(node_query) + logger.info(f"batch cleaning node_ids {node_ids}") + + # 重构 node_query task_query 避免因前序查询导致结构变化无法执行操作 + nodes = Process.objects.filter(root_pipeline_id__in=task_instance_ids) + tasks = TaskInstance.objects.filter(instance_id__in=task_instance_ids) + callbackdata = CallbackData.objects.filter(node_id__in=node_ids) # callbackdata 没有索引 走全表扫描 + expired_data = { + "task_execution_snapshot": task_execution_snapshot, + "task_snapshot": task_snapshot, + "callbackdata": callbackdata, + "context_value": context_value, + "context_outputs": context_outputs, + "task_operation_record": task_operation_record, + "task_mock_data": task_mock_data, + "node_ids": nodes, + "task_instance": tasks, + } + + chunk_size = settings.CLEAN_TASK_NODE_BATCH_NUM + # node_ids -> 其他节点关联资源 一对多 + nodes_list = chunk_data(node_ids, chunk_size, lambda x: Node.objects.filter(node_id__in=x)) + data_list = chunk_data(node_ids, chunk_size, lambda x: Data.objects.filter(node_id__in=x)) + states_list = chunk_data(node_ids, chunk_size, lambda x: State.objects.filter(node_id__in=x)) + execution_history_list = chunk_data(node_ids, chunk_size, lambda x: ExecutionHistory.objects.filter(node_id__in=x)) + execution_data_list = chunk_data(node_ids, chunk_size, lambda x: ExecutionData.objects.filter(node_id__in=x)) + schedules_list = chunk_data(node_ids, chunk_size, lambda x: Schedule.objects.filter(node_id__in=x)) + + retry_node_list = chunk_data(node_ids, chunk_size, lambda x: AutoRetryNodeStrategy.objects.filter(node_id__in=x)) + timeout_node_list = chunk_data(node_ids, chunk_size, lambda x: TimeoutNodeConfig.objects.filter(node_id__in=x)) + + # 将一对一 和 一对多的分开返回 便于删除时区分 + expired_batch_data = { + "nodes_list": nodes_list, + "data_list": data_list, + "states_list": states_list, + "execution_history_list": execution_history_list, + "execution_data_list": execution_data_list, + "schedules_list": schedules_list, + "retry_node_list": retry_node_list, + "timeout_node_list": timeout_node_list, + } + return expired_data, expired_batch_data + + +def delete_expired_data(expired_time): + + expired_data, expired_batch_data = get_expired_data(expired_time) + with transaction.atomic(): + # 清理无分块内容 + for field, qs in expired_data.items(): + logger.info(f"clean no batch {field} querySet ids : {qs.values_list('pk', flat=True)[:10]}...") + qs.delete() + # 清理分块内容 + for field, qs in expired_batch_data.items(): + logger.info( + f"clean {field} {len(qs)} batch data, " + f"e.x.: {qs[0].values_list('pk', flat=True)[:10] if len(qs) > 0 else None}..." + ) + [q.delete() for q in qs] + logger.info("clean task done...") diff --git a/bkflow/task/celery/settings.py b/bkflow/task/celery/settings.py index f99f898d..52a2a881 100644 --- a/bkflow/task/celery/settings.py +++ b/bkflow/task/celery/settings.py @@ -48,4 +48,10 @@ def get_task_queues(module_code: str) -> List[Queue]: routing_key=f"task_common_{module_code}", queue_arguments={"x-max-priority": 255}, ), + Queue( + f"clean_task_{module_code}", + Exchange("default", type="direct"), + routing_key=f"clean_task_{module_code}", + queue_arguments={"x-max-priority": 255}, + ), ] diff --git a/env.py b/env.py index 8fdf18ce..390819b0 100644 --- a/env.py +++ b/env.py @@ -143,3 +143,18 @@ # 是否支持API插件使用 BKFLOW 凭证 USE_BKFLOW_CREDENTIAL = os.getenv("USE_BKFLOW_CREDENTIAL", False) # 默认关闭使用 + +# 清理任务批量数目 +CLEAN_TASK_BATCH_NUM = os.getenv("CLEAN_TASK_BATCH_NUM", 200) + +# 清理节点批量数目 +CLEAN_TASK_NODE_BATCH_NUM = os.getenv("CLEAN_TASK_NODE_BATCH_NUM", 5000) + +# 是否开启清理任务 默认关闭 +ENABLE_CLEAN_TASK = os.getenv("ENABLE_CLEAN_TASK", False) + +# 清理任务保存周期 默认 30 天 +CLEAN_TASK_EXPIRED_DAYS = int(os.getenv("CLEAN_TASK_EXPIRED_DAYS", 180)) + +# 清理任务周期 默认 5 分钟一次 +CLEAN_TASK_CRONTAB = os.getenv("CLEAN_TASK_CRONTAB", "*/5 * * * *") diff --git a/module_settings.py b/module_settings.py index 60491d3f..8d142e29 100644 --- a/module_settings.py +++ b/module_settings.py @@ -22,6 +22,8 @@ from enum import Enum from urllib.parse import urlparse +from blueapps.core.celery.celery import app +from celery.schedules import crontab from django.core.serializers.json import DjangoJSONEncoder from pydantic import BaseModel @@ -125,7 +127,7 @@ def check_engine_admin_permission(request, *args, **kwargs): }, } - from pipeline.celery.settings import CELERY_QUEUES # noqa + from pipeline.celery.settings import CELERY_QUEUES, CELERY_ROUTES # noqa from pipeline.eri.celery import queues as eri_queues # noqa CELERY_QUEUES.extend(eri_queues.QueueResolver(BKFLOW_MODULE.code).queues()) @@ -152,8 +154,24 @@ def check_engine_admin_permission(request, *args, **kwargs): "plugin_service", "bkflow.contrib.operation_record", "django_dbconn_retry", + "bkflow.contrib.expired_cleaner", ) + BKFLOW_CELERY_ROUTES = { + "bkflow.contrib.expired_cleaner.tasks.clean_task": { + "queue": f"clean_task_{BKFLOW_MODULE.code}", + "routing_key": f"clean_task_{BKFLOW_MODULE.code}", + } + } + CELERY_ROUTES.update(BKFLOW_CELERY_ROUTES) + + app.conf.beat_schedule = { + "expired_task_cleaning": { + "task": "bkflow.contrib.expired_cleaner.tasks.clean_task", + "schedule": crontab(env.CLEAN_TASK_CRONTAB), + }, + } + MIDDLEWARE += ("bkflow.permission.middleware.TokenMiddleware",) REST_FRAMEWORK = { @@ -187,6 +205,11 @@ def check_engine_admin_permission(request, *args, **kwargs): PAASV3_APIGW_API_TOKEN = env.PAASV3_APIGW_API_TOKEN LOG_PERSISTENT_DAYS = env.LOG_PERSISTENT_DAYS USE_BKFLOW_CREDENTIAL = env.USE_BKFLOW_CREDENTIAL + CLEAN_TASK_BATCH_NUM = env.CLEAN_TASK_BATCH_NUM + CLEAN_TASK_NODE_BATCH_NUM = env.CLEAN_TASK_NODE_BATCH_NUM + CLEAN_TASK_EXPIRED_DAYS = env.CLEAN_TASK_EXPIRED_DAYS + ENABLE_CLEAN_TASK = env.ENABLE_CLEAN_TASK + CLEAN_TASK_CRONTAB = env.CLEAN_TASK_CRONTAB elif env.BKFLOW_MODULE_TYPE == BKFLOWModuleType.interface.value: From d08320c89018bbd6e7956be1e1e439b1f5f17581 Mon Sep 17 00:00:00 2001 From: ZC-A <57583928+ZC-A@users.noreply.github.com> Date: Mon, 31 Mar 2025 20:54:35 +0800 Subject: [PATCH 17/61] =?UTF-8?q?fix:=20api=E6=8F=92=E4=BB=B6=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E4=BC=98=E5=8C=96=20#149=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: api插件文档优化 #149 * fix: api插件文档优化 #149 --- docs/guide/api_plugin.md | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/guide/api_plugin.md b/docs/guide/api_plugin.md index 76544cf3..d33e921b 100644 --- a/docs/guide/api_plugin.md +++ b/docs/guide/api_plugin.md @@ -59,8 +59,8 @@ sequenceDiagram "result": true, "message": "", "data": [ - {"name": "c1", "key": "c1"}, - {"name": "c2", "key": "c2"} + {"name": "c1", "id": "c1"}, + {"name": "c2", "id": "c2"} ] } ``` @@ -72,21 +72,23 @@ sequenceDiagram 2. 输出:接口返回标准三段结构,result为True时展示接口列表,False时展示错误提示 ``` json { - "result": true, - "message": "", - "data": [ - { - "id": "api1", - "meta_url": "xxxx/api1", // 对应的 detail meta api - "name": "api1", - "category": "xxx" + "result": true, + "message": "", + "data": { + "total": 1, + "apis": [ + { + "id": "api1", + "name": "API1", + "meta_url": "xxxx" // 拉取 api 信息的 url + } + ] } - ] } ``` ### detail meta api -基于选中的api,BKFlow 会从detail meta api中获取接口的详细信息,接口的输入输出: +基于选中的api,BKFlow 会从上述的detail meta api中获取接口的详细信息,接口的输入输出: 1. 输入:GET方法 2. 输出:接口返回标准三段结构,result为True时展示接口列表,False时展示错误提示 @@ -97,7 +99,7 @@ sequenceDiagram "data": { "id": "api1", "name": "api1", - "url": "https://{some apigw host}/xxxx", // 执行时实际调用的 api + "url": "https://{some apigw host}/xxxx", // 执行时实际调用的 api 注意必须要符合网关API的格式 "methods": [ "GET" ], @@ -348,13 +350,16 @@ sequenceDiagram ... } +// 从轮询返回具体的 task 相关信息时 通过 result 字段标明响应是否成功 // {{api_url}} api response { + "result": true, "task_tag": 1234 } // {{polling_url}} api response { + "result": true, "status": "success", } ``` @@ -402,8 +407,8 @@ sequenceDiagram "url": "{{api_url}}", // 触发任务的 url "methods": ["POST"], "callback": { - "success_tag": {"key": "status", "value": "success"}, // polling_url 响应中用于识别状态成功的 key 和 value(value 只支持字符串和数字类型) - "fail_tag": {"key": "status", "value": "fail"}, // polling_url 响应中用于识别状态失败的 key 和 value(value 只支持字符串和数字类型) + "success_tag": {"key": "status", "value": "success"}, // callback_url 响应中用于识别状态成功的 key 和 value(value 只支持字符串和数字类型) + "fail_tag": {"key": "status", "value": "fail"}, // callback_url 响应中用于识别状态失败的 key 和 value(value 只支持字符串和数字类型) }, ... }, From 41b4b8cdb8e2305de06b8999d413ff94d1d16437 Mon Sep 17 00:00:00 2001 From: ZC-A <57583928+ZC-A@users.noreply.github.com> Date: Tue, 1 Apr 2025 15:17:00 +0800 Subject: [PATCH 18/61] =?UTF-8?q?feat:=20=E6=8F=92=E4=BB=B6=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E6=90=BA=E5=B8=A6=E7=94=A8=E6=88=B7=E5=90=8D=20#151?= =?UTF-8?q?=20(#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 插件请求携带用户名 #151 * feat: 插件请求携带用户名 #151 --- .../pipeline_plugins/query/uniform_api/uniform_api.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bkflow/pipeline_plugins/query/uniform_api/uniform_api.py b/bkflow/pipeline_plugins/query/uniform_api/uniform_api.py index fefbbe90..fac6c69b 100644 --- a/bkflow/pipeline_plugins/query/uniform_api/uniform_api.py +++ b/bkflow/pipeline_plugins/query/uniform_api/uniform_api.py @@ -52,7 +52,7 @@ class UniformAPIMetaSerializer(serializers.Serializer): meta_url = serializers.CharField(required=True) -def _get_space_uniform_api_list_info(space_id, request_data, config_key): +def _get_space_uniform_api_list_info(space_id, request_data, config_key, username): uniform_api_config = SpaceConfig.get_config(space_id=space_id, config_name=UniformApiConfig.name) if not uniform_api_config: raise ValidationError("接入平台未注册统一API, 请联系对应接入平台管理员") @@ -63,7 +63,7 @@ def _get_space_uniform_api_list_info(space_id, request_data, config_key): url = uniform_api_config.api.get(api_name, {}).get(config_key) if not url: raise ValidationError("对应API未配置, 请联系对应接入平台管理员") - request_result: HttpRequestResult = client.request(url=url, method="GET", data=request_data) + request_result: HttpRequestResult = client.request(url=url, method="GET", data=request_data, username=username) if not request_result.result: raise APIResponseError(f"请求统一API列表失败: {request_result.message}") response_schema = ( @@ -86,7 +86,8 @@ def get_space_uniform_api_category_list(request, space_id): serializer.is_valid(raise_exception=True) data = serializer.validated_data api_category_key = UniformApiConfig.Keys.API_CATEGORIES.value - return _get_space_uniform_api_list_info(space_id, data, api_category_key) + username = request.user.username + return _get_space_uniform_api_list_info(space_id, data, api_category_key, username) @swagger_auto_schema(methods=["GET"], query_serializer=UniformAPIListSerializer) @@ -100,7 +101,8 @@ def get_space_uniform_api_list(request, space_id): serializer.is_valid(raise_exception=True) data = serializer.validated_data meta_apis_key = UniformApiConfig.Keys.META_APIS.value - return _get_space_uniform_api_list_info(space_id, data, meta_apis_key) + username = request.user.username + return _get_space_uniform_api_list_info(space_id, data, meta_apis_key, username) @swagger_auto_schema(methods=["GET"], query_serializer=UniformAPIMetaSerializer) From 7cb2c19814acb80fdbd9be5130732bccc7b71840 Mon Sep 17 00:00:00 2001 From: ZC-A <1483681501@qq.com> Date: Tue, 1 Apr 2025 18:57:33 +0800 Subject: [PATCH 19/61] =?UTF-8?q?feat:=20=E6=8F=92=E4=BB=B6=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E6=90=BA=E5=B8=A6=E7=94=A8=E6=88=B7=E5=90=8D=20#151?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bkflow/pipeline_plugins/query/uniform_api/uniform_api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bkflow/pipeline_plugins/query/uniform_api/uniform_api.py b/bkflow/pipeline_plugins/query/uniform_api/uniform_api.py index fac6c69b..98b3e74d 100644 --- a/bkflow/pipeline_plugins/query/uniform_api/uniform_api.py +++ b/bkflow/pipeline_plugins/query/uniform_api/uniform_api.py @@ -116,8 +116,9 @@ def get_space_uniform_api_meta(requests, space_id): serializer.is_valid(raise_exception=True) data = serializer.validated_data meta_url = data.pop("meta_url") + username = requests.user.username client = UniformAPIClient() - request_result: HttpRequestResult = client.request(url=meta_url, method="GET", data=data) + request_result: HttpRequestResult = client.request(url=meta_url, method="GET", data=data, username=username) if request_result.result is False: raise APIResponseError(f"请求统一API元数据失败: {request_result.message}") client.validate_response_data( From 3762c119cfcdbd2ae575b290e97dab5ca85cfd2e Mon Sep 17 00:00:00 2001 From: jackvideo <13226110808@163.com> Date: Wed, 2 Apr 2025 11:45:08 +0800 Subject: [PATCH 20/61] =?UTF-8?q?feat:=20=E8=93=9D=E9=B2=B8=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E4=BA=8C=E6=AC=A1=E6=8E=88=E6=9D=83=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?&=E4=BD=BF=E7=94=A8=E8=8C=83=E5=9B=B4=E5=8A=9F=E8=83=BD=20#132?= =?UTF-8?q?=20(#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 蓝鲸插件二次授权确认&使用范围功能 #132 * feat: 蓝鲸插件二次授权确认&使用范围功能 #132 * feat: 蓝鲸插件二次授权确认&使用范围功能 #132 * feat: 蓝鲸插件二次授权确认&使用范围功能 #132 * feat: 蓝鲸插件二次授权确认&使用范围功能 #132 * feat: 蓝鲸插件二次授权确认&使用范围功能 #132 * feat: 蓝鲸插件二次授权确认&使用范围功能 #132 * feat: 蓝鲸插件二次授权确认&使用范围功能 #132 * feat: 蓝鲸插件二次授权确认&使用范围功能 #132 * feat: 蓝鲸插件二次授权确认&使用范围功能 #132 --- bkflow/bk_plugin/__init__.py | 19 +++ bkflow/bk_plugin/admin.py | 49 ++++++ bkflow/bk_plugin/apps.py | 25 +++ bkflow/bk_plugin/migrations/0001_initial.py | 83 +++++++++ bkflow/bk_plugin/migrations/__init__.py | 19 +++ bkflow/bk_plugin/models.py | 178 ++++++++++++++++++++ bkflow/bk_plugin/permissions.py | 31 ++++ bkflow/bk_plugin/serializer.py | 93 ++++++++++ bkflow/bk_plugin/tasks.py | 70 ++++++++ bkflow/bk_plugin/urls.py | 28 +++ bkflow/bk_plugin/views.py | 125 ++++++++++++++ bkflow/constants.py | 3 + bkflow/exceptions.py | 4 + bkflow/template/serializers/template.py | 12 ++ bkflow/template/views/template.py | 1 - bkflow/urls.py | 1 + env.py | 5 + module_settings.py | 10 ++ 18 files changed, 755 insertions(+), 1 deletion(-) create mode 100644 bkflow/bk_plugin/__init__.py create mode 100644 bkflow/bk_plugin/admin.py create mode 100644 bkflow/bk_plugin/apps.py create mode 100644 bkflow/bk_plugin/migrations/0001_initial.py create mode 100644 bkflow/bk_plugin/migrations/__init__.py create mode 100644 bkflow/bk_plugin/models.py create mode 100644 bkflow/bk_plugin/permissions.py create mode 100644 bkflow/bk_plugin/serializer.py create mode 100644 bkflow/bk_plugin/tasks.py create mode 100644 bkflow/bk_plugin/urls.py create mode 100644 bkflow/bk_plugin/views.py diff --git a/bkflow/bk_plugin/__init__.py b/bkflow/bk_plugin/__init__.py new file mode 100644 index 00000000..c67ae4ea --- /dev/null +++ b/bkflow/bk_plugin/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" diff --git a/bkflow/bk_plugin/admin.py b/bkflow/bk_plugin/admin.py new file mode 100644 index 00000000..ef0406ea --- /dev/null +++ b/bkflow/bk_plugin/admin.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" +from django.contrib import admin + +from bkflow.bk_plugin.models import BKPlugin, BKPluginAuthorization + + +# Register your models here. +@admin.register(BKPlugin) +class BKPluginAdmin(admin.ModelAdmin): + list_display = ( + "code", + "name", + "tag", + "logo_url", + "introduction", + "created_time", + "updated_time", + "managers", + "extra_info", + ) + search_fields = ("code", "name", "tag") + list_filter = ("code",) + ordering = ("code",) + + +@admin.register(BKPluginAuthorization) +class BKPluginAuthenticationAdmin(admin.ModelAdmin): + list_display = ("code", "status", "config", "status_update_time", "status_updator") + search_fields = ("code", "status_updator") + list_filter = ("code", "status_updator", "status") + ordering = ("code",) diff --git a/bkflow/bk_plugin/apps.py b/bkflow/bk_plugin/apps.py new file mode 100644 index 00000000..be3cdee7 --- /dev/null +++ b/bkflow/bk_plugin/apps.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" +from django.apps import AppConfig + + +class BKPluginAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "bkflow.bk_plugin" diff --git a/bkflow/bk_plugin/migrations/0001_initial.py b/bkflow/bk_plugin/migrations/0001_initial.py new file mode 100644 index 00000000..9d7fd805 --- /dev/null +++ b/bkflow/bk_plugin/migrations/0001_initial.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" +# Generated by Django 3.2.15 on 2025-04-02 02:14 + +from django.db import migrations, models + +import bkflow.bk_plugin.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="BKPlugin", + fields=[ + ("code", models.CharField(max_length=100, primary_key=True, serialize=False, verbose_name="插件code")), + ("name", models.CharField(max_length=255, verbose_name="插件名称")), + ("tag", models.IntegerField(db_index=True, verbose_name="插件隶属分类")), + ("logo_url", models.CharField(max_length=255, verbose_name="插件图片url")), + ("created_time", models.CharField(blank=True, max_length=255, null=True, verbose_name="插件创建时间")), + ("updated_time", models.CharField(blank=True, max_length=255, null=True, verbose_name="插件更新时间")), + ("introduction", models.CharField(max_length=255, verbose_name="插件简介")), + ("managers", models.JSONField(default=list, verbose_name="插件管理员列表")), + ("extra_info", models.JSONField(default=dict, verbose_name="额外信息")), + ], + options={ + "verbose_name": "蓝鲸插件", + "verbose_name_plural": "蓝鲸插件", + }, + ), + migrations.CreateModel( + name="BKPluginAuthorization", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("code", models.CharField(db_index=True, max_length=100, verbose_name="插件code")), + ( + "status", + models.IntegerField( + choices=[ + (bkflow.bk_plugin.models.AuthStatus["authorized"], "已授权"), + (bkflow.bk_plugin.models.AuthStatus["unauthorized"], "未授权"), + ], + default=bkflow.bk_plugin.models.AuthStatus["unauthorized"], + verbose_name="授权状态", + ), + ), + ("status_update_time", models.DateTimeField(blank=True, null=True, verbose_name="最近一次授权操作时间")), + ( + "config", + models.JSONField(default=bkflow.bk_plugin.models.get_default_config, verbose_name="授权配置,如使用范围等"), + ), + ( + "status_updator", + models.CharField(blank=True, default="", max_length=100, verbose_name="最近一次授权操作的人员名称"), + ), + ], + options={ + "verbose_name": "蓝鲸插件授权记录", + "verbose_name_plural": "蓝鲸插件授权记录", + }, + ), + ] diff --git a/bkflow/bk_plugin/migrations/__init__.py b/bkflow/bk_plugin/migrations/__init__.py new file mode 100644 index 00000000..c67ae4ea --- /dev/null +++ b/bkflow/bk_plugin/migrations/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" diff --git a/bkflow/bk_plugin/models.py b/bkflow/bk_plugin/models.py new file mode 100644 index 00000000..ba9f6bb9 --- /dev/null +++ b/bkflow/bk_plugin/models.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" +import logging +from enum import Enum + +from django.db import models, transaction +from django.utils.timezone import localtime +from django.utils.translation import ugettext_lazy as _ + +import env +from bkflow.constants import ALL_SPACE, WHITE_LIST +from bkflow.exceptions import PluginUnAuthorization + +logger = logging.getLogger("root") + + +class BKPluginManager(models.Manager): + def fill_plugin_info(self, remote_plugin): + """ + 将最新插件信息封装为本地蓝鲸插件 + """ + managers = set() + if remote_plugin["profile"]["contact"]: + managers.update(remote_plugin["profile"]["contact"].split(",")) + if remote_plugin["plugin"]["creator"]: + managers.add(remote_plugin["plugin"]["creator"]) + return BKPlugin( + code=remote_plugin["plugin"]["code"], + name=remote_plugin["plugin"]["name"], + logo_url=remote_plugin["plugin"]["logo_url"], + tag=remote_plugin["profile"]["tag"], + created_time=remote_plugin["plugin"]["created"], + updated_time=remote_plugin["plugin"]["updated"], + introduction=remote_plugin["profile"]["introduction"], + managers=list(managers), + ) + + def sync_bk_plugins(self, remote_plugins_dict): + """ + 批量更新插件信息 + """ + if not remote_plugins_dict: + return + # 比较插件code和更新时间 + remote_pairs = set((code, plugin["plugin"]["updated"]) for (code, plugin) in remote_plugins_dict.items()) + local_pairs = set((plugin.code, plugin.updated_time) for plugin in self.all()) + to_create_pairs = remote_pairs - local_pairs + logger.info(f"蓝鲸插件同步过程新增插件{len(to_create_pairs)}个") + # 准备好批量创建的插件列表 + to_create_plugins = [self.fill_plugin_info(remote_plugins_dict[pair[0]]) for pair in set(to_create_pairs)] + to_delete_codes = [pair[0] for pair in set(local_pairs - remote_pairs)] + logger.info(f"蓝鲸插件同步过程删除插件{len(to_delete_codes)}") + # 开启事务进行批量操作 + with transaction.atomic(): + if to_delete_codes: + self.filter(code__in=to_delete_codes).delete() + if to_create_plugins: + # 每次同步检查一次权限记录,是否需要创建新记录 + self.bulk_create(to_create_plugins) + + def get_plugin_by_manager(self, username): + """ + 根据用户管理员权限获取插件列表 + """ + # 仅获取该用户有管理员权限的蓝鲸插件 + return self.filter(managers__contains=username) + + +class BKPlugin(models.Model): + """ + 蓝鲸插件数据 + """ + + code = models.CharField(_("插件code"), primary_key=True, max_length=100) + name = models.CharField(_("插件名称"), max_length=255) + tag = models.IntegerField(_("插件隶属分类"), db_index=True, null=False) + logo_url = models.CharField(_("插件图片url"), max_length=255) + created_time = models.CharField(_("插件创建时间"), null=True, blank=True, max_length=255) + updated_time = models.CharField(_("插件更新时间"), null=True, blank=True, max_length=255) + introduction = models.CharField(_("插件简介"), max_length=255) + managers = models.JSONField(_("插件管理员列表"), default=list) + extra_info = models.JSONField(_("额外信息"), default=dict) + + objects = BKPluginManager() + + class Meta: + verbose_name = "蓝鲸插件" + verbose_name_plural = "蓝鲸插件" + + +class AuthStatus(int, Enum): + authorized = 1 + unauthorized = 0 + + +def get_default_config(): + return {WHITE_LIST: [ALL_SPACE]} + + +class BKPluginAuthorizationManager(models.Manager): + def get_codes_by_space_id(self, space_id: str): + """ + 根据空间ID获取已被授权的插件code + """ + authorized_dict = self.filter(status=AuthStatus.authorized) + result_codes = [] + for obj in authorized_dict: + white_list = obj.white_list + if ALL_SPACE in white_list or space_id in white_list: + result_codes.append(obj.code) + return result_codes + + # 批量检查插件授权状态 + def batch_check_authorization(self, exist_code_list): + if not env.ENABLE_BK_PLUGIN_AUTHORIZATION: + return + authorized_codes = set( + self.filter(code__in=exist_code_list, status=AuthStatus.authorized).values_list("code", flat=True) + ) + unauthorized_plugins = list(set(exist_code_list) - authorized_codes) + if unauthorized_plugins: + logger.exception(f"流程中存在未授权插件:{unauthorized_plugins}") + raise PluginUnAuthorization(f"流程中存在未授权插件:{unauthorized_plugins}") + + +class BKPluginAuthorization(models.Model): + """ " + 蓝鲸插件的授权记录 + """ + + AUTH_STATUS_CHOICES = ( + (AuthStatus.authorized, _("已授权")), + (AuthStatus.unauthorized, _("未授权")), + ) + + code = models.CharField(_("插件code"), db_index=True, max_length=100) + status = models.IntegerField(_("授权状态"), choices=AUTH_STATUS_CHOICES, default=AuthStatus.unauthorized) + status_update_time = models.DateTimeField(_("最近一次授权操作时间"), null=True, blank=True) + config = models.JSONField(_("授权配置,如使用范围等"), default=get_default_config) + status_updator = models.CharField(_("最近一次授权操作的人员名称"), max_length=100, blank=True, default="") + + objects = BKPluginAuthorizationManager() + + class Meta: + verbose_name = "蓝鲸插件授权记录" + verbose_name_plural = "蓝鲸插件授权记录" + + @property + def white_list(self): + return self.config[WHITE_LIST] + + def to_json(self): + return { + "code": self.code, + "status": self.status, + "status_update_time": localtime(self.status_update_time).strftime("%Y-%m-%d %H:%M:%S") + if self.status_update_time + else "", + "config": self.config, + "status_updator": self.status_updator, + } diff --git a/bkflow/bk_plugin/permissions.py b/bkflow/bk_plugin/permissions.py new file mode 100644 index 00000000..b9d564dc --- /dev/null +++ b/bkflow/bk_plugin/permissions.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" +from rest_framework import permissions + +from bkflow.bk_plugin.models import BKPlugin + + +# 是否有插件的管理员权限 +class BKPluginManagerPermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + plugin = BKPlugin.objects.filter(code=obj.code).first() + if not plugin or request.user.username not in plugin.managers: + return False + return True diff --git a/bkflow/bk_plugin/serializer.py b/bkflow/bk_plugin/serializer.py new file mode 100644 index 00000000..8fc02b5f --- /dev/null +++ b/bkflow/bk_plugin/serializer.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" +from datetime import datetime + +from rest_framework import serializers + +from bkflow.bk_plugin.models import AuthStatus, BKPlugin, BKPluginAuthorization, logger +from bkflow.constants import ALL_SPACE, WHITE_LIST + + +class BKPluginSerializer(serializers.ModelSerializer): + class Meta: + model = BKPlugin + fields = "__all__" + + +class PluginConfigSerializer(serializers.Serializer): + white_list = serializers.ListField(required=True, child=serializers.CharField()) + + def validate_white_list(self, value): + if not value: + logger.exception(f"{WHITE_LIST}参数校验失败,{WHITE_LIST}不能为空") + raise serializers.ValidationError(f"{WHITE_LIST}不能为空") + for space_id in value: + if space_id is ALL_SPACE and len(value) > 1: + logger.exception(f"{WHITE_LIST}参数校验失败,{ALL_SPACE}不能与其他空间ID同时存在") + raise serializers.ValidationError(f"{ALL_SPACE}不能与其他空间ID同时存在") + return value + + +class BKPluginAuthSerializer(serializers.ModelSerializer): + code = serializers.CharField(read_only=True, max_length=100) + status = serializers.IntegerField(required=False) + config = PluginConfigSerializer(required=False) + status_updator = serializers.CharField(read_only=True, max_length=255, allow_blank=True) + + def validate_status(self, value): + if value not in [AuthStatus.authorized, AuthStatus.unauthorized]: + raise serializers.ValidationError(f"status must be {AuthStatus.authorized} or {AuthStatus.unauthorized}") + return value + + def update(self, instance, validated_data): + update_fields = [] + if "config" in validated_data: + update_fields.append("config") + instance.config = validated_data["config"] + if "status" in validated_data: + instance.status = validated_data["status"] + instance.status_update_time = datetime.now() + instance.status_updator = self.context.get("username", "") + update_fields.extend(["status", "status_updator", "status_update_time"]) + instance.save(update_fields=update_fields) + return instance + + class Meta: + model = BKPluginAuthorization + fields = "__all__" + + +class BKPluginQuerySerializer(serializers.Serializer): + tag = serializers.IntegerField(required=True) + space_id = serializers.IntegerField(required=True) + + +class AuthListSerializer(serializers.Serializer): + code = serializers.CharField(read_only=True, max_length=100) + name = serializers.CharField(max_length=100) + managers = serializers.CharField(max_length=255) + status = serializers.IntegerField(required=False) + config = PluginConfigSerializer(required=False) + status_updator = serializers.CharField(read_only=True, max_length=255, allow_blank=True) + status_update_time = serializers.DateTimeField(required=False) + + class Meta: + model = BKPlugin + fields = ["code", "name", "managers"] diff --git a/bkflow/bk_plugin/tasks.py b/bkflow/bk_plugin/tasks.py new file mode 100644 index 00000000..10ed165b --- /dev/null +++ b/bkflow/bk_plugin/tasks.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" +import logging + +from celery.app import shared_task +from rest_framework.exceptions import APIException + +from bkflow.bk_plugin.models import BKPlugin +from bkflow.constants import BK_PLUGIN_SYNC_NUM +from plugin_service import env +from plugin_service.conf import PLUGIN_DISTRIBUTOR_NAME +from plugin_service.plugin_client import PluginServiceApiClient + +logger = logging.getLogger("celery") + + +# 每十分钟执行一次增量同步 +@shared_task() +def sync_bk_plugins(): + plugins_dict = {} + try: + plugins_dict = fetch_newest_plugins_dict() + except APIException as e: + logger.exception(f"同步蓝鲸插件列表时失败: {e}") + BKPlugin.objects.sync_bk_plugins(plugins_dict) + + +def fetch_newest_plugins_dict(): + """通过部署环境和授权app_code过滤蓝鲸插件,获取授权给bkflow的插件列表""" + plugins_dict = {} + offset = 0 + limit = BK_PLUGIN_SYNC_NUM + while True: + result = PluginServiceApiClient.get_plugin_detail_list( + exclude_not_deployed=True, + include_addresses=0, + distributor_code_name=PLUGIN_DISTRIBUTOR_NAME, + ) + if not result["result"]: + logger.exception(result.get("message", "拉取蓝鲸插件列表失败")) + raise APIException(result.get("message", "拉取蓝鲸插件列表失败")) + plugins_dict.update( + { + plugin["plugin"]["code"]: plugin + for _, plugin in enumerate(result["data"]["plugins"]) + if plugin["deployed_statuses"][env.APIGW_ENVIRONMENT]["deployed"] + } + ) + offset = offset + limit + if result["data"]["count"] <= offset: + logger.info(f"拉取蓝鲸插件列表成功,共{len(plugins_dict.keys())}个") + break + return plugins_dict diff --git a/bkflow/bk_plugin/urls.py b/bkflow/bk_plugin/urls.py new file mode 100644 index 00000000..821d2bc6 --- /dev/null +++ b/bkflow/bk_plugin/urls.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" +from rest_framework.routers import DefaultRouter + +from bkflow.bk_plugin import views + +router = DefaultRouter() +router.register(r"manager", views.BKPluginManagerViewSet) +router.register(r"", views.BKPluginViewSet) + +urlpatterns = router.urls diff --git a/bkflow/bk_plugin/views.py b/bkflow/bk_plugin/views.py new file mode 100644 index 00000000..4d863e1f --- /dev/null +++ b/bkflow/bk_plugin/views.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" +import logging + +from drf_yasg.utils import swagger_auto_schema +from rest_framework import mixins +from rest_framework.decorators import action +from rest_framework.response import Response + +import env +from bkflow.bk_plugin.models import ( + AuthStatus, + BKPlugin, + BKPluginAuthorization, + get_default_config, +) +from bkflow.bk_plugin.permissions import BKPluginManagerPermission +from bkflow.bk_plugin.serializer import ( + AuthListSerializer, + BKPluginAuthSerializer, + BKPluginQuerySerializer, + BKPluginSerializer, +) +from bkflow.exceptions import ValidationError +from bkflow.utils.mixins import BKFLOWDefaultPagination +from bkflow.utils.permissions import AdminPermission +from bkflow.utils.views import ReadOnlyViewSet, SimpleGenericViewSet + +logger = logging.getLogger("root") + + +class BKPluginManagerViewSet(ReadOnlyViewSet, mixins.UpdateModelMixin): + queryset = BKPluginAuthorization.objects.all() + serializer_class = BKPluginAuthSerializer + pagination_class = BKFLOWDefaultPagination + permission_classes = [AdminPermission | BKPluginManagerPermission] + lookup_field = "code" + + def list(self, request, *args, **kwargs): + plugins = BKPlugin.objects.get_plugin_by_manager(request.user.username) + paged_plugins = self.pagination_class().paginate_queryset(plugins, request) + authorizations = self.get_queryset().filter(code__in=[p.code for p in paged_plugins]) + authorization_dict = {auth.code: auth for auth in authorizations} + paged_data = [ + { + "code": plugin.code, + "name": plugin.name, + "managers": plugin.managers, + **( + { + "status": authorization.status, + "config": authorization.config, + "status_updator": authorization.status_updator, + "status_update_time": authorization.status_update_time, + } + if (authorization := authorization_dict.get(plugin.code)) + else { + "status": AuthStatus.unauthorized, + "config": get_default_config(), + "status_updator": "", + "status_update_time": "", + } + ), + } + for plugin in paged_plugins + ] + serializer = AuthListSerializer(data=paged_data, many=True) + serializer.is_valid() + return Response({"result": True, "message": None, "data": {"count": len(plugins), "plugins": serializer.data}}) + + def update(self, request, *args, **kwargs): + code = kwargs["code"] + authorization, _ = self.get_queryset().get_or_create(code=code) + ser = self.get_serializer(authorization, data=request.data, partial=True) + ser.is_valid(raise_exception=True) + if "status" in ser.validated_data: + ser.context.update({"username": request.user.username}) + try: + ser.save() + except ValidationError as e: + return Response({"result": False, "data": None, "message": e.message}) + return Response({"result": True, "message": None, "data": ser.data}) + + +class BKPluginViewSet(SimpleGenericViewSet): + queryset = BKPlugin.objects.all() + serializer_class = BKPluginSerializer + pagination_class = BKFLOWDefaultPagination + permission_classes = [] + + @swagger_auto_schema(query_serializer=BKPluginQuerySerializer) + def list(self, request): + ser = BKPluginQuerySerializer(data=request.query_params) + ser.is_valid(raise_exception=True) + plugins_queryset = self.get_queryset().filter(tag=ser.validated_data["tag"]) + if env.ENABLE_BK_PLUGIN_AUTHORIZATION: + authorized_codes = BKPluginAuthorization.objects.get_codes_by_space_id(str(ser.validated_data["space_id"])) + plugins_queryset = plugins_queryset.filter(code__in=authorized_codes) + paged_data = self.pagination_class().paginate_queryset(plugins_queryset, request) + serializer = self.get_serializer(paged_data, many=True) + return Response( + {"result": True, "message": None, "data": {"count": len(plugins_queryset), "plugins": serializer.data}} + ) + + @action(detail=False, methods=["GET"], url_path="is_manager", pagination_class=None) + def is_manager(self, request): + is_manager = self.get_queryset().filter(managers__contains=request.user.username).exists() + return Response({"result": True, "message": None, "data": {"is_manager": is_manager}}) diff --git a/bkflow/constants.py b/bkflow/constants.py index 4331055a..e4ac5cdd 100644 --- a/bkflow/constants.py +++ b/bkflow/constants.py @@ -26,6 +26,9 @@ MAX_LEN_OF_TEMPLATE_NAME = 128 TEMPLATE_NODE_NAME_MAX_LENGTH = 50 USER_NAME_MAX_LENGTH = 32 +ALL_SPACE = "*" +WHITE_LIST = "white_list" +BK_PLUGIN_SYNC_NUM = 100 formatted_key_pattern = re.compile(r"^\${(.*?)}$") diff --git a/bkflow/exceptions.py b/bkflow/exceptions.py index 2b3aee41..5ebb3875 100644 --- a/bkflow/exceptions.py +++ b/bkflow/exceptions.py @@ -53,3 +53,7 @@ class APIRequestError(BKFLOWException): class APIResponseError(BKFLOWException): pass + + +class PluginUnAuthorization(BKFLOWException): + pass diff --git a/bkflow/template/serializers/template.py b/bkflow/template/serializers/template.py index 5135187c..9b20a2cb 100644 --- a/bkflow/template/serializers/template.py +++ b/bkflow/template/serializers/template.py @@ -27,6 +27,7 @@ from rest_framework import serializers from webhook.signals import event_broadcast_signal +from bkflow.bk_plugin.models import BKPluginAuthorization from bkflow.constants import ( TemplateOperationSource, TemplateOperationType, @@ -117,6 +118,17 @@ def create(self, validated_data): def update(self, instance, validated_data): # TODO: 需要校验哪些字段是不可以更新的 pipeline_tree = validated_data.pop("pipeline_tree", None) + # 检查新建任务的流程中是否有未二次授权的蓝鲸插件 + try: + exist_code_list = [ + node["component"]["data"]["plugin_code"]["value"] + for node in pipeline_tree["activities"].values() + if node["component"]["data"].get("plugin_code") + ] + BKPluginAuthorization.objects.batch_check_authorization(exist_code_list) + except Exception as e: + logger.exception("TemplateSerializer update error, err = {}".format(e)) + raise serializers.ValidationError(_("更新失败,存在未授权的蓝鲸插件,err={}".format(e))) instance.update_snapshot(pipeline_tree) instance = super(TemplateSerializer, self).update(instance, validated_data) diff --git a/bkflow/template/views/template.py b/bkflow/template/views/template.py index cc3591e0..f7a67f34 100644 --- a/bkflow/template/views/template.py +++ b/bkflow/template/views/template.py @@ -141,7 +141,6 @@ def create_task(self, request, space_id, *args, **kwargs): space_id=space_id, template_id=ser.data["template_id"] ) ) - create_task_data = dict(ser.data) create_task_data["scope_type"] = template.scope_type create_task_data["scope_value"] = template.scope_value diff --git a/bkflow/urls.py b/bkflow/urls.py index 736f583b..16a813d4 100644 --- a/bkflow/urls.py +++ b/bkflow/urls.py @@ -36,6 +36,7 @@ url(r"^api/decision_table/", include("bkflow.decision_table.urls")), url(r"^api/space/", include("bkflow.space.urls")), url(r"^api/plugin/", include("bkflow.plugin.urls")), + url(r"^api/bk_plugin/", include("bkflow.bk_plugin.urls")), url(r"^api/admin/", include("bkflow.admin.urls")), url(r"^api/permission/", include("bkflow.permission.urls")), url(r"^api/plugin_query/", include("bkflow.pipeline_plugins.query.urls")), diff --git a/env.py b/env.py index 390819b0..0c830e4e 100644 --- a/env.py +++ b/env.py @@ -158,3 +158,8 @@ # 清理任务周期 默认 5 分钟一次 CLEAN_TASK_CRONTAB = os.getenv("CLEAN_TASK_CRONTAB", "*/5 * * * *") + +# 是否开启蓝鲸插件二次授权检查 +ENABLE_BK_PLUGIN_AUTHORIZATION = os.getenv("ENABLE_BK_PLUGIN_AUTHORIZATION", False) # 暂时关闭使用 +# 蓝鲸插件同步频率,默认 10 分钟一次 +SYNC_BK_PLUGINS_CRONTAB = os.getenv("SYNC_BK_PLUGINS_INTERVAL", "*/10 * * * *") diff --git a/module_settings.py b/module_settings.py index 8d142e29..a448804a 100644 --- a/module_settings.py +++ b/module_settings.py @@ -239,6 +239,7 @@ def check_engine_admin_permission(request, *args, **kwargs): "webhook", "version_log", "bk_notice_sdk", + "bkflow.bk_plugin", ) VARIABLE_KEY_BLACKLIST = ( @@ -275,3 +276,12 @@ def check_engine_admin_permission(request, *args, **kwargs): # ban 掉 admin 权限 BLOCK_ADMIN_PERMISSION = env.BLOCK_ADMIN_PERMISSION + + # 添加定时任务 + app.conf.beat_schedule = { + # 同步蓝鲸插件任务 + "sync_bk_plugins": { + "task": "bkflow.bk_plugin.tasks.sync_bk_plugins", + "schedule": crontab(env.SYNC_BK_PLUGINS_CRONTAB), + } + } From cd7340e4c8689aa597aee94232bb5d3b4eb7b5b1 Mon Sep 17 00:00:00 2001 From: v_xugzhou <941071842@qq.com> Date: Wed, 2 Apr 2025 17:42:47 +0800 Subject: [PATCH 21/61] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E5=A4=B4TRACEPARENT=20--ignore=20#=20Reviewed,=20tran?= =?UTF-8?q?saction=20id:=2037310?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/ajax.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/api/ajax.js b/frontend/src/api/ajax.js index 67402e3e..90ece7a5 100644 --- a/frontend/src/api/ajax.js +++ b/frontend/src/api/ajax.js @@ -8,6 +8,7 @@ import i18n from '@/config/i18n/index.js'; import bus from '@/utils/bus.js'; import store from '@/store/index.js'; import { showLoginModal } from '@blueking/login-modal'; +import { generateTraceId } from '@/utils/uuid.js'; axios.defaults.baseURL = window.SITE_URL; axios.defaults.xsrfCookieName = `${window.APP_CODE}_csrftoken`; @@ -18,6 +19,7 @@ axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; axios.interceptors.request.use( (config) => { config.headers['Bkflow-TOKEN'] = store.state.token || ''; + config.headers.TRACEPARENT = generateTraceId(); return config; }, error => Promise.reject(error) From 4941414d6bb8dbf36ae9f699df779c9ecd4deca5 Mon Sep 17 00:00:00 2001 From: jackvideo <13226110808@163.com> Date: Mon, 7 Apr 2025 15:48:18 +0800 Subject: [PATCH 22/61] =?UTF-8?q?fix:=20=E8=93=9D=E9=B2=B8=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E4=BA=8C=E6=AC=A1=E6=8E=88=E6=9D=83=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E5=8A=9F=E8=83=BD=20#132=20(#156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 蓝鲸插件二次授权补充过滤功能 #132 * fix: 蓝鲸插件二次授权补充过滤功能 #132 * fix: 蓝鲸插件二次授权补充过滤功能 #132 * fix: 蓝鲸插件二次授权补充过滤功能 #132 --- bkflow/bk_plugin/models.py | 7 --- bkflow/bk_plugin/serializer.py | 30 +++++++---- bkflow/bk_plugin/views.py | 96 +++++++++++++++++++++++----------- bkflow/space/views.py | 2 - 4 files changed, 86 insertions(+), 49 deletions(-) diff --git a/bkflow/bk_plugin/models.py b/bkflow/bk_plugin/models.py index ba9f6bb9..dcff1d2b 100644 --- a/bkflow/bk_plugin/models.py +++ b/bkflow/bk_plugin/models.py @@ -75,13 +75,6 @@ def sync_bk_plugins(self, remote_plugins_dict): # 每次同步检查一次权限记录,是否需要创建新记录 self.bulk_create(to_create_plugins) - def get_plugin_by_manager(self, username): - """ - 根据用户管理员权限获取插件列表 - """ - # 仅获取该用户有管理员权限的蓝鲸插件 - return self.filter(managers__contains=username) - class BKPlugin(models.Model): """ diff --git a/bkflow/bk_plugin/serializer.py b/bkflow/bk_plugin/serializer.py index 8fc02b5f..1e6277ba 100644 --- a/bkflow/bk_plugin/serializer.py +++ b/bkflow/bk_plugin/serializer.py @@ -21,7 +21,13 @@ from rest_framework import serializers -from bkflow.bk_plugin.models import AuthStatus, BKPlugin, BKPluginAuthorization, logger +from bkflow.bk_plugin.models import ( + AuthStatus, + BKPlugin, + BKPluginAuthorization, + get_default_config, + logger, +) from bkflow.constants import ALL_SPACE, WHITE_LIST @@ -80,14 +86,20 @@ class BKPluginQuerySerializer(serializers.Serializer): class AuthListSerializer(serializers.Serializer): - code = serializers.CharField(read_only=True, max_length=100) + code = serializers.CharField(max_length=100) name = serializers.CharField(max_length=100) - managers = serializers.CharField(max_length=255) + managers = serializers.ListField(child=serializers.CharField()) + status = serializers.IntegerField(required=False, default=AuthStatus.unauthorized) + config = PluginConfigSerializer(required=False, default=get_default_config) + status_updator = serializers.CharField(max_length=255, default="") + status_update_time = serializers.DateTimeField(required=False, format="%Y-%m-%d %H:%M:%S%z", default=None) + + +class AuthListQuerySerializer(serializers.Serializer): status = serializers.IntegerField(required=False) - config = PluginConfigSerializer(required=False) - status_updator = serializers.CharField(read_only=True, max_length=255, allow_blank=True) - status_update_time = serializers.DateTimeField(required=False) + status_updator = serializers.CharField(required=False, max_length=255, allow_blank=True) - class Meta: - model = BKPlugin - fields = ["code", "name", "managers"] + def validate_status(self, value): + if value not in [AuthStatus.authorized, AuthStatus.unauthorized]: + raise serializers.ValidationError(f"status必须为 {AuthStatus.authorized} or {AuthStatus.unauthorized}") + return value diff --git a/bkflow/bk_plugin/views.py b/bkflow/bk_plugin/views.py index 4d863e1f..fb394b58 100644 --- a/bkflow/bk_plugin/views.py +++ b/bkflow/bk_plugin/views.py @@ -19,47 +19,85 @@ """ import logging +import django_filters +from django_filters.filterset import FilterSet from drf_yasg.utils import swagger_auto_schema from rest_framework import mixins from rest_framework.decorators import action from rest_framework.response import Response import env -from bkflow.bk_plugin.models import ( - AuthStatus, - BKPlugin, - BKPluginAuthorization, - get_default_config, -) +from bkflow.bk_plugin.models import BKPlugin, BKPluginAuthorization from bkflow.bk_plugin.permissions import BKPluginManagerPermission from bkflow.bk_plugin.serializer import ( + AuthListQuerySerializer, AuthListSerializer, BKPluginAuthSerializer, BKPluginQuerySerializer, BKPluginSerializer, ) from bkflow.exceptions import ValidationError -from bkflow.utils.mixins import BKFLOWDefaultPagination +from bkflow.utils.mixins import BKFLOWCommonMixin, BKFLOWDefaultPagination from bkflow.utils.permissions import AdminPermission -from bkflow.utils.views import ReadOnlyViewSet, SimpleGenericViewSet +from bkflow.utils.views import SimpleGenericViewSet logger = logging.getLogger("root") -class BKPluginManagerViewSet(ReadOnlyViewSet, mixins.UpdateModelMixin): - queryset = BKPluginAuthorization.objects.all() - serializer_class = BKPluginAuthSerializer - pagination_class = BKFLOWDefaultPagination +class BKPluginFilterSet(FilterSet): + manager = django_filters.CharFilter(field_name="managers", method="filter_by_manager") + + class Meta: + model = BKPlugin + fields = { + "code": ["exact"], + "name": ["exact", "icontains"], + } + + @staticmethod + def filter_by_manager(queryset, name, value): + return queryset.filter(managers__contains=value) + + +class BKPluginAuthFilterSet(FilterSet): + class Meta: + model = BKPluginAuthorization + fields = { + "status": ["exact"], + "status_updator": ["exact"], + } + + +class BKPluginManagerViewSet(BKFLOWCommonMixin, mixins.ListModelMixin, mixins.UpdateModelMixin): + queryset = BKPlugin.objects.all() + serializer_class = BKPluginSerializer + filterset_class = BKPluginFilterSet permission_classes = [AdminPermission | BKPluginManagerPermission] lookup_field = "code" + @swagger_auto_schema(query_serializer=AuthListQuerySerializer) def list(self, request, *args, **kwargs): - plugins = BKPlugin.objects.get_plugin_by_manager(request.user.username) - paged_plugins = self.pagination_class().paginate_queryset(plugins, request) - authorizations = self.get_queryset().filter(code__in=[p.code for p in paged_plugins]) - authorization_dict = {auth.code: auth for auth in authorizations} - paged_data = [ - { + query_serializer = AuthListQuerySerializer(data=request.query_params) + query_serializer.is_valid(raise_exception=True) + plugins = self.filter_queryset(self.get_queryset()) + filtered_plugins = plugins.filter(managers__contains=request.user.username) + filtered_authorization = BKPluginAuthFilterSet( + query_serializer.validated_data, queryset=BKPluginAuthorization.objects.all() + ).qs + authorization_dict = {auth.code: auth for auth in filtered_authorization} + result_data = [] + for plugin in filtered_plugins: + status_param = query_serializer.validated_data.get("status") + updator_param = query_serializer.validated_data.get("status_updator") + authorization = ( + authorization_dict.get(plugin.code) if authorization_dict.get(plugin.code) else BKPluginAuthorization() + ) + # 二次过滤,处理没有授权记录的情况 + if (status_param is not None and status_param != authorization.status) or ( + updator_param and updator_param != authorization.status_updator + ): + continue + data = { "code": plugin.code, "name": plugin.name, "managers": plugin.managers, @@ -70,25 +108,21 @@ def list(self, request, *args, **kwargs): "status_updator": authorization.status_updator, "status_update_time": authorization.status_update_time, } - if (authorization := authorization_dict.get(plugin.code)) - else { - "status": AuthStatus.unauthorized, - "config": get_default_config(), - "status_updator": "", - "status_update_time": "", - } ), } - for plugin in paged_plugins - ] - serializer = AuthListSerializer(data=paged_data, many=True) - serializer.is_valid() - return Response({"result": True, "message": None, "data": {"count": len(plugins), "plugins": serializer.data}}) + result_data.append(data) + + serializer = AuthListSerializer(data=result_data, many=True) + serializer.is_valid(raise_exception=True) + paged_data = self.pagination_class().paginate_queryset(serializer.validated_data, request) + return Response( + {"result": True, "message": None, "data": {"count": len(serializer.validated_data), "plugins": paged_data}} + ) def update(self, request, *args, **kwargs): code = kwargs["code"] authorization, _ = self.get_queryset().get_or_create(code=code) - ser = self.get_serializer(authorization, data=request.data, partial=True) + ser = BKPluginAuthSerializer(authorization, data=request.data, partial=True) ser.is_valid(raise_exception=True) if "status" in ser.validated_data: ser.context.update({"username": request.user.username}) diff --git a/bkflow/space/views.py b/bkflow/space/views.py index 97fe620a..2d0555ef 100644 --- a/bkflow/space/views.py +++ b/bkflow/space/views.py @@ -121,8 +121,6 @@ class SpaceViewSet(AdminModelViewSet): filter_class = SpaceFilterSet pagination_class = BKFLOWDefaultPagination permission_classes = [AdminPermission | SpaceSuperuserPermission | SpaceExemptionPermission] - filter_backends = [DjangoFilterBackend] - filter_class = SpaceFilterSet def create(self, request, *args, **kwargs): serializer = CreateSpaceSerializer(data=request.data) From 57c07a7c9e56e46c7dbb276c58994543eb78c08d Mon Sep 17 00:00:00 2001 From: ZC-A <57583928+ZC-A@users.noreply.github.com> Date: Tue, 8 Apr 2025 14:19:10 +0800 Subject: [PATCH 23/61] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E6=A8=A1=E7=89=88=E5=A4=8D=E5=88=B6=20#157=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 支持流程模版复制 #157 * feat: 支持流程模版复制 #157 * feat: 支持流程模版复制 #157 * feat: 支持流程模版复制 #157 * feat: 支持流程模版复制 #157 * feat: 支持流程模版复制 #157 --- bkflow/template/models.py | 23 +++++++++++++++++++++++ bkflow/template/serializers/template.py | 5 +++++ bkflow/template/views/template.py | 13 +++++++++++++ 3 files changed, 41 insertions(+) diff --git a/bkflow/template/models.py b/bkflow/template/models.py index 13e9b192..0535bee7 100644 --- a/bkflow/template/models.py +++ b/bkflow/template/models.py @@ -31,6 +31,27 @@ logger = logging.getLogger("root") +class TemplateManager(models.Manager): + def copy_template(self, template_id, space_id): + """ + 复制流程模版 snapshot 深拷贝复制 其他浅拷贝复制 其他关联资源如 mock 数据、决策表数据等暂不拷贝 + """ + + template = self.get(id=template_id, space_id=space_id) + # 复制逻辑 snapshot 需要深拷贝 + template.pk = None + template.name = f"{template.name} Copy" + snapshot = TemplateSnapshot.objects.get(id=template.snapshot_id) + with transaction.atomic(): + # 开启事物 确保都创建成功 + copyed_snapshot = TemplateSnapshot.create_snapshot(snapshot.data) + template.snapshot_id = copyed_snapshot.id + template.save() + copyed_snapshot.template_id = template.id + copyed_snapshot.save(update_fields=["template_id"]) + return template + + class Template(CommonModel): """ 字段说明: @@ -52,6 +73,8 @@ class Template(CommonModel): is_enabled = models.BooleanField(_("是否启用"), default=True) extra_info = models.JSONField(_("额外的扩展信息"), default=dict) + objects = TemplateManager() + class Meta: verbose_name = _("流程模板") verbose_name_plural = _("流程模板信息表") diff --git a/bkflow/template/serializers/template.py b/bkflow/template/serializers/template.py index 9b20a2cb..3bfb8cbe 100644 --- a/bkflow/template/serializers/template.py +++ b/bkflow/template/serializers/template.py @@ -240,3 +240,8 @@ class PreviewTaskTreeSerializer(serializers.Serializer): appoint_node_ids = serializers.ListSerializer( child=serializers.CharField(help_text=_("节点ID")), help_text=_("包含的节点ID列表"), default=[] ) + + +class TemplateCopySerializer(serializers.Serializer): + template_id = serializers.IntegerField(help_text=_("模板ID"), required=True) + space_id = serializers.IntegerField(help_text=_("空间ID"), required=True) diff --git a/bkflow/template/views/template.py b/bkflow/template/views/template.py index f7a67f34..e125dccc 100644 --- a/bkflow/template/views/template.py +++ b/bkflow/template/views/template.py @@ -68,6 +68,7 @@ DrawPipelineSerializer, PreviewTaskTreeSerializer, TemplateBatchDeleteSerializer, + TemplateCopySerializer, TemplateMockDataBatchCreateSerializer, TemplateMockDataListSerializer, TemplateMockDataQuerySerializer, @@ -176,6 +177,18 @@ def batch_delete(self, request, *args, **kwargs): ) return Response({"delete_num": update_num}) + @swagger_auto_schema(method="POST", operation_description="流程模版复制", request_body=TemplateCopySerializer) + @action(methods=["POST"], detail=False, url_path="template_copy") + def copy_template(self, request, *args, **kwargs): + ser = TemplateCopySerializer(data=request.data) + ser.is_valid(raise_exception=True) + space_id, template_id = ser.validated_data["space_id"], ser.validated_data["template_id"] + try: + template = Template.objects.copy_template(template_id, space_id) + except Template.DoesNotExist: + return Response(exception=True, data={"detail": f"模版不存在, space_id={space_id}, template_id={template_id}"}) + return Response(data={"template_id": template.id, "template_name": template.name}) + class TemplateViewSet(UserModelViewSet): queryset = Template.objects.filter(is_deleted=False) From 51c2364c74f823371adcd44716d60f4cdefe8447 Mon Sep 17 00:00:00 2001 From: jackvideo <13226110808@163.com> Date: Tue, 8 Apr 2025 14:39:15 +0800 Subject: [PATCH 24/61] =?UTF-8?q?fix:=20=E8=93=9D=E9=B2=B8=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E4=BA=8C=E6=AC=A1=E6=8E=88=E6=9D=83=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=88=97=E8=A1=A8=E8=BF=87=E6=BB=A4=20#132?= =?UTF-8?q?=20(#160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 蓝鲸插件二次授权补充插件列表过滤 #132 * fix: 蓝鲸插件二次授权补充插件列表过滤 #132 --- bkflow/bk_plugin/serializer.py | 5 ----- bkflow/bk_plugin/views.py | 27 +++++++++++++++++++-------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/bkflow/bk_plugin/serializer.py b/bkflow/bk_plugin/serializer.py index 1e6277ba..4cc47060 100644 --- a/bkflow/bk_plugin/serializer.py +++ b/bkflow/bk_plugin/serializer.py @@ -80,11 +80,6 @@ class Meta: fields = "__all__" -class BKPluginQuerySerializer(serializers.Serializer): - tag = serializers.IntegerField(required=True) - space_id = serializers.IntegerField(required=True) - - class AuthListSerializer(serializers.Serializer): code = serializers.CharField(max_length=100) name = serializers.CharField(max_length=100) diff --git a/bkflow/bk_plugin/views.py b/bkflow/bk_plugin/views.py index fb394b58..21672ca4 100644 --- a/bkflow/bk_plugin/views.py +++ b/bkflow/bk_plugin/views.py @@ -20,7 +20,9 @@ import logging import django_filters +from django.db.models import Q from django_filters.filterset import FilterSet +from django_filters.rest_framework.backends import DjangoFilterBackend from drf_yasg.utils import swagger_auto_schema from rest_framework import mixins from rest_framework.decorators import action @@ -33,7 +35,6 @@ AuthListQuerySerializer, AuthListSerializer, BKPluginAuthSerializer, - BKPluginQuerySerializer, BKPluginSerializer, ) from bkflow.exceptions import ValidationError @@ -46,18 +47,32 @@ class BKPluginFilterSet(FilterSet): manager = django_filters.CharFilter(field_name="managers", method="filter_by_manager") + space_id = django_filters.CharFilter(field_name="space_id", method="filter_by_space_id") + search_term = django_filters.CharFilter(field_name="search_term", method="filter_by_search_term") class Meta: model = BKPlugin fields = { "code": ["exact"], "name": ["exact", "icontains"], + "tag": ["exact"], } @staticmethod def filter_by_manager(queryset, name, value): return queryset.filter(managers__contains=value) + @staticmethod + def filter_by_space_id(queryset, name, value): + if env.ENABLE_BK_PLUGIN_AUTHORIZATION: + authorized_codes = BKPluginAuthorization.objects.get_codes_by_space_id(value) + queryset = queryset.filter(code__in=authorized_codes) + return queryset + + @staticmethod + def filter_by_search_term(queryset, name, value): + return queryset.filter((Q(name__icontains=value) | Q(code__icontains=value))) + class BKPluginAuthFilterSet(FilterSet): class Meta: @@ -137,16 +152,12 @@ class BKPluginViewSet(SimpleGenericViewSet): queryset = BKPlugin.objects.all() serializer_class = BKPluginSerializer pagination_class = BKFLOWDefaultPagination + filter_backends = [DjangoFilterBackend] + filterset_class = BKPluginFilterSet permission_classes = [] - @swagger_auto_schema(query_serializer=BKPluginQuerySerializer) def list(self, request): - ser = BKPluginQuerySerializer(data=request.query_params) - ser.is_valid(raise_exception=True) - plugins_queryset = self.get_queryset().filter(tag=ser.validated_data["tag"]) - if env.ENABLE_BK_PLUGIN_AUTHORIZATION: - authorized_codes = BKPluginAuthorization.objects.get_codes_by_space_id(str(ser.validated_data["space_id"])) - plugins_queryset = plugins_queryset.filter(code__in=authorized_codes) + plugins_queryset = self.filter_queryset(self.get_queryset()) paged_data = self.pagination_class().paginate_queryset(plugins_queryset, request) serializer = self.get_serializer(paged_data, many=True) return Response( From 9e80c13a4cfd4af6110148cc90b35a144b5c47b5 Mon Sep 17 00:00:00 2001 From: v_xugzhou <941071842@qq.com> Date: Tue, 8 Apr 2025 16:12:16 +0800 Subject: [PATCH 25/61] =?UTF-8?q?feat:=20=E8=93=9D=E9=B2=B8=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E6=8E=88=E6=9D=83=E4=BA=8C=E6=AC=A1=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=20&=20=E9=9A=94=E7=A6=BB=20--story=3D122414341=20#=20Reviewed,?= =?UTF-8?q?=20transaction=20id:=2037877?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.vue | 17 +- .../components/layout/NavigationHeadLeft.vue | 17 +- .../src/components/layout/NavigationMenu.vue | 81 +---- frontend/src/config/i18n/cn.js | 24 ++ frontend/src/config/i18n/en.js | 23 ++ frontend/src/constants/route.js | 79 +++++ frontend/src/router/index.js | 8 + frontend/src/store/index.js | 10 + frontend/src/store/modules/plugin.js | 21 ++ .../src/views/admin/Plugin/AuthorizeBtn.vue | 101 ++++++ .../src/views/admin/Plugin/ManagerTags.vue | 103 ++++++ .../src/views/admin/Plugin/RangEditDialog.vue | 186 +++++++++++ frontend/src/views/admin/Plugin/index.vue | 292 ++++++++++++++++++ .../views/admin/Space/common/TableOperate.vue | 25 +- .../NodeConfig/SelectPanel/plugin.vue | 53 ++-- 15 files changed, 915 insertions(+), 125 deletions(-) create mode 100644 frontend/src/constants/route.js create mode 100644 frontend/src/store/modules/plugin.js create mode 100644 frontend/src/views/admin/Plugin/AuthorizeBtn.vue create mode 100644 frontend/src/views/admin/Plugin/ManagerTags.vue create mode 100644 frontend/src/views/admin/Plugin/RangEditDialog.vue create mode 100644 frontend/src/views/admin/Plugin/index.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 45924c77..9ea3da3f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -102,8 +102,7 @@ created() { // 被iframe嵌套则不需要展示导航 if (window.top === window.self) { - // 判断用户是否为管理员 - this.loadSpacePermission(); + this.loadInit(); } else { this.permissionLoading = false; this.setIframe(true); @@ -147,22 +146,30 @@ ...mapActions([ 'getSpacePermission', 'getGlobalConfig', + 'getBkPluginPermission', ]), ...mapMutations([ 'setToken', 'setAdmin', 'setSpaceSuperuser', + 'setBkPluginManager', 'setAlertNotice', 'setSpaceId', 'setIframe', ]), - async loadSpacePermission() { + async loadInit() { try { this.permissionLoading = true; - const resp = await this.getSpacePermission(); - const { is_admin: isAdmin, is_space_superuser: isSpaceSuperuser } = resp.data || {}; + const [resp1, resp2] = await Promise.all([ + this.getSpacePermission(), + this.getBkPluginPermission(), + ]); + // 判断用户是否为管理员 + const { is_admin: isAdmin, is_space_superuser: isSpaceSuperuser } = resp1.data || {}; this.setAdmin(isAdmin); this.setSpaceSuperuser(isSpaceSuperuser); + // 判断用户是否为【我的插件】管理员 + this.setBkPluginManager(resp2.data.is_manager); } catch (error) { console.warn(error); } finally { diff --git a/frontend/src/components/layout/NavigationHeadLeft.vue b/frontend/src/components/layout/NavigationHeadLeft.vue index 675847d1..ccfeac87 100644 --- a/frontend/src/components/layout/NavigationHeadLeft.vue +++ b/frontend/src/components/layout/NavigationHeadLeft.vue @@ -17,6 +17,8 @@ + diff --git a/frontend/src/views/admin/Plugin/ManagerTags.vue b/frontend/src/views/admin/Plugin/ManagerTags.vue new file mode 100644 index 00000000..8dae34b7 --- /dev/null +++ b/frontend/src/views/admin/Plugin/ManagerTags.vue @@ -0,0 +1,103 @@ + + diff --git a/frontend/src/views/admin/Plugin/RangEditDialog.vue b/frontend/src/views/admin/Plugin/RangEditDialog.vue new file mode 100644 index 00000000..1c0d9eb0 --- /dev/null +++ b/frontend/src/views/admin/Plugin/RangEditDialog.vue @@ -0,0 +1,186 @@ + + diff --git a/frontend/src/views/admin/Plugin/index.vue b/frontend/src/views/admin/Plugin/index.vue new file mode 100644 index 00000000..07ba9944 --- /dev/null +++ b/frontend/src/views/admin/Plugin/index.vue @@ -0,0 +1,292 @@ + + + + +; diff --git a/frontend/src/views/admin/Space/common/TableOperate.vue b/frontend/src/views/admin/Space/common/TableOperate.vue index b04c9a91..d89ff0d8 100644 --- a/frontend/src/views/admin/Space/common/TableOperate.vue +++ b/frontend/src/views/admin/Space/common/TableOperate.vue @@ -10,7 +10,6 @@ v-model="searchSelectValue" :placeholder="placeholder" :search-list="searchList" - :disabled="!spaceId" @change="handleSearchValueChange" />
@@ -38,21 +37,15 @@ }, }, data() { + const { query } = this.$route; const { - id, - name, - creator, - executor, - updated_by, + activeTab, create_at, update_at, create_time, start_time, finish_time, - template_id, - is_enabled, - activeTab = 'template', - } = this.$route.query; + } = query; const dateInfo = { create_at, update_at, start_time, create_time, finish_time }; const searchList = [ ...this.searchList, @@ -77,18 +70,12 @@ activeTab, searchSelectValue, requestData: { - id, - name, - creator, - executor, - updated_by, + ...query, create_at: dateInfo.create_at ? dateInfo.create_at.split(',') : ['', ''], update_at: dateInfo.update_at ? dateInfo.update_at.split(',') : ['', ''], create_time: dateInfo.create_time ? dateInfo.create_time.split(',') : ['', ''], start_time: dateInfo.start_time ? dateInfo.start_time.split(',') : ['', ''], finish_time: dateInfo.finish_time ? dateInfo.finish_time.split(',') : ['', ''], - template_id, - is_enabled, }, }; }, @@ -145,7 +132,9 @@ query[key] = val; } }); - query.spaceId = this.spaceId; + if (this.spaceId) { + query.spaceId = this.spaceId; + } query.activeTab = this.activeTab; const { name } = this.$route; this.$router.replace({ name, query }); diff --git a/frontend/src/views/template/TemplateEdit/NodeConfig/SelectPanel/plugin.vue b/frontend/src/views/template/TemplateEdit/NodeConfig/SelectPanel/plugin.vue index 24a23685..73018f4f 100644 --- a/frontend/src/views/template/TemplateEdit/NodeConfig/SelectPanel/plugin.vue +++ b/frontend/src/views/template/TemplateEdit/NodeConfig/SelectPanel/plugin.vue @@ -127,7 +127,7 @@ {{ plugin.introduction || '--' }}

- {{ $t('由') + ' ' + plugin.contact + ' ' + $t('提供') }} + {{ $t('由') + ' ' + plugin.managers && plugin.managers.join(',') + ' ' + $t('提供') }}

@@ -204,9 +204,12 @@ thirdPluginGroup: [], thirdPluginTagsLoading: false, thirdPluginLoading: false, - thirdPluginPagelimit: 15, - isThirdPluginCompleteLoading: false, - thirdPluginOffset: 0, + isCompleted: false, // 第三方插件是否加载完毕 + pagination: { + current: 1, + count: 0, + limit: 15, + }, searchStr: '', bkPluginDevelopUrl: window.BK_PLUGIN_DEVELOP_URL, apiTabList: [], @@ -292,26 +295,25 @@ try { this.thirdPluginLoading = true; // 搜索时拉取全量插件列表 + const { limit, current } = this.pagination; const params = { - fetch_all: this.searchStr ? true : undefined, - limit: this.thirdPluginPagelimit, - offset: this.thirdPluginOffset, - search_term: this.searchStr || undefined, - exclude_not_deployed: true, - tag_id: this.thirdActiveGroup || undefined, + limit, + offset: (current - 1) * limit, + tag: this.thirdActiveGroup || undefined, + space_id: this.spaceId, + name__icontains: this.searchStr || undefined, }; - const resp = await this.$store.dispatch('atomForm/loadPluginServiceList', params); - const { next_offset: nextOffset, plugins, return_plugin_count: pluginCount } = resp.data; + const resp = await this.$store.dispatch('plugin/loadBkPluginList', params); + const { plugins, count } = resp.data; const searchStr = this.escapeRegExp(this.searchStr); const reg = new RegExp(searchStr, 'i'); const pluginTagIds = []; - let pluginList = plugins.map((item) => { - const pluginItem = Object.assign({}, item.plugin, item.profile); + let pluginList = plugins.map((plugin) => { if (this.searchStr !== '') { - pluginItem.highlightName = this.filterXSS(item.plugin.name).replace(reg, `${this.searchStr}`); - pluginTagIds.push(item.profile.tag || -1); + plugin.highlightName = this.filterXSS(plugin.name).replace(reg, `${this.searchStr}`); + pluginTagIds.push(plugin.tag || -1); } - return pluginItem; + return plugin; }); if (this.searchStr) { // 当第三方插件搜索时,反向映射插件分类 @@ -325,11 +327,9 @@ }); pluginList = pluginList.filter(item => this.thirdActiveGroup === (item.tag || -1)); } - this.thirdPluginOffset = pluginCount ? nextOffset : 0; + this.pagination.count = count; this.thirdPartyPlugin.push(...pluginList); - if (nextOffset === -1 || pluginCount < this.thirdPluginPagelimit) { - this.isThirdPluginCompleteLoading = true; - } + this.isCompleted = count <= current * limit; } catch (error) { console.warn(error); } finally { @@ -347,17 +347,18 @@ // 规则为容器高度除以每条的高度,考虑到后续可能需要触发容器滚动事件,在实际可容纳的条数上再增加1条 // @notice: 每个流程条目的高度需要固定,目前取的css定义的高度80px if (height > 0) { - this.thirdPluginPagelimit = Math.ceil(height / 80) + 1; + this.pagination.limit = Math.ceil(height / 80) + 1; } this.getThirdPartyPlugin(); }, // 滚动加载逻辑 handleThirdParPluginScroll(e) { - if (this.thirdPluginLoading || this.isThirdPluginCompleteLoading) { + if (this.thirdPluginLoading || this.isCompleted) { return; } const { scrollTop, clientHeight, scrollHeight } = e.target; if (scrollHeight - scrollTop - clientHeight < 10) { + this.pagination.current += 1; this.getThirdPartyPlugin(); } }, @@ -390,7 +391,7 @@ const { id = '' } = this.thirdPluginGroup[0]; this.thirdActiveGroup = val ? '' : id; this.thirdPartyPlugin = []; - this.thirdPluginOffset = 0; + this.pagination.current = 1; this.setThirdParScrollLoading(); } }, @@ -478,8 +479,8 @@ onSelectThirdGroup(val) { this.thirdActiveGroup = val; this.thirdPartyPlugin = []; - this.isThirdPluginCompleteLoading = false; - this.thirdPluginOffset = 0; + this.isCompleted = false; + this.pagination.current = 1; this.getThirdPartyPlugin(); }, async onSelectThirdPartyPlugin(plugin) { From 32cee4d4c87688a9356e47adbda4c395a04e0896 Mon Sep 17 00:00:00 2001 From: jackvideo <13226110808@163.com> Date: Tue, 8 Apr 2025 20:02:14 +0800 Subject: [PATCH 26/61] =?UTF-8?q?fix:=20=E8=93=9D=E9=B2=B8=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E4=BA=8C=E6=AC=A1=E6=8E=88=E6=9D=83=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E8=AE=B0=E5=BD=95=20#132=20(#163)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 蓝鲸插件二次授权更新授权记录 #132 * fix: 蓝鲸插件二次授权更新授权记录 #132 * fix: 蓝鲸插件二次授权更新授权记录 #132 --- bkflow/bk_plugin/models.py | 2 +- bkflow/bk_plugin/serializer.py | 8 ++++---- bkflow/bk_plugin/views.py | 10 +++++++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/bkflow/bk_plugin/models.py b/bkflow/bk_plugin/models.py index dcff1d2b..e22fcac1 100644 --- a/bkflow/bk_plugin/models.py +++ b/bkflow/bk_plugin/models.py @@ -144,7 +144,7 @@ class BKPluginAuthorization(models.Model): ) code = models.CharField(_("插件code"), db_index=True, max_length=100) - status = models.IntegerField(_("授权状态"), choices=AUTH_STATUS_CHOICES, default=AuthStatus.unauthorized) + status = models.IntegerField(_("授权状态"), choices=AUTH_STATUS_CHOICES, default=AuthStatus.unauthorized.value) status_update_time = models.DateTimeField(_("最近一次授权操作时间"), null=True, blank=True) config = models.JSONField(_("授权配置,如使用范围等"), default=get_default_config) status_updator = models.CharField(_("最近一次授权操作的人员名称"), max_length=100, blank=True, default="") diff --git a/bkflow/bk_plugin/serializer.py b/bkflow/bk_plugin/serializer.py index 4cc47060..f3f7db47 100644 --- a/bkflow/bk_plugin/serializer.py +++ b/bkflow/bk_plugin/serializer.py @@ -84,10 +84,10 @@ class AuthListSerializer(serializers.Serializer): code = serializers.CharField(max_length=100) name = serializers.CharField(max_length=100) managers = serializers.ListField(child=serializers.CharField()) - status = serializers.IntegerField(required=False, default=AuthStatus.unauthorized) + status = serializers.IntegerField(required=False, default=AuthStatus.unauthorized.value) config = PluginConfigSerializer(required=False, default=get_default_config) - status_updator = serializers.CharField(max_length=255, default="") - status_update_time = serializers.DateTimeField(required=False, format="%Y-%m-%d %H:%M:%S%z", default=None) + status_updator = serializers.CharField(max_length=255, allow_blank=True, default="") + status_update_time = serializers.DateTimeField(required=False, format="%Y-%m-%d %H:%M:%S%z", allow_null=True) class AuthListQuerySerializer(serializers.Serializer): @@ -95,6 +95,6 @@ class AuthListQuerySerializer(serializers.Serializer): status_updator = serializers.CharField(required=False, max_length=255, allow_blank=True) def validate_status(self, value): - if value not in [AuthStatus.authorized, AuthStatus.unauthorized]: + if value not in [AuthStatus.authorized.value, AuthStatus.unauthorized.value]: raise serializers.ValidationError(f"status必须为 {AuthStatus.authorized} or {AuthStatus.unauthorized}") return value diff --git a/bkflow/bk_plugin/views.py b/bkflow/bk_plugin/views.py index 21672ca4..84faae02 100644 --- a/bkflow/bk_plugin/views.py +++ b/bkflow/bk_plugin/views.py @@ -86,6 +86,8 @@ class Meta: class BKPluginManagerViewSet(BKFLOWCommonMixin, mixins.ListModelMixin, mixins.UpdateModelMixin): queryset = BKPlugin.objects.all() serializer_class = BKPluginSerializer + list_serializer_class = BKPluginAuthSerializer + partial_update_serializer_class = BKPluginAuthSerializer filterset_class = BKPluginFilterSet permission_classes = [AdminPermission | BKPluginManagerPermission] lookup_field = "code" @@ -105,7 +107,9 @@ def list(self, request, *args, **kwargs): status_param = query_serializer.validated_data.get("status") updator_param = query_serializer.validated_data.get("status_updator") authorization = ( - authorization_dict.get(plugin.code) if authorization_dict.get(plugin.code) else BKPluginAuthorization() + authorization_dict.get(plugin.code) + if authorization_dict.get(plugin.code) + else BKPluginAuthorization(code=plugin.code) ) # 二次过滤,处理没有授权记录的情况 if (status_param is not None and status_param != authorization.status) or ( @@ -136,8 +140,8 @@ def list(self, request, *args, **kwargs): def update(self, request, *args, **kwargs): code = kwargs["code"] - authorization, _ = self.get_queryset().get_or_create(code=code) - ser = BKPluginAuthSerializer(authorization, data=request.data, partial=True) + authorization, _ = BKPluginAuthorization.objects.get_or_create(code=code) + ser = self.get_serializer(authorization, data=request.data, partial=True) ser.is_valid(raise_exception=True) if "status" in ser.validated_data: ser.context.update({"username": request.user.username}) From aa405cbd074eb8422d0c99168e969422be0bfde6 Mon Sep 17 00:00:00 2001 From: jackvideo <13226110808@163.com> Date: Wed, 9 Apr 2025 11:53:06 +0800 Subject: [PATCH 27/61] =?UTF-8?q?fix:=20=E8=A1=A5=E5=85=85=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E6=A3=80=E6=9F=A5=E5=A4=B1=E8=B4=A5=E6=8A=A5=E9=94=99?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=20#132?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bkflow/template/serializers/template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bkflow/template/serializers/template.py b/bkflow/template/serializers/template.py index 3bfb8cbe..ce9bf232 100644 --- a/bkflow/template/serializers/template.py +++ b/bkflow/template/serializers/template.py @@ -128,7 +128,7 @@ def update(self, instance, validated_data): BKPluginAuthorization.objects.batch_check_authorization(exist_code_list) except Exception as e: logger.exception("TemplateSerializer update error, err = {}".format(e)) - raise serializers.ValidationError(_("更新失败,存在未授权的蓝鲸插件,err={}".format(e))) + raise serializers.ValidationError(detail={"msg": ("更新失败,{}".format(e))}) instance.update_snapshot(pipeline_tree) instance = super(TemplateSerializer, self).update(instance, validated_data) From 90bb707f97daa40edb0074bf552df42d07e96fec Mon Sep 17 00:00:00 2001 From: jackvideo <13226110808@163.com> Date: Wed, 9 Apr 2025 13:05:07 +0800 Subject: [PATCH 28/61] =?UTF-8?q?feat:=20=E7=A9=BA=E9=97=B4=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=96=87=E6=A1=A3=E8=A1=A5=E5=85=85=20#159=20(#161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 空间配置文档补充 #159 * feat: 空间配置文档补充 #159 --- README.md | 2 + docs/guide/space_config.md | 210 ++++++++++++++++++++++++++++++++ docs/pics/hide_display.png | Bin 0 -> 36680 bytes docs/pics/horizontal_mode.png | Bin 0 -> 12932 bytes docs/pics/only_show_display.png | Bin 0 -> 17047 bytes docs/pics/vertical_mode.png | Bin 0 -> 15115 bytes 6 files changed, 212 insertions(+) create mode 100644 docs/guide/space_config.md create mode 100644 docs/pics/hide_display.png create mode 100644 docs/pics/horizontal_mode.png create mode 100644 docs/pics/only_show_display.png create mode 100644 docs/pics/vertical_mode.png diff --git a/README.md b/README.md index 570c209f..6dbf195c 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ BKFlow 提供三大核心功能服务: - [服务接入方式](./docs/guide/system_access.md) - [体验 & 快速接入](./docs/guide/quick_start.md) - [接入系统业务拓展](./docs/guide/system_extensions.md) +- [空间配置说明](./docs/guide/space_config.md) + # Support - [源码](https://github.com/TencentBlueKing/bkflow/tree/master) diff --git a/docs/guide/space_config.md b/docs/guide/space_config.md new file mode 100644 index 00000000..b8a84110 --- /dev/null +++ b/docs/guide/space_config.md @@ -0,0 +1,210 @@ +# 空间配置说明 +## superusers +**字段类型:** JSON + +**字段含义:** 空间的管理员 + +**默认值:** ["{ 空间创建者 }"] + +配置方式: +在列表中添加空间管理员用户名。 +```json +["admin1","admin2"] +``` +**功能表现:** 对应用户获得空间管理员权限 +## token_expiration +**字段类型:** TEXT + +**字段含义:** 该空间下访问资源权限的token的过期时间 + +**默认值:** 1h + +**配置方式:** + +| 示例值 | 描述 | +|------|-----------| +| [n]m | m->minute | +| [n]h | h->hour | +| [n]d | d->day | +**说明:** 至少为1h。 + +**功能表现:** 该空间下访问资源权限的token的过期时间被修改为对应值 + +## token_auto_renewal +**字段类型:** TEXT + +**字段含义:** 是否开启Token自动续期,token会在用户操作的过程中自动续期,避免用户操作过程中token失效的问题 + +**默认值:** true + +**配置方式:** + +| 可选值 | 描述 | +|-------|--------| +| true | 开启自动续期 | +| false | 不开启,到期后token过期 | + +**功能表现:** 开启后token到期会自动延长一个周期 + +## callback_hooks +**字段类型:** JSON + +**字段含义:** 回调url配置,请优先使用 apply_webhook_configs 进行回调配置 + +**配置方式:** +``` json +{ + "url": "{callback_url}", + "callback_types": [ + "template" + ] +} +``` +| 字段 | 类型 | 描述 | +|----------------|----------|------------------| +| callback_url | string | 回调所用的来自apigw的url | +| callback_types | string[] | 资源类型列表 | +**说明:** callback_url必须为来自apigw的url,callback_types为资源类型 + +**功能表现:** 在对应事件触发时回调该url +## uniform_api +**字段类型:** JSON + +**字段含义:** 统一API插件配置 + +**配置方式:** +``` json +{ + "api": { + "{api_key}": { + "meta_apis": "{meta_apis url}", + "api_categories": "{api_categories url}", + "display_name": "{display_name}", + } + } +} +``` + +| 名称 | 类型 | 说明 | +|--------------------|----------|------------------| +| api_key | string | API插件的Key值 | +| meta_apis url | string | 获取API接口元数据列表的url | +| api_categories url | string | 获取API接口分类列表的url | +| display_name | string | API插件的展示名称 | +**说明:** API插件具体开发可参考:[API插件开发](api_plugin.md) + +**功能表现:** +```json +{ + "api": { + "api_key": { + "meta_apis": "https://xxx.com/xxx/uniform_api_list/", + "display_name": "API插件", + "api_categories": "https://xxx.com/xxx/uniform_api_category_list/" + } + } +} +``` +(配置了名为API1的插件) + +![uniform_api_selection](../pics/uniform_api_selection.png) + +## canvas_mode +**字段类型:** TEXT + +**字段含义:** 画布的呈现模式,默认为horizontal。 + +**配置方式:** + +| 可选值 | 描述 | +|------------|------| +| horizontal | 水平模式 | +| vertical | 垂直模式 | +**说明:** 修改画布模式后,新建流程才会生效,不会影响原有流程 + +**功能表现:** +horizontal: + +![水平模式](../pics/horizontal_mode.png) +vertical: + +![垂直模式](../pics/vertical_mode.png) +## gateway_expression +**字段类型:** TEXT + +**字段含义:** 网关的分支条件中表达式的语法类型。 + +**默认值:** boolrule + +**配置方式:** + +| 可选值 | 描述 | +|----------|------------| +| boolrule | boolrule语法 | +| FEEL | FEEL语法 | +**说明:** 详情参考各语法文档:[boolrule](https://boolrule.readthedocs.io/en/latest/expressions.html#basic-comparison-operators) [FEEL](https://github.com/TencentBlueKing/bkflow-feel/blob/main/docs/grammer.md) + +**功能表现:** + +## api_gateway_credential_name +**字段类型:** TEXT + +**字段含义:** API_GATEWAY使用的凭证名称 + +**配置方式:** 输入API_GATEWAY使用的凭证名称 + +**功能表现:** 空间将会使用该凭证进行验证 + +## space_plugin_config +**字段类型:** JSON + +**字段含义:** 空间插件配置,可以设置 只显示/隐藏插件,仅提供画布编辑,与实际执行权限无关。 + +**配置方式:** +``` json +{ + "default": { + "mode": "{allow_list/deny_list}", + "plugin_codes": [ + "plugin_1", + "plugin_2" + ] + } +} +``` + +| 键名称 | 类型 | 示例值 | 说明 | +|--------------|------------|----------------------------|--------------------| +| mode | string | allow_list / deny_list | 只显示插件的列表 或 隐藏插件列表 | +| plugin_codes | string[] | ["display", "bk_example"] | 插件code列表 | + +**功能表现:** + +①隐藏“消息展示”插件: +```json +{ + "default": { + "mode": "deny_list", + "plugin_codes": [ + "display" + ] + } +} +``` +![hide display plugin](../pics/hide_display.png) +选择插件页面,消息展示消失。 + +②只显示“消息展示”插件: +```json +{ + "default": { + "mode": "allow_list", + "plugin_codes": [ + "display" + ] + } +} +``` +![hide display plugin](../pics/only_show_display.png) + + diff --git a/docs/pics/hide_display.png b/docs/pics/hide_display.png new file mode 100644 index 0000000000000000000000000000000000000000..8cca8dbb7aa5e8797fd254cc6be536cae1ea9f06 GIT binary patch literal 36680 zcmd?RXH=70v@Vk=9+7+wdQ>0Gv8;H z=2ry{NgM)!KmymV{b2g$n-@RWj%52x^Rag!p>ND5ELNws>kP&HZ^{4y0RK{UatvK% z-hzL9IrjIT?}vI{I?13|EdWbOzBF^E3aMtFo|3$p4^&G zb9y58tCRD^VWIf*j`eFQ5{H*G|LzbDUi~uEYBXv%r zza=?7v+^qP(b>Oc=aeuulKNW?SEEwir=`5V`sepN1`hhS+f=y|Y5xv;i|emjf6s$k zBME*n=~wGExQX!LtN-*qc$Q=C|8M8TvQcwK-QsjLd!1pWZn&?W7(_HZ0J;_I_E=TT zon^ri-5=)*_x_0RxbH<>#)cM+a;`)Do|ts)UB{8_1zGBcwI1z7F@2p z*QoShaH;Di&b4<;l2zHgdhtQ8DMWm8@SNMCulbquUaiI2OS6pqo(E%o3BCJgybG6S z@d|tTeP6F^lB`nJRSyP2hE`;qRP@r81^vhdj0^;dKC-VfqsLAS_j=!Gmd&&Dj@pU^ zpmxZ&HlWN^EeSo8GmU8M=PO(s+fctt`mi@yPp@4=@zNQ!o#|Og{aab{qF_vsaC%Js zn(yBVj~4s8ODTQFz1A3=3YA?-iI%^xrH_`>Gg(ujj~S<=**uDo=J=rZ4RVMk)SpC& znQKVUN@kSmoLEJ$Ak6j^Uzd zn5Z7XkzfS_Y4p`(_(1*f&2QMwbei#Qf{%8}WN=9+zG$g?z7yx?a_9IeZKii_)xVm! z1*jY^*CtE1{gyF@S}Jyspuj=EpM zf1H zas`C0D9{_TjI7d+5jx;O)zl;oWLC|(I&Yv9vJg(nQk&~TI&~4m**mw2DSlhI!3*Dz z9c}CiznI?bw6qh{T4l$G19b=9TulNA%;44g?9Xy?Kh!%0MxLWDl}jLxfp-~x%e%zs zou1h$Zqo-J>-^hag>~Wxq&v|<1rdAyND*DhdpL&4rb;s&=5W{{T=@DI(Lp@hta?5% zRgttZBo7|?GI)J(W9AYy7(TPYZUJMQ(Bm%Ir)wI0aFZcoG3C6LG6gTIF1Vh^p4NOT zix155>8l>(Cpw%P-VMg^ zHJurB7@E$chOW`5sr$QLv5s@5bueh&B(Q6`Z+ta|kMfnc=+YBVpd;OXVFJZ|>gDIh z_YG9VGdLCA7?@vx7UJ441xr>r)_4$K7pii+`tSB0>B#z7r4l@0;wEh1`IH22xy~&AofsY>t&DC?ZRzj-b=BPdFCd4ZdJ=~ z_3pvfi2aLjtF=&(L#6(cXh9SzAv7pp-p;4msYO(3{s)rU;ierheJF_s>Ib>vWVuXarK%!$tZ?>gYp!+MTDg! zACe4jnf?70fx7;IbZ1NyoXZL*3ZdUzkaMNKATe&dtLKfJnPyy9+F!!xBI#=tW+SYy zs@7DfwC**m4a{sFq1i+w4p(u7-7MJ2dL!4SDvU3(i%_i@7%Sl1ibMb=1<3?+rp7?` z{;Xq--^)ugW?(SG#M}>vWH_hUP>7BDgq%@A$a1QjkviYr(zM0?NWbPCOZ^FocwVi* zLQ|#qWw6DqHlLW?Md2bxjc*sT=UI72sSJM^Z96?%+V@{AY-wxY;pe(F~;Uef8jt31slw>ksKbb z%3|z$0)uFLe2~Rdi?-e-zKdd65(rY|!?hJ9JZN_seqnIbfh)HD^b`bQSE?Ey>$%xIu&1ALX=;0u z6{z9#j<|LPxB*D@$%JQ&&HZjjPPGW4OZD2$Bi< zwy%iuUVfk0s9BXqo%p@PsBYb*EFeT=N zBQOeVTcEF2&WMWTwzBd17uOXz@S5Nk zC61g;DCMn_P%EzcuYV1l1kYfk?pQC?osGyOAYC0JQSlD8lh_kzLZi|nBsnxP6N4lW zk1RnXEdAb0$<5#wJTXMGO;lH{$-XWJ*^Qj@h4+&|k_5r^p9_`7oLj*`$?!bwfCK4X z=P@C)_=!pOqWTgaW3pQ!ufNM(>>5H0Y_9-*6<=;6TwKS5>2_s9(zF+~WkrQPz%*Ys z#WR;PJrD00s+V*}?A=>W|1LNhKQteG6JjVVtYh@x(if`)P;9RJ`igs`@mG{=3x zjEb@dl;tSa_(g^|Nxfxws6}sP@8-UU(M7uF*A#NQFn)~b4j*R2g9)u_(l&Khi)Si;c`0>TwXY?}xdUxvNrqI-FqCqR6KYodD0DdIso^Aw@= zmo|zVI=TU8Sj`b@c!g$^i~B%|Hs+7! zqb`SB)0vB?ja3O(oF8j@dreD3I3nK%WMjGKAN|QWOSmCR^v+rtxCwo^u>rU^lHtgH zV$ncLS&@q4*=}g60=Y?y5z8?6b;ndspXK;Ni4hU{m)%uWJ1)SZw2BP)Ru6bL>a>Md ziX5Lr;b;RmH+v4#wQ;l}8gO2G#h)kcuWa;t-awvehmGk1ffEwV?0caN&I$L~f*^;i zHUnZysL`iuuhB_y!>2iDbtY|J9t_c%Ogo0E2Nr&ApKWrQ&2yej;X*^8rhD5^yusmL zY6D!WYr&OuBl(8JBm7^WC^YvSKR>&No+g(y9aNA9PQx9KlVYUjyZZ9leU^R5)Ky$N zttqrU^O(LZ*V(nOS!h9g`I#h8mFi3(xTx+c@#OdEBugwCjW-2#RV4+y^y)7=;9zZ} zL`Sbx@ZN#e&^Lm2mNqu9g$?qt7XEl9({HRxzYCI@5+GS8$CBA!%QpzsWcaxsLvklf zff<+Z(&s&f)WV{ShDV`6JPZf*wlegg2|ov=jtU%~cOM5jy?+i3cqH1C#N^$K=al`x?_o%@!z9KqXLI{zOVeF!@u+X2K*N+n*{p%6Yp=Qt^$zE zKj;d-)Bk6@<-f&2n`|!hHo;347r!hfU8YSsnyX<#aWSUSb~nA$-e(+TG;7h{9vKat-5f1O zJ51S?{k6QyUFShz&CouQa1Y!UR|a}Bm2pf$^2$W5ZloDgZ-HvJ;YK9BC?oFV0Y?!$ z7f|yIrTa1Gn4Q>;qc;FsX^_m67Pu#WM*S`vxcXQvAl3~)tRyz^VqJQg1KC$SBsUr2 zU3PUW9p(|zD;f^Dnw7CH$l4`P^h&rB6LH0Ug}+J>85 z5;q%@@7n9;OrflH&f2i7%w3)q(zcpO8`phGyYn1AcLRQPTK_{vLci6e!1Wq&&FI*n z`LYry^z+pe`=;~ncweHo|z!aKT`p1havNJPHSv_1?7ba+9)rk&Md?BOb(-)5-jM(%z6t$kBdwSbmKJ3VdF{5bC-fA;F$Zu-s7bs+tzhojG1nrB z=s`ujTB& z2Nq$@6A+~pM~TohDNU6~C*yXe6QZa*%$d_WVsQFskcS?r2?j&wT~2@;wEbsbO&{%J zesx2eFG=QL>W=@P#Xs8(SlyRZ4=@x=Gpo<(vFI-c#AG5$ktcx72lw70%gqqaNN#qa zz6S9O3N=HT2|yHP!pKnM_~F>$wK)|row=U*DDH8bxb)I9izJj%?i#|)QxVhO#^4@E z#{?L%mrCdC`H?0?IS}srrsI9`mZ4q=W!oZqZZ>(4t6BOdPaFCX{*26m9}TPMRr)-A za9qgKCwYze7I(l~B&J9dG018Ng zMarfy-g>T6Oo}R$?=(p|7tUsZ0fewiR6I0b=`C7--u+ffwRNj}Q*r-D@nSad4ti6| zZr+hztx%wqNf{hnT@nK~b+KOdjTw(DuLij=n<8W0XWw0?-6FXHN)YYC0Ts0#>@2H3d`aC5OJ|fztHjG+1;EE1DK~O3xj2{05GojeRn&p zqaGouoa&d{!JYw>YFPlb=W=S5k;P9rPM-#1zI^|Kl#7;Tc#q< zJTr0*+;{xBgBTw^OvizDa523o5$Av_Js<|VWMGhNgx5TH=Uh#MmtrG#wu2MGX1t2g zdOmRURWuC!af9I*)XK8tn(*3{h^(mE@601WwdHMCtq}p))^f^7oYDT>HBTjZ@L^i? z%8r-|-#9d|_Ra}FzG00M$?$-PP979n&!Ab*;5wkfh@TJDtg(`tLpp9+(#hRWeOO+} zfxYrj{RmhF`6F-&O2j%U>V9%9@?WlqkH&3B3KyFSZta}aMRV^#d^HzcL$+d_AFuZu z+@MuW$hl5ECe;Rn8xPc-<@jjEd0jVf)S2UjXB)0432tb>uv&M~%r=PqerFQ6>~XPm zkx}RA)h1TLGt-CGr;t?Fb_2q&1?P699ilS{9g=x4bhuU{;V)cF zWA19tYSPF;PrB(lPw7FL=r&~zmtaxVPdgzVCP>C5Z?gpA057KA7%pl&N@HtXYPOC~ zmn#lf#Ur~@WYm8vV_j?;p6Pif=Ii3avt)g0l5T;J3bi3b)?4tR3e|@F;bZ- zYW3kSWey36yx!5}kcU2N5i58%lNRmQfs!VY${$Ih;FpGb4WuxnNkSP;c&ghl_rgfC z=3dK;k0aLv5j&WbEGBmBKc@v8q^m*$7Zz7LhiEYEavw|S=^HJ@Xydl0Wynqggsw5X zkJv0W4Q_|t#BRY*hRrgVOu3o4j6n8Pk0%3TO&n9*qF|jfyS7*l)K8-=DIkSLl-Sz? z0H^`RGw$UKQg}3L)4T7b1!hLJUQt;&D=F=^pLzbpW!dVdfIy^{DSKY6rgB(M=4$&k z9hh&GpI+w``2`QT_08WtJ0) zH;`g2F!tWLx&5zF%QtXLi-PwwA-iHI#`&Wh)m|Z8PivwVQu9S`yEi$p zTc!RYihh1WlLN1Nc*2XN^Vl?bZ}|nsMl$@yugLLvIoO)Zv%MJr*6Wv}V@(srmoQK3 z^)D(p47KV4NX;7npmP*qEqOx zBA;25U;1-$dLW}Fy~I@Fc{L%1D#o4YpmF}XCIHl)IVx|iw5OM)l3~x>lyTO%OG6-3 zU9#urIx}Zp97Lz*H5S3dUn!T=ZAJUyA1O==-%u6x6!YFn24z9G-rhgXV11tnPJfef zeCP6t{w~fh^!k26&GIY!EXSv(Mg3d2Zp~t9fHO93B%7UoWOZ|@WPM{z6pT=Lubc=D zc_wG%BS}}H;~IRjX|}@a^BZ%tp#oAc_nRK)J8LWU6o;%BZUC))w-s*86noM-8({vX zlVf!NQO9acOG_&uv;6RA?k!v4ngC*Z3Gk?={J#5Yc^#w5@ky>#*O?VxJ3C`=9cp^< zJd)vkC@vxiWVVUrf8_8}RyJ`!`8L-VKE70Oq+WAfn+HvlYz=^p#tGq)eP;uc;m>?J zXyi<>{p6jKMskVGry~98xd7CygAYSfCE2&`&Qv)55Qy>n7%#|P!7`pG(ySiq(edIW4n=EI9IygEr9*4*QrHZ%<_|ydO^g8GT z_&9Ivc_+ge`zLEgD8ncy8KixtzdITJhW0~6l(Y$0)SZ*XlsC1W5UW;}dk)(QYM$_O z_GFjnwA-)-`xKE~Q?{OOn$_XOTG$8oy?njXM8N)F*ZKc=8W+BWG2W4uMmnhhQ%W@|)IT)(!W0?K{wk5NIHz(uF)7+&Y|Pd7c7nYVcC!|7>R zRkuF&(Gb?Syiv!Xg;LMLJH6+&{aYVqXABg&WBS z3^aPOm83T&n3`8Qpt0jZv-8KmoLAl4>T>iw;(Qlk1oX7y*lsoY%hllhI9yES6Bq3x zWu;kq&kmJe$0)cMG#quntv~;g2XOF5YgAQrVkwn>4R%COp)EU9(oBn;=>SUoph5nH z5n}#ZE8+EiG$oHprQTU#nPyd`0I|=x6-w#f=l)iilNeYU)!{>0-3aA*7!1ISceMS- zn1A7?lHfs=9ZWWsM4vAc<@+Fi49vtKeOB9xSyk2XwbI9?Nc{B15Fm>nF9Obk;T^X0 z^3D`W9O2h;W)<*6%c+L9JT}E{qWLKL+r5?}_*kuai%W_ZLp{KZSZ6dObpS)xnSc}} zRF2+@AA6*^Z|u)+Hv-tOC$XsIsNhqWn$F&oj41@tBuazK1S?%-<|g*?d3PXRYEgBi27-+l^v_=YSt9y z8XyM7A|kl>IHvrXbRW74yejS7nU3>F5x*22yACNxL=C`zP@}wb{xSR2L$ka%!0Xji zJpKxoBXR{2r&K?{JO;*V?%RfaMx~tVaPC}tt=TYWV*1bGGxM$0{!d5&vMqX3D)qdU zf7YT~o0^->#tN!)>}nS%%l@1rw-;}hDya9>&WEv74qP{emt!e7*q0SN^2{9}qsf+- zqQ;5fuQB1*a4T{88P3j#yV94Uo6nzs`F}YdLS4DEL^x$FJR{V$dLowCn!s99Yf9;W z6}lg709Y+nvjBQ<`@y$S`}UB((*^cplq9%xMM_klZT3sCy{d&VXWjsmc36v=^_p?Q z1ce$Q@KJ}^l6na?nZ27?*mryB(L#=vTNgr5l`p$S$LYI8mTH5IXmiV7**k`vvZ_}f zw86{|dPu(dIc(Rpw|E7h0d1g8p`jJzOOVHtc7!UzpAYLi2J*)1=g2>xxpIiapT`7h z7<_#6o)?!Jf&hEfOQ^=?x3SK z-F;;<4gfJepa0c6${FBYye6*r72wIASH=9S&$(3Dv}ks^|GSW(;2dIMe=SDe10mr4 z2T(ENAMo{y@GqYR1PXriD>w$hrC$Gy-=4Voe`S;C54`l3vyZl%e(8yDMc8Qf!QUS@ z>yB3B_C0&~O+2Kenv6-LX`{rm9EG7P2!xCN>) zOY;@EO;jwg1{2T(V;e&@cbg3m1LR+SAk78XrR^xFOA(gx63ezv`D(1wBFQJ95Y_>H?Q(xB4jJ#J$pzakwIXf^<`EnFdH%KQc^qy=0aTS{<~9I z{d6pbcJbda2FSDxGHs&l?{z-`aK(Pj=Kqp)ZxD)+OtFvol0Cn)r~Kz)eV}Za?U{bb zy4Zlo6mMy7E%yz7H(Qx?)Rxe0ek(4ZH(nlQsHVpv)HhA8hFdKLt(vxk8plu4zGT2Oh7@0Z3_=u@PWzik$DmK&9*anwkF_^;p zy?y4e^tC9zTah=Rp2?xPZ8a}2_NvO5 z;oe)#Z}e!!4S<3=ao>zRXqDS@9i%A9^hXO<8{d)|qDyyQ{Ke`6FzP9H`>nDup3;H) zRC$=ssz!3B424j}8ec<)VE4UU8CpCI9j7V{r27UjW;t3td$X@A`qqWrm}a-pF|_I& z9jJMrlypE>Z{zI;yWK_H1L6$U{gBN?Gj>gTPyoKcOGihix5}{~F*FoU+ND&v#wbiJ zRn1az>>x4S8rnsgfD@&yti0ej$0w$edsRl#*S(+Bi*J0no}R73hjEZx5!Rv*Fa)I_$u>t3-ZYmDivubPj`QX!gBZ*~}sIUfzAj_(eQ`bHSS&@Vz2t^gE_ z@^J?4+k>xbbn>eU0j3)vK3|cNEa7<#T>BbhI*DE{Suk710V8e)pxZZ7a~N(xTOn_s zUJ9??^Fb2%VWP%$^6y`}O)b%jvRKPhwt*m#21&nAj?`~$u@&k+l)f!SkqBW0Z#5)+ zD)sP5W=FjF*%jCI65E9W8^KT^*lAbSN~h+Dxq#{$9!skoYBJIHpNj(*=u70|R5?JQM@8ZKfb&DhY|5x3A0cl3;f5R35@e}HBBSM1LA3s=m3 z4E^!G<@I9dVjQasa}e|#%zV##_BO}F)*s@7lE!eC&t-MYH7Yw-w9DMb&OHtio9|s} zDi{lJ213mn`r&=5XC9@W5dnvZy{Kc_e6rB2J7V*QPN^M#C7Ivz`~YxP@-s2Q&M=BB z!YMT6HRQ2j<$(S?i**C$Q_D>4+UtuM3MtI@=?$aYANa-`3h$$ns!w{9%%`}>5UE|- zorWG|Z*Q(ovZ3#7u9K~}K_o9Au)Kiamvqs(0*O}FYM`PRlPFAN&-L<((%AiGBcFf!9v=|(7{ zUR;Bo;feuZ$=r>j<+-;?yTv-8(!dsRtDvRJf{YM9w2h6*j>j1-kw=K^aa>FDZdmBe9#Qa7+iqFd zPUU(KhL>LX=LXZstZHb$$?MO*5^>3}Ck`iSou=tptr+fw)8^N>w(KX57-#X&y_Gq9 zHQ%hRSAXrslUDM?Cg8v0U@$UJ=6TQx`m- zzuXgjh#y1}8vj7B)VS74Jm*~h4VH69c#v>PV9-x^JuPCg;|YKS%Ge1Hj)ux-ms3nU zs0f#qMJY4^z~#TpkhdkZI@2C_$;a0lvc7M&KR95azj<#c=7YRxrY#-z0tA;CQ%!<< zn}YH_^}wxG$fuP>-;&44jwv}GRi+RUs!;J&M8<@2g!{wt_Z5AjVAjBJft*n?*A(r2 zyZ;gAD=yyaJL7ZT$t+C}E8H$perk=etKEe+Z`G@HT@)H(IgIu%0Z`46xU7Z)uM+cBQc?Upf?h-*`3%2FrUxULnZ2GIwQr*9eUF zN&SVmrQ3=ef--vd9=B+9_~KfXY`I>Q%ul*i9;5p&IBd?D24u#jjVlbcx^Z9Uty)YArWeZ6H=*l~C^1$~Wy~zG-*7ADb zx>Mi1$7`+-&YSvIgAf7OoVzX6-2Nr%vrVGV&5iX9s~y%`wTd36Vb5d`>;%3*u1)%! zd`o$M344?7DXTnP;W*%>(+FJ=QlG~blgU@zyJ6kY=8ZX^akr2T=Is-woPW186P_6^ zIEL@hz$b3JSK!z}2lu21-` z_b>Z6=80eO&Y!G3xn~$m-HkP?LZ!**3hQZkuJ^Rl^0e5;R!|WEJ2A8-&p;vtuc z+~&(Ac-+7ulC*6-H)PP!ZuIYOOqI2*W`?uh=VoPBvlH3J6ClZT1iU=UPw#260Y@Vp)DjH=F<(_F6~ zGP;@IT(BBOv#8@Ad<8979(U$CN09Gp9T)L9f3}HP0GRcR$~RkcWwJ2Ioa19;#72Fv z0@9%spb$0OzO~}EkOV4dVQ)m=1vfN#16B%7%58l*3V>m)l@D2 zl!$HO3@Df!CmdBwDd3C_pdvr9E(0MesFsheu#2lx1aQwl%g!f+t3^SYhEEKxGQ{;* z&oIc3;{CZt96rC+#cOhhYNuGKt8Ix-F~bX~C=R3Wf=eMPck(Sb?NDuP$G!tF3k7(I}$c#V)(=q!MKMd<5oQeO#E8PcNhbzB7_2f_C~wl$VER5|2df%n8$rW!U@;l zabL4#w>@VlWLS}NrFf!1&>7%Eas;k1#x>bxKdE!1sHO+S{n6~ake+s{oo=CyrE?A1 z=Mh?E{wT_3k1zmLPAL`EO*vN|GnrNSQL4Kq`1wjn# zh1MUfwC0;0Y~1#qbJji>4c&cAr{0LuR*s#9q}^-{Ey=G~GDhGCj*R6xRQK5kCgz(U zx=5u51phDsf-bxdTdX}(D8yZ<2o8030(1=!%|$j@JhGzPXq{x(szYd=Sb-O0gOF?E zMl1sO&@GQ)8^b^b7(0K)+$<%Do6$;)jtQL&lI|`WV-vv8cXCEqr_bhY6dw>>rG@JP zn8y7p@&Gz?#OP9NK;~4o$VPWqxecefKwGEm(~4W%DGo9+WHwqGlx1ZMuclFnG4D<} zBbdaI&wdVkMhQ33h$IfP?r|)@-d-b%g8eVebvZvrC(W#@#DT{(luNcal@0B_B9B0k!u^HR5tCQt^XVcIpiXQ+YsTXf#>f1V^)$-C3 zA>{hFIZ<$#ClrLXD3hBh4cq8O*O?e;+Q@iT!4XZ|KR#zYLLf{J;s^TK(!03AT(N2^ z#K0e?e;=M0ZRsmfS-oau3gDk>O@DN*v6mO=RDcQ1)V;L@jd**iw!0?F+NLKiQ>T(B zz58L+hnxbt6tR|xG35l^@7qc@6ySbmUEY7lzhdcqJ#UScKVFLegC;M)Jiipz+%H9r zHqRZ}rrQP!*L?hbFP>`qlo|~sfKN!_b+Ju^(lCM0a0u?UjOTCdfGPY09I0oui0c5) zY4Z6yxnie-#Q+0xrTBA8;Zz^<6?hDQwo-niG(c~1$~$)zXRK;|8T&@bO2*O^hF==P z-9=6ToW>h`c7Qz&E!iuE%SH@VCeCkNVzK7wVL#(E&_>u#IAVqYr$B6&34Yo{Rl%VT zqJCxT63+NXSDct%L_jAASVm)?L-bm8kna|)%wEU+qC z7Kt`hHgOY6y?A+Wb$Y`E$W90 zA3rYt{oss4>%FkMZ6DfEk}kunXuXL1(F*MCYLrD%^-?{R~Ep=>Nc1 zT~7Yp_A@JB$BuSL>2HV1HB-g$c8Gf)OD~&+2;=@Xb0%+Fepxx-@kCDQz9$KDV2@tLczcW8x*u5Nk1?%&E9&8LxtDHP?-F|EJ0szi@ zdER>r9H7HlZv_~Ktv(hgit@O+D()%}8di@7u;3QkD&PnNkUw*Ac9Hl-Cw{8Bo99ig zbg=@ttijmm-J6y>#nsYSD$Vv0r@Lg8C8AqN1gx{^SBex^nV%w-*&k8V-iq>E8m+xe)012(<0yzpCp;WA_(N3+g(IrDE0kOc%BC-kMb|p}C0>IvIh)0a6z(u50 ztMoO&T|7}@Uwau?fI%eD)WuAWvF>}hkJOeW!EY2{AY7jB_s-;fMrb-iP(h!<$F2_O z1{p**i&Bs9mJMqzFOaNwVx+3BKL;|1idDkJ53gEpg^51|B6SbTH8Jq6@2A1Sn=X?2 z)fe94zcH?VDtrWOoeoi6+4OckIU}TAx-=e)yUf_})B*!akkC`5|HO5iupa_Zai@Ds z7r$0O&aN*sN;o{-T>T=We5-hB2)o=0tOr!M~4 z&CSD!9deV;rc%HA^t3uQ)}L>a-p`xg-QV`>%gu=Z;B0bksULDysuEdM<;mzki1Oe7 zQ=w{xuT44D@?H#kZ*Y+cFoO~(nt8=908xLyjC<`nd60Ruifi6jwhy|acWc9Cu#o4_RpY&H}V_;`rh_PY&kiX7t>q> zGPtQw`Kl+D-~2sHo~jgp;H+ySldE#jTpi-*F{UipG|M3&9wWWi3UJsOgHFEoqBW>< zXuNDog*FdUZy2{VY{(vf$X*I=z9;O8<;~Vy+MJ0YuxfyP1VRaM!C5EOjo|>xMBu~= zD>LEst>!>kv)eSgXuB;5!UALoYB*GT1F$Y`>_~xQm)C*V7*V{7MDb;$Z4I_Kw6g`r zg$7J-xiV$YpiHG|aDjuWsSy|sP&5XCBBpwvAm#Q?OwgeMtAwi}5{kdy03xl8fp5e$ z6Q9!)%zk;N7q5@}2Mn_7tIVzjU$35i%DtEr$6fWzGR3Dj~#ZdDeSd=Irk_lJVOBBgB{^thi0iDbC%T&1XOO< zM1UFq&gKV4_LZ9Fuh;HbR`b@VOS<>wOUA->LlYXIx;q|J|kO~{5ltK1-9y*p^q09xC41z*1Z4; z%UyhXrQx*?HeA<+9~^hMsJ4t$93pVw$-~Th9klHmf>kV%aT zaq__ZRPEGw&!m|VclU%(={;AOKG4 za^U1X?As^-+lw;=0mc*acoZs4n_gR!i&N44qHM<%TXO+_)kmCku3nNLVSx)>;&c{t zt*AgJ++q)ds$RPill?C*h44IMz1s8!$_T7y5apQ2h)*q|oB6tedKX8C4!+--JYmjx zh2K|C4;eI>#v{h%j9^dL(FiBYP#&&IppNF#^Ai0Y1d!PLt||51&BOg#RRsTGX4=Xo z;p4eyx;v|m?2Sp(a#6p+BQ zEa114xQ*uW$i{U!HCV`vSdtH1~i`b)0#XMe&jW z>-*u5CG!pBeITAx5de1XNx-=~l>w%vpt$RF`20#QBg^}I4!bwU73QTGFM=HyJ)i#Oe%oswG=r}CTaji3O*S1g$w?nF?Q!hO66<@uI}Lk)Z+&OK()AU%>>gs%PU8WQ z=pVA`u+fnSlNbmrEm zG*Um!Z>BnjoiMOqLa;S)`% z#f~bTe;T7cT#A*S8ZYSKI9K)C-SpBNvFlst8V2d7C`RM`UoP?`7@i2QDd@{bfW;~% z@LZ(eG#0r7A|=cPZ{*?CHupsJb0qtaW@8qxTIv;bM_ z6*q~qlii<+i^>jALZcG=|9sny?(g5_gc%x+84qB$BGDMpr*#@iZGu`v7b*SfoCDO0 zMW;Tz53ox^l|6La=hlfFNJqTxt6;Yhdim5sXyXZ3T_cK`tV0Wl!zELYImh) zXH?L#ZLZa@Q~E6$B^4TQ^^Q^tq;GdHXseN2|EGa- z+Fgi`S!&h^&!Ov%ye*bjh7N!L&e_M??K3vkb77C2P(McOf4-@s7?>3SFkbe~ZeH19 zOYO@;d%gk+7Q4MYHpIZ57>C7`_3+x5koa4vWBf^PmTC5H!njsunlkNAl{NUBKexQ7 zBUmr3~Dh``i{kF%d4c>DG9J7^KvTzp5g+)CvIn0lz)Cjc7f> zdXxE^O^Fb6gm{S~Xo8Mi_EBtsiAD}j1wsmz>+wF@ej4fk;NIq9RopStK%+=!98$da z$+?n>t6NfsJ(ab~I!u~kSYbaTqazDs1$4rCz0ct!^(j58ta0CuS*363Z$p#Yy*=d< z3ld+;o$<2065%-$;E|v5_Pp*slgfw)K=E)JYwkDp98bCXo6=g(%%}B*{dD|tq&npvNj<&mks4BUPeeCF94|&iQqtr3Uvxa0j@SD?WvAvHd)F&fEWaQl= zB0>`1(F0hrR|d4te6R}4ByR(}WbRx-vHd}vLU%zD0}*Z$As%0b9hVT0sQEDtwa13* z=PSIX=|)vykc>~&@oDcuX={Q8O#e{5j4SvH`z3Z?>f>&6U2D8>V)Y2{4PY_HmjfC3 zxoL?(lk{H+TKLc12f!iY@1*on2{}!c`qilU2kIBi1_XL5{m+O0ofotG_b3!H^zm%E z|EuPW?OhIyJr*AZh$(KMg*>1Cz$WM!<}pkCt|FNJ;|X&VPz4qkQbGBQFOC zzVe#)1faWXKMtx2%uwS2pi|fwv|oT`WpTxg&u3ryTmR1IoG9;qk$zNL?Id8ZA4>k| z;O>6?^nVVi$?+rae;TszZ(Z2`+uMGPDSYMMrw=CozZUM!x&PYWUBKcm@kIU+FZ~9* zt1JE%7rrG1+kAdYVDrh7LM@<6OueU?MRvPdn&<@_Kkvdc-GR+ng;qXjjuaULqh6RRiOZnD-nPP^r|9zP?Y!yLR+nncO+Ive4WlevIKo3=t?LJC+7pAN;dp{Q zb|-1m|Hb}$iiZFAXF*2>N2dZr`2a*J5u&U`ta8Ft#UeeuCgoH*-5N8@2 zKp!8t(V!0|)@K;|DS(T>i-(Z^YTV0nwCtCyUHGcZ@jj;vA8Ug}l&uTeovRZx$Q{@eH zAg49w|Iyx?Mm2eLVZ+SO0s@bfs3@S|j38A6nF6(xQU_FIk|6?CRFKF#gb$24Z zFMf*-o;cy-5nU`^3(CLiZ%h`8HSWmzpx0RpTKFZh)PB|p^X*^6|Hy;h!KHa*`S?`p zsofxVI74AtRM%u!z5&CA=|-DiQ$HD1pYdI8dRf7O+5 zHwG_$@zWSzMMqOf8~eZ|oX_B9dlRZ)X`7W~IBK_5^i(fVFD!D#TV5XjFRxu)sM6l1 ztLRi4WCePv{6>3w7WS#iyMnTe=8O-=D*B$Wy#_DqU*-XUjqm-Ng_l?0=LIY%ZPlAD ze%WgoL=(fLAi;~gc7Rqu{J*{+2DdXG@W!-%xTil_s4i~p@M+2cP#y4Uuc>a zW%AusdBhRWCvh5kd0}DlWjDe_S6Wz})Fn4QHKlNN?RLlFWIc)b_34?=%gKJ1Uq0lj zKX&YvDmxkzg0ZRdLD#wjHP?ZDzcl>gcRH@B$1Y~!K9|4JKUM91a8l8?cit{&=x6tJ z`}>#t+Yi0ehdlYdR6EZXjb?|+ishVLb^u`YPh@P416KnSUpXUXdT9xWLkNU%3|UyW zsqD-|=-BNl`FD#y*tMvkZ~iDlhjfayl?6Is^Gw=1Exh7>aC?xYbiDjHQ#;Q{Why}7yc{-!~VQHEQlMaGSCSEBd@bqj>rId&XDFe6+ltS{kd>CR0A5wz*Z1MSru+O@ zj^8HmlkdQ!cHe(NAmBnU=J{=TJta<>D^P+Rwc!Ss)wkV>g*WEgBfI3DpdbJ1ks@21 zT^{Wcyw?fKfmlc4)DkWftU)1X8I(Y~_H6kV@YG`v z7)Yf53&*Ip$PB@jS(}=Y342) z{7Rz)Jn%A#16#9VuG?yT#Qx=!AKRbQ$cPpkx4dAGd0xW?T>eQc)C>N4IVY>TskXPj zgE~FGqdKoWTK_#Y@M}52;CtV(u9T0|rSo7m-yC=Kp!whM-Yla-rBKLuYL?gX-0Adx zAG50x!SXK!V;>&vpKI5Ofo`Jrc61wZGY)ZueKjB1+w-4auE$N8j1=cSyS`a&^G;p4 zow~U>H@460imSYTC8u(i+~(~Qf1HtjDQ#&nIr=o~g2|n$Qmy0XD{UuL*?Ak-s*0uE>39XV4{$$M|}3em6^S*x$)49 zo_7oBd=F3H|MJdg7Rg}_|CGz>&K~~sC_4>zXJT;c8UBMEley8tSP#dzOBy z?WW^#g&-M4e~hJD!MGl{Ug>XW^wD+9e@a9Ki?7)L!kB z=8ihL+eOVz&_=JgsM@21lleXcg!=A2@>6&6(yeXAi1}_eoZTZ1YdW<51lzn*eJHS zROvik zw2ao?;S8FOECPr@kwfPi-$(g>#x}od9PPT;*UaAUt4nn!->@0HAduF1)^_ zm6@<)rG-$JM18wq9k;ftP_g(YtHE zd`K_0P1T-w_cMs2Nt2ISB)s0>=B|+Y;)SNp#h}VI%Ri!#;^=Bn7BO|FlMpAgzo)CLhFGPzQGJ2-d(yr%1wbVK z;mSnN7V9ei7S4A_V_g5%FxulZKt)m4D<6!m9--JK+ah@<4q%%UM{SXO(<#1}qQr2S z=k1rd*7%e*a*z8F<(TN(+iVlp8XdlNM?C3a*&E0&b+3t8y;qBge&4(F(t7kt21AG1 zSMrq85tpcebT{YB%il4IFLCBOu{DPpt|Mi|nKYg1NzxDca(`<-dJPXCBaStDwmGLp zUN7rIRZk)=`w7i49G9q>80P*$hD zLCW>g`_4}3#j-Z?2|v#G#~%(Ikn4GI(VA7Apqq9af>qtIIoc{PcT0Fe{w@|%t2J$sW}D%N*sE1nB%Y-GXPPKiEL!Z6 z(6)K24=wXRp3Lt_$!PdnrOv01uLaLZ_7Z(OetN^E?cWB$`b3XNZu$DPDhK^@crbyV zTc8KDp1qu;J(>ZJjv}uCj~a!8{5|-eqMuKxv>ToVJ8>cr_L?=&>XI+Q4@y)@*4{yd zSJE33{c@~rW0Llc82GcV>UNtGB={RFMI-ZLNe^9N=T6vUv2v>Va^q#2Es`GUeDnN0 z;MnY-z+-qCMB7P%_3nkY)j{}oiv(E4KG7$Y3K<0h+|1wFHtS$ZJI*Wh0^fdkSTxdp z`F+qp&G0}9kkt&AokIp%=3RKr1#xzg8#KU7MQgL)CGq153qxD?)xuz3eBiD4`o-f# za**$D0)`YFaXC7^zBwZ_D!0&^QNbZ8itsv_$Iw=AVVlue)Df=f@cE zG2k5>C-(n%LrZGQ@*w4C#Y>Sn%U=VDds+qT!ejUW&@p}(2`_bWvI4T8$h@#IML$n4 zliQy{=&@<7tGrw;;MtM)qQq@7=WPd{@xd+==WGj+5MfQ)RWFL61^`*A2 zoGJ)uup|5l|Ibc!amf{CEtI)*0jxJ%l!oaV&z!+B(nOzj4tGW0U+F3F0?Q0(@R;+W zkJvJ}Hlcv9Td&IZ z)F-O&Ad6+!R_ioab?!Xpqb<_VeTPl@Ie6S@J4pqJ2qMUa#ky1V&qjAOuikd_Bp%8T zr0Rv6D!SEYF3?ylv|nn`&l)u~%^8!^R1hu|?;zYwA8{kN28PL2`t6Zb+UF(U4JeN{ zGB`bpz~ZcHLUAZ_-qNnoT?Hr~bAt;I`ibBJ)a8ife@?N@Cv5X+w$9G##N=M=3rU_s zSLasmi5ja)wGNxOR)<^4+xz%$$XBw#I zp9L#PJaDTa?INWATo5reyURNTM4*phvDyy>fo<*hzd_8s<=U!4{jC zv$QDG4hw3$trdj&%P?C$8!PB^CK}S!rA(iBcFKkBCXoX-Z5%e7APXX%^ZL>*vB14l z0Jp-28{XIl;myU^c?m5r&SV7JG1cJyK!e`)o4fNi7d_2wPcJA12@I=Hb!*# zTjCxQd4rDw#=m@~Q<1{qf>!UN4Ig-^ioC*}y*ZrpGFDd~V-;_&woNV!lpRm*lDL+R z3_bIy%3Ln)XqKt7d*E~}z5LKjR0(H(-5SZuEgF&g4i~D%AxEwx6st4)4NXE7F}VIo zGDswO_NafTQ4}++ET7)8JcFsWzqM~>!6Bz#RhcI>{|l{NQ?ojBqQ28-TYxS9{=wJ+ z>JSA(XS+pkZUl7CFbZfRh7A9Qr$7Gb_-@>_(lEPV46&Z9BpZF#CEjsll1anf-wEa(qhA{MZA1*p?l5jb?&+bXW9Wh~bKK8x_^|T{hjcR>h-29Z_u$`5CMm5;OhJUQGJm%D7JY4f z<==SWHLvR~Dt_d-!DriLs?C{g@q)|reKCtmJM0L0N*lR>=<7ylIXp5axNOWZPu-OH z&s-)&J@T9tcA$dygJm=%8x~kiH%D+;+UKW4ds86;xTJe%bpq#o`Z#9NvToF| zwJ?QZ_=p}96MYfISZn9nZ+k{r`67T?0UyV9kG50{+7q|=Nk0+ZAXMP&NEIG|G#_i} zKR%>=V~c+HXm@p~!}E-&$C-Z0dsJ4hlWK@hXgt)Lq2ELjmtCb0;n6-XhLS9S4nsZqt$3s2eXig(Y^V7a@sbxML>u^jgP+W{tn;uE0yP{Brym8cy%aJ zx1mN>yi{1{^tk2j-Wg3zKaa5BX|+rjIS|e1C0-I4G123+Y}^j-7D3=SWy-oglD5K! z5Wt(&Sd!b5y*G~mRrVqWAO>f%_C?wHjd-M-F9QKCBZ3n0wrM_bXMN@`MtuB@rMP3xr^F3P03>F3TZ z)i*n&P!jq3rwraZ#YAsUTwBXYvC3?_i>+_zERs)p$b{W}SE1b9FCvd)X=+B@7La$& z3NyNNU z@Vz1MlyUI#xzSnc>?iK4E95sEnDv?4lJxL5*a-no%xTg~8KN{Uc1)BtI~0)T76E_L zBlVopt)ji0T`-xMaD9#Ld*RjNlep2uVX$oe{xh;L)&r0HGNc7D=sTghr+Po3G#jX1 zwS&}okMK(+hSzM9=0in(qb{YeA2)Lh|6+z!VeJ}+H z<2641yv4d+q;BQ2T87|H2*S6DII=r{a^4ObMk@SAWOHYFa7&BVFYLheRG@Kl6aIZ< z;pt%B%Y|u^X5i9K7Q_M1f?-WDIwur@q!W>_(?Gn<<2!I|)XZsvArOqUihH$A*8x)g za06`lgif)eiU2RR!RP1{|KNUk0Bt{tK3Vmz-_x-IBe}juo-4Ch#?ZyRI_=OvA_%(| zLY-eBah}WyP+qk8=t?-}SR}W-@H^CNeY{ivGcr87eQDUzKR$RuS4uRC@;2e zcq$GP9T6$XUn72Xg@w(&Q;W*oIR@}nh|qlT69=h(F%&@m8qC35caUMX$~TkfU;k}c z<#?s%5R4bOV;}^dIWS;GWJs%C1>(S0_JB~6Zc@4OZIE6l;sYum`kK$0m<7nEM1H%H zg0Wm63yRDO8}qBLi6YrIKI8$>lusg>viN=5&E(elt{^+bserd|W#4*d))jho2 zRoL1T0CH(dMG7jiw495+1kmdKZ0;fGgv0dP40Ea_PkZ8?$hYC+?MgiB7Oa9XcC^j3 z_X7!d7EtzcQ4Q6+Z^!J+>|MtVg)YkwSLnLq*=Vk90YwO;Q@8BJZdD;N z4;6Tj#jYHBP|yqr##7FJY>a6lObH0;e3$0XgF}x~b~%`Lmilr=MmL5Iy11K6HhCb* zDs)|4vyA+oarAG`xNi84ZJIngRZJ# z!!;3Eq*II-aG~y3S@GkpQtly*H|Q+9Y4wGMz+H}JE=M!;^X4)bi|;Tq{7?>S0S#`U z0@%M{gXu|rAbtK*rxyZcTvQwQ{=;nj?(BV32S|@9=r$7$r;pj$mzI8pP#9tWFfHUO zW>2YCC)IyJ(a;e=WqP-y6LTV`n0W;WW0lh$gG+sH@kmZ&53k7wNL1s4kCYqj%wTbLPeG-v)3IQni;B^FF{?UIlpn9OnG5qL?F1X7 zbV$6*c8Gk#;K9((jds_t;MF`N4voPeM8L9*4SxA*}t9KReA*%a{KUW~#dteGlit za-I5J$Y*0S+tS_}7{$?s7puJ}Q3WaYD3fDA69W@XA}`S=TY0VXn-4gw?Z8u*aiv!_ z#dpfF&~zVRf@R&z05vq~AQ@o!e98$WiPm|H>42tYgEvK(>SSNyF*4TrW$TR>!v~WB zMAH4ZhH2h@`6)jL#LL$iU6=t#&lp8vUTGN~CXq68Fvw`Nv4P%js=VG!R|$>!zYgi4 z$&3XP#T{i`O`}CvawH@i3=ndrgsP7z->#5C+y$GU%=9vbKj>laZ967}B5k5YU zGEX@kl)J`SrOC(BIfD4c73*UW=kfAft)bgrMZG@ZJruHNSZ`xF zqs+=nGo*7z56Bs1Fqi;Hk3}g5v+|>3Q_Q*||44cUTVTdjU8?q3HJ__&vP9V$$m%ZL zG;r>0TUFIu`u+R$owOh&i6;7-o?wG{Z7!3>?vPgqoo7>U5Yncd^V4k-M#cedLUC36|2@>a#F)-X7nA zx@65_#V8e>tF3nH*Fcf;NWUlEoEx58oL+KLTq>!UBkf%$b@(4Ls!VjC!>ZSaBbQ1D zb77%upc~WQi_+#A@y4o(%|qqE)4cYB#CQD5vKmiGSte+YkxrZ{0xrZ=ou5l-^#Y`= zG%8TQRhu*+a3=Dy3n+f%ku3V;jG|PH%OX{a`wR9~EdZFA6#5G^&b^jTu5HNc`87@^V)nbhH^y&wcN(=!QYh6pgD1TvY%MK;xx)qu~p&EM6 z?9QZUU}~{?kxOGW=IVqQzz8$j6j)XPQ(>rV*Gv3+(w)TF0Y!zWqWwP?QrV$O5&}Ad zStu)hNaKR}78_?6J&SbW$*$;13cv31%S3@dBj0Uob~W@}tBlBH#b5ri(8mbQngk^nY2SXCV~9LC9RTTD=NP1lr*r)nwf({B zqMObS@G4SVQ=`EUQ5-ye?IEHmDlqlsLJ`PtgnwTI#Cj(lelh*B%MRb zxheiDKi#J|({T_eQ67whgpL_@rfK-IQ_k&tc0O*ky64*CLJZp~V1DpgOQ!s&ACuPD zKJuYC&k-dA+Xx~gP;-MwFyIxzC}KN^oRxQLL%MEuHV!rybf-X?(_QwgHHanUeb{?m zRQ>^&jnaD!fsAhpUS4M=7e_rsz)lx=ASyQL7J5n6z8Ac6DlIw3{6f7!$u3)Zzb)TO zMi|^#-g$NI<;z<_r3D7kiQk0%G)rl=R(G=}ktgb*(j494FK$;XxOZI1d7%%CP`Uy4 z?52TSoZv0PztGZR30#h_#Qx(|##484CvLrFx}0u+EIj|ZNZiYyS9S7xjnY%z*;wb90TV7dC!I54t4 zJQb%Mf>oW0&xFFLqUt-FqUzNV4gQ#4u!kUFGy^@I29JIM*40J$)8c3&cE*3$5_^9` zI&WQr|5blIEFr`^62Dy4g!#lt_r_~iOT|Bf>P*nlt#P=fA7q(}%n?ckdZG#*vK8h8 zRSQMpQIqL`rPn=t^9|El6fi~b4EKrzMSF!_Er5hYLVg(}9gC1~cQ+b>r&TaK^@jfW z6(K_f*%g!khUb66Q2riV`1q>)Yo0DyL8A`9qO*vYU?pLI-wj*SQ|{PLE3|9?)?l6{ zsw4o=zEV?nQXGQSd=WnFM4nke3+)?<#1FP)n}DK#3~c%}{FoK2GQUMa5#kO%!z>Bn z8(%pV_CoY`xZk~VxI+x4P$Du2>PxVWFQOQ&>Jar2k(Ped_Vt&1fKd*>_F0xMzi}U+ zm3n4_rzE3b1pp}aeh}dmEem+iDTpA5${+`z(#6*zLW@Mgs}(f)ro@Px<+nkmT~=04 z%Hu8Jiit?bUrFw`MGz|ynHM&ui1c~lqe$(+YMHGnNgSc1LG75ybl86?T$5$Hsn{YqCwXa~6 z<<2?K{y2-=P#KIO;!@?diYqAaDXV+yhad{|l?8#L59;E@1p*Qg=Vab{8F8qDG&lIT&72qTDkd6g zozXb8;8gx;=w%F1|IDp#vclS_G&2b(8JN%YH;?i%UL;%N)r)j1N3to28z+q*96HSB zF?iR^5W{9j;nJZh0Fhx(jwAE|>8?u9;pO9wCC;sb=-Vkb1zS zKqytu_MXuv)^+gxuMH%m&8IUcw;e#AKQw>t#1XUX0<`%WY^v)a5s*4g!de|X~erBZ2Lytl8MCu^> z5`}n3W5(3bXW@!R+f{BAQ%1b+MY-*^@rBV;&^MrMg9~4lDNnlv;-mNK_i7GZP3=nV zZmst3nH+0%af+Iwc0=mTate%z9yFdL0i*(0U^CD$jSuttd%FkM&sBkK23@x5`g_I) zVWDo`5e8Y_Wp&7emoK5lgD4?38@S(&1b8u^hXkmS2vk(LaWTP#-w%+`p0U7OMG1O( zo=nG)v&vtZ^{n4oI~49j@}`=L9sfw$SM@D%5KZKa?1Yn`9QIorr8vvDVDe6q_7PoJ zw*xvB>q~sJRp@}DF7BV}9(0;&|Fd)NXoBvTXi(ZKP_OYAEOH|xYo8B*Ee6nn!u*)# z(D0$W%<6N<50TY@D#%`S@j$fe-80IUh8wQWfU15 zTQo}7=X4)cjEe$X5SB^IT@3~`H3wHSHWFfFWeeD-FlJ|-?xL*&zOn8z5Ih|4R6tE< zL3PnVNcv|+wiyz4xNN7(C1~O0*;tK*JBHa@_Shv5yxzd!ubZwxuWFI7?FQm8vv>A= zUZ<6yyv(m~5Z9SJY23COkmXid7jRs=V(fIw1$&FRs@}wn)DZp;->l;BwU9qP(Hf-u z1r$t;8GPWoDYmAZNLm%b{gj=VYK3Y;k(mEw6ybNHsHHGY_8r?)VKu#X7Sw?3)1q1w zJCFYbJXlm;u@atcyQmsgz2CRr6{_sg<;ZV>=Eq9|cng`MF9@N@2V<3#Ff-jKdgyYcvuapaQjesr}4fr<;9Hf4NtKp&~4Qp_s6gT`Lx(n3e zK%N&B;UAn*`L~n|mjgkPUJ?zoAP47gQF7@vH*bgaRv2Ei!aAoY+54(Gt;8t4KdrTv z`(t?!AL_p{&;Q`m|KQaBZ8#MHn!6-rWtx$K#azypCvJN|BP>o>`=GykvS!R~WT9S( zm(RX>`7*I3&t!UWtdDL0t5|?7x8zZ#zI@gwfD7T^S3^LDK)|Ih$5?z{sAkDGQKRtD z@4Fq7g_T2~N7GCxk;Cq*WX8}L{zA#-4BO>~93b0^;PD+P*6 z#RF8sZ1v;b%3nqsWW_<{(7`=&;<*ZpWj)%VQoH>gZb)xAG`BOiDWkDemrS*7C_hV<-pgY z1=#dr)_$*}!Jm!idcz^4XVTUfzzH23Fgi?6X$1{~N|H9eTAsu*BiUxc8PkOiyL_7a z7?pWT;4k3Rm~+~)q81*hAgG%LS0kD~lX$82WFjFuf!Y&F4&i9o1~s6m88`RKL0AcN z+JPn&{&WR(7wDv{>WA7YA$`j|bfA!vnZUk&5;cSh_%uKJ`WC8yGF&!NS~aJd7zXvZ zey#K9DM~zW@eg`UjnEcLcH=&v8Kro8s1S)-}&_uo;s~T4KF1+)s zRE|jsLn-LcA$-Z|0qsRS;qL$=niUD3*b5d61&IepMhm&Rza|vL>Iu{;b3ImT-MrCzcSf{v50Ag_aBdVbCZ$j)#b-6e18rND5;huDH65?;d;;B&8sXg!8a((? zp!V;?>X_?1j$xaXL>{gfeDN^wRwU@2w>G8j>-|ACcUnccK+)Ze*CfX(`r$suwa8pt zGZ8UGn%4AFiu1gCoVs(*T(w$kX0$SIZNrHEzVlg$%Jel#Ne{`}V5sE|u=+uy61<6OH{_sh02Ym30#dKXpQVs-ox*^$W}m;0c96@v#A zy&YgnK{qj!ye-fJ>-XFOHSn$~%^7!N(3yN1XxyXLd085^Q{yPy3=q^nm;L$YN5nw4 z0g!q}?;kuk`sVhwW~iT{NIQ#Um}q$0GNL_OwYEQ z?tI){9B(80Mx{~Kjh>*APc0QX>zUPi-@9JZ*ochs?6}FJV^{~59+L@#C+<|N4*=D^ zMKT)(4km(zos6|j7v=n~SQMU&qtQY82!_qh#4%dGpR8W399gttC8w(=wh zD)BG_A(Vm9L^Nr)(ne!f?8Xeq5W_BYaYKG+6%L4kbiPb_Of+bMh@7}no=X?-_GQcK zZ;xw{*~Jq2-l!sRwNs=qpTSh@H*%T?4LkO^;nTOC?dU$Lx%o5>>y+Ffhua%(#u+}Y(3j3 zO<&{KQn(;@PPJy5V#Z{qTO}|G1naiRO%3p9k1vk^+@8aGz`fHsnQrSyHX~#+AbnSe zb1-*EB`$HC~Eo~s93~D+nV!hp_?Su_PC?x z%n_c^|DpWnk0e2sh`N;3Js2+n9pmyEh+=$k*fY+_&b~a|yCWQ^C~$FS7$jVGm--@S z-Xq-r6&n8Oic>EY``Ohk2f5_L0nLu~^|3~e8EG{48B+xQro&uE^b?Hov5fHPT924$ zpiyjKre9Z(Xle$ERdOh7Q&A(-uIXNu8*Q^uVy};Cg<``Wm&44XBsChFFsuOn;%@fD z1<(_;<>l0Y>Cg4&JnO!UxiD^o`4%1p{oS@2ag0Zxdo^hBQt(fnmH#|jAo7837vu-$ zG|=CT15mGRK;pUA|Le`^||MtW~ z{Q}0JaKHmKcry@Md^;Rbmtd{AtuaYB^N0uD+ViP|Hgd%zh1uN((JZ@c_FPGKM`Mxz z>S!gT2e{E#{`WUn&lwoX-;ftrg_g57$=N-2M-{D#bNQj^EjStbL?sYN9q9DwvPTq^ zGEr!Tt!ZntzSNWK1bZoepI?>+(i^v4VNHV5pez}8ir?deR|Btts>+1R)+ihU z^e6e#UzzEi^5S3zxS{()W`s)McF()YW<(!j+KdIfXa zQ3a~&CD2gi=6iT;-j5v>|M{z@q(s1jf(~>yXOvuA{UX9|EZ8t)(4_b5<<)@g=Y&TN zg@K~ai9whOvp&Q}7xleJW$R#{^ndK8o4x>vl>W>9{c<-j3)^~AQCK%caZkc!uIgpN zizv4uow$UN9`xzwO=?mdjk_fYMVNw9N0h;z;VE)u`O=7`bj^j`Pk?aGT)d@4&gud+ zX#4@WyYJ$oyQxlSfQ#0_KD2z(=X4?HH>=vP-nc(<@i_~?Mc;!jum5z2Gcp{-O7FHV zR5fzbzZB;p?fJL=R1dm7E1|8obfgdJ;*2#jckq8;S?Cm(E1%Igy-NSx;-%(oEqTBF zbT9#k6ur)CHrc%01*a_NS`fH4AgXb6`#_p+kU3oN=>9JOwEGYwT`&KpVY>Y+BKVy?nXjTZ215r_aB4aRaIh zs$|P8Inbq!uYK}xoAo!g>!6aivOVR0G+Fb1Rfqpo9sb{}I{fbQk0HBq=YI&T2LTC z1w^DtC^4af5CR00kc1FI&Xe(VX5RJxpHF9YSi&n8TSvQ9n^lZjg+(vA>D5Fa?cPVvh_gHTQuKo!MYob75syK; zcHHv3)0a#-x`n7CwSEE)0lm!Tq@Lma5(niy&V9=70Ym_S%8u*t0*{x^L3@Blobmts z@&6Siye!RAS4qj!f>qZ?9}ipB$bz8*-RJ}Lk%SkkW~<&oPlH|s^^hY4!&bD@U|3IE zRkzJjBtE=SeMEg;oxUCo0*UGZ)`Wf}2WQsiMICfY$VBz(;;8NA-s7A?nFxPVk8qz?x_N_WgnI0%l-FNqi=;qJbM>LlAkb5Op2Hv~$~Q8V zklUY;@!_GJqQgigAan%=AsdLD)x9c-d6W5`L%T0c$Y)_IkxPKog?uWD<@5`lj>kgdM?c>6V zl*m z_4e8nEH=BNd$Sg)Fye+lf7O?tP(nlA+zIs>PxGSIx+&m2ni&$G=@i}Pe#Ba3mhUAW zmTBU8^yQ2vnM%+SWB+c=|``QWg^;YJ(d!&-v#LKKeRXO5T?bBJYyui{=dWD;YiJpEWgm8##mRsF_o9YsC6t^wO5eGYG}Ty*<@*n z^0lEA?FWksVXh5F69!AosZ!*31+IjF+0t4uHUD9B@*J&g^^r1;y2oI=Ib1i2bUzzO zd&tnu$wR`sI}Kr^pC;3$L|M-^&uln;DOM#kxIuoopP@ zcWEDSOLTE{D@WbNnWI+6v=J5%*ivK0h=|K$Cm52)YCYE&LY*+GTbuSX80Lrh82saV zZlBl9<9F^}jZs$~jhgaA7hd{m^*GLJw2#ePir<%fS!-GmHW~85eQ`%|*<9sFCVG$0 zh`&Vbc$MRQ9^CUeAD`|c2don$E_Ouue$@)uE|LH1bbFMuQWgK9wDA@iEyZv4sH>B0 zoS@9LsC85GVkbYdl>C1z@#J-Zj^6c_Xg@!vqTk6BhHe*nRVND-a6)UZvb*9bOhA*V zaz9D$$kETkr!TyXo^!zJJn}OT(oS)iSuX5@ycr5~e%UWl)e|La5Rp;s<6cY>C|ry$ zQM`UL|GVbNz9jhV@MHZ9E-G4${V zJ|28Xf-)9-3NqmxA?V)cbQ8)|030NDGWU$UxVJEmg=I$=Em*1}+K47Z6$fI$$HpAN z^}sSEY)dYmgQZ7bzLH5MxS{{-b=109a7j?;xy&P%c z-F@t+kFeAMd&kCAMGWKL$0HPP-q?F+FHIWvY_^oj@l(^aM3<=KwiT+rbvc_*}vRxaAX|$d%*-Tv0uOV*#o27tM6Tr4AVxh z5*S$Wuyv4)jN40#pSEhgKLQ=za-`B(AqF?7~19Mq43y%F|Cc_ zhIoESw^j4(JlF+4XZpsE)aV#@|FLKw??+ysfQ42I~1g zzNiu8EyeGQlPYk206umgS>}yK@QM`>d=zf*&5*>K=_K7rAj(;ui(||0@4xRVJ7)8Q z<`q{Tz`6A2Y-S1--qU&r2(Y z`z6zMfrE8mI?}XEh;vs)_4OARMsF%f#=@c1D+*BZY&3hom5pQs{EWUG#edWZ56uU` zD-g4CPmk-tCu}SSTos(`PyDN#jok!OpiAsO2>EOy<5g>LV?c>zoS?vMJYF``{H)22 z;@>fmi%VUpr44V>;BbmqZ|YDFxL@|lFduljhd^y`)rh&fU_EbmSV~;;oM>%c>z9cd z=8r zxp`Qw*i^osV2UI(Z;iyA*#3)v2Z(Pwq5c^38tnZu*3$BJg0(qm2@pmzW-Va5E55Vuh9hlRY zRvtYi0pM!3IK!{k|2U&1Z~S=YQyXaPjyUI&z;!B%54^7V;vHd=_WK^uC+pk0K$vR) z-nG9fb}Mhh|F4fd?f>iBX2xwrrTyE?xnWI0c`&{%bw_`mPVBMfFsieh=V2Kh3iuy+ z>Ul=wydpwY-xn>LOWNrEpdfV_Ziv%5xlV6m>>ux}SNxZFyROR?Pg@S5*&;qmkVqJe z&T3S{Z_TfY#kXwZ`(v;$#%)T&<>N1f$m|0kXf{`Q&sEp9(QK30#qO1{ zD2anYp-keqFX54O1G~pD$oCUe`taG@|GHYyz-cYG1J>)T-X<9`Qd0TIW*rQtICCP5 zngI3K82@zx=taGY+JSPTQLlAJYnMDn8ku_NR{`Owu}r$}$e*T!G!37+@RF*}9j&f6 zUM2Xsj7FHYsM4RUh{XL!1q7>**MfVi=;fflTVS1bUQGmEd?9sW5_IxBh57_V;w|1* z7FmN-GrQ4ltJfguY}e0gmQP^qSQkXi>N*<~QG?s{6}(?W!l2=>ls0tzc+^}XjTv_6 zxQc@G;V)poBw3#I{j!<)yHk>Ai0+X9?X2D#Z#1HO7U4Lh8>Q{oS?w9jjP~Fx=+-jw z!0WIMi@NC#MP(HrGPy3ZGZpWCM&o&a0QI=|O6zlf)#4fLSE41~Z7~P35}Zf&q~v*d)(2$hoD_dflkT9j>yP zib--wib?0R{XrLH{o)#yX6zfeFA?Iwb)#r*5GRasduIPS|IL2Y<%^k^&5*DiJSK&`$npEs$Zm^r92^E(A5CqETWege!CYqL8aB!3Ibeb~Z=Mz%E$4Qo>slaD zOg)e4;ia&H#RRi*2cR;;yXxEWn)wHZBRir>I(e@7177`7#1k>t^JW*gbk`YoYG!~E zn|J+JP8EF%yuhmMA(*lgOtnXpQ5b6t&1CQCLtiYo>TxHM_Ir%+7Dw!#eA=&?rnBxW zK$?2?Cn%5WiAC~Pyk+&Eu8Vy{w0XXcc&%B)PgiV8r)yM+W`evXr>zNm%J?*$WoIGM zVNzPYEQn$vcB^Rk^py(JLN*C&b;@RFw$r>0xy15eZL*$@>yPY9j+5sKE#`$7tPqiq z>fmFBiD+%324`}&p7TBhth)5TP})lCcGYzbko%ZgMi0$1j`mGt*Qv33bybJaOeUwg z8}Y?1?fY&~Gwvl+iKP13hr7T7>Ao^2UX-h=jn9D$0mFtGACZBDQq1UUJ}@NZ$dj|= z<&GB(_`S(-cEt>}IR8SJS+n!?IKk8#rVq}gw>=5I5G3;M6X5h_#`?ty_*HkYa74eI z@5MZRugXR;VXcjAZ=C85$ZrQ^*f{?JSRc6MHK;t(BRIf5*9W={sy_)F>TpD+!TQ6o zm*U(@I(j0M4=Ww#+BKG}pSv)VJGcaT_Z!Md5AJ^g~rURkfbdRoaOl6)2mxSr8 zQxl7h6L886&W(a2aXJa$^!m32SRJ8#V2J$@Z7l+whiO&7w@KJ48&RjQ+ z0wVtPZwYj!6!7{#>OfvMCRcFrDmOYBz$kuDA|QsyTw=K<^~y0^@YXvv{FLU@_#tI#~qDL^O?<}a!FDAa=bek1>Dyv4sMgr(^ecb)nF z`3jH^pOGWtLSAvkila|Y1y`of^N>T|G-EGD@zCVn}9;3_HGd@z{>tuA#s~S;k97$Eq!%14mUg{)+TnIqiWwvBMrZw$$yKp9Z= zB{(^Z$n!k6s=%AX?R@$$tQC@20&`Xm4|?sl^?hJ=8S_Uaz)t{qu}LxBWQho%eapJe zK+$}9WYk4rsXU1{X&7agEqRC6aLsl?10-c&q)Qo+MwdJj9i#|}-5NsTnm&wmIQpFvcT@Wj?Wbx(5jk&8&|x>19yS}vncT#J}XRQ z#;6eKky+@|1Hqm?lX zBB0rIwF?k9I~X!I+8%pKc|M>gQ(RX?)PKAQrWEBtT-ZLgKd1_~#CP^tyGZVbM~W1c z!vBxBUz=E0C|FgW_f%E-y`mkV;VmORpL5?Ac=9FXn%XBe$?$=bTWyJ?s-^33Lr;FB z(`3?5doQ-okhB8MvIui?a0di%5_+9))b>8-2YQx_Ro$M0l9EUJ(4mYUM6?NuK7`g7 zi7h}!clWtA%-K#`S3a%Vd9rC!Nu3Gl&Op=gwNiRk#x&@jl(?ENeT>QhbbnE!#Q@JS z=tg_(!ZSh25<9+sUY}@1ORsK*vX_>}1E5zFZ`5gy!lrDHbtEM`J#=Lz9k)63Azjmi z^hKUR9SJ?Hw>TR8a{ba%!&2U2nj=4+KKRO|nW1~bn#F&-sT+17%Fj+g6prWm zOG1MV7Qci|G$s88?%p(1oqwGTR!ul!8YhU-4yAV!v4y+N-27uL*f)Ir+e2c%kD8=B zr8Ensv-s%=6-lE!_DOfvjeByO?P85u{O>c(xMu?CKuy>A*?3E+S}Vd}R3oF~sJ=mm zXa2fM!8pjP4SlQuW~fvI{V~*7=(5wY30sDV?@ObPp82VPoYv?l@_o@num)Emn^U}n zN=lQTVkhcd1FsX)eHx8j)m^vlXw^`9nBC0HdQIn{b)ck96xsgAXNn=-N4(bmZJv~v zOz+~27?m7)adkctpVRg7c=C#wu2B}9uor+xVq_9lD+=lYLFPd7Tub& z?KP{q9_W%^SV4Tl22bddD=XFNjFJ)iEg|G4zbLb`iAo(kV${O=?=;NQK1h>;1H?v_ z_xMjW!1pxZ_zP{%wV#IVhrpcP(nGyqO{PuNF~an$$+C#8sT@;2s+(;8{zvBLc0_AM zQUE^U{wj4jvyMY(+MVO_VHw-YN8R&QC%g3=Cz(8xiKpT_wsBO?;Zf_%%{sK%y$>)v6e^vHIkN$(J?NyHg5F&G*Yy+5>8}LlfSj zTG1Srq$05SEGZiiP=WEEn5kmcMl6of#2V}JSgg84%_LPp?eszgr*%)`{sM8{<8kMK z^z2=j2HGPUP_!py?>T5`nWt;%l2Ije_vkI9va%Y94=44!ojQ4o2ln6gDb6Ze+Uy-g zhCZ|-=~bY>CJBTIS9ivS2Kw##@YI)jJNe5c>)OQIyT#sFh@rJ&Hy?to$_+1_(Bu6$ z>iOvP!(>u*w`@;RQw-HiqP9KlOgDa|SMyH$r~E<*-I(Vv7|Z#p<-nn1+-jbC_lM(~Fe~ zY9_0PT%3ywT_k2K>W9T_pNh((6)IxB4S<2dT>Y1s1+sF^dpMc2{$8~Uyb7zm*lZ&L z5^8|X-Xp4Z#7fyZQD;PSz3X|NckbjBaN0B|dA8|nMEfU)<5sN_1t(F4gF4Bt%Wh~# zZe9pb2Ix;CbLUSZe%d#!YygUn_P$4@0YvUfC7FjRKrb7}rt*8x(w)^Deuij0(CPtM zqI`E4Jg4zaU}3c(0NX!k4%#1x6udRWr;OmNVLpN6s%@cc(8=fP-PbUNfHr=*JMZyZ zr;8`cE3n)$EMa~eg%V8?mbTmvN$C5d#uOH3)1#0pKQF^&d2cOzZg~IwRQ|RdVo$q% z4g7bxd6gvD;!>34fnWK0t3|Efv;FJm7nA!T%@8#s%VR(faGO9RnY$*y@6aga_v_vR z1JA!jF)=N;fhqz`JcmP z){)CA7(PP90hSM=#hnKisGU-%Y*f!d=!lP*KQQ_sQF8_$aS5dE@H@%a?0C7w^>_A! zdULgZk~uNzHS?xHl>@O+Hfrt8_eRB}kW9e)P6clHF1|WLt;T%Gp{aENf5)Z58oVj*Yd!B&9oQ{$od< z1(VL)fkgjsqko@n3&9(gpF8yn4du|80Pg%&U^+Q}pBac&z~?vSB{*2CwfoL(!U*+| zOD26W)U<{RdT=&sJo;3#Eu$DM3KP`Mhx;A*4Lm(~V!+WWu-(e$+OjxMeK$0@@Yd?!wDx+@&Zu#%X%ayEqdc{n<9r<@XIt1_BdJs(oGf3iLH!JwOpe}L?^ z8_o~`Xw(e9u{SDWqUwkhf3Q&JMTZxv0p{U(_77ZRBiWKgcj?P-LmG`ui2#gtEy!E7 zKjw!3U||H&p=fin4-;K%vA)Z@0Z2*!iu^_u9k%a*rO}Uj3rjuRodBOOH5kVR>rRG4 zb=n4W0?YxQJsW$2x(8M1I~Ei2DZZ5iKx);*`omIwgveLu$pxqNZQ1_Lz>lxdGrAR< zAUWtI6{{gn-s1m&>qo50>*;0yb-vY;&5BgiW4=$0ao%@v0>+t*CNN?*>XzvgQf!yE zpp=29V7JUu_=H~K;(h+oOx_oE>_s(BDsV-B)tKbG?dbab~Zbe2PP)AUO9;;H!Gpb5j(uzRPd*`@Gj5n@i~_{@W{_- z?VvdMoXv0GhYK}=d6x?dJ(y4XiHMtytQKwY11W|V`|bibl*uR2HX@AjhoLuQ^e%=h z-}G{lj``s-8NM>nU()Af?*NIg43_!>vd5w-mB-2w;QhO>>^#J`OP%2y_FVKSA|{Ga zo5`8QGi#Ge`zrii0DZX+S#vT<1A>m|f)m5MfLq0~M>Q3hqyV|+$=3Db=Z^gAxhcKgLM$mg%|?nb$@jMN2F+ow<*aU*L2hDu5m*2 z=af8;N%lKs$|3^H#iE}(C<9GYf1^nzK0&s=YBKCNVqDwF{_*Y5n%uxG{h8)B%EyqDw%pn>=d7`Pr^cqv)&j1ueVINP8==i%>c+As32gxq z(xYW?kk`P>9J!*zwk5Wb2R8w8<+h9$9eIzx6nhFZjBroQ&gc)cqUc3{al5V?%S^&G zO}Fc~<|Dr>Ukxlm{%k34n;BeGCv^Pmb3-rc$v0g!%G1zH>9@9l9NM~;r>cNZlB)p5 zTJIL}!b|UP=;N8S2_k$^7}d&632^MjteB!4IzUBlZ%{Nv-We#tiyZ*8_`A9Nt53e4 zJYICV+7yZbiB_cY;C{@?)~&)gy0x~Icw}X5XR@S6CH_^nGaH3p+f#k)`d)*tYab^k zsuiQyiE4HHx;fAIEP59WdhcC00+bTxYd{|YP$d(&^hnl42`TX<-AoK&sx5o@?{+=( zydIPi%W>XkUJ||4l`;WltWGNZcDn6JX@yFqj9!k$(qOdJirtl$0w^yPJ%_E>p%9_YXxd`KG7hPPV9G>&pJEngS#~BNdXk${T@c2mbgmO!X0@FA z$ruO6XH*|MpEr4SqW4>o28L}YCpW3NoJMV zq+$70d%(aiJQ0~*h~%EEodA?LcqhQLD*r3={1cx;e+vMII~%QHbQ^>Ndve$}uq`@z zn*iYHs(du!xEh@mV6Sp1?2uGqdms<4b$c|KN;xQ@^_;z~8 z#5y=`(={az5LU^99(Crrj_R*mAgvq}-gwFCoG*|woPSEgkA&&mbv-Qg&<)o79}Whi z1G+Fm2Fb}or`zv*on%g!5S|FInYA^0Qwkd`&V}QEegO&8{O0i0;UwxE4q1BIqB(}K zh!`Aiw3*~g-#Qh#@u?;x-Kyde*WyL6?OZzZ(|;(Xtr{3PUWL%E^4RB@vn1$5PX%wW5_Y4OzRg$}@7{sDynYHk zH|WvxN3GVpmb|k-u%b}+Y;L9eyh&5&(o#rJ8;CsJ7EkDNih5{ltO8WcFMjp?e1PKn zvVH>>1PpHD_*4NI{vyf}fn9+Hx9%ig&b_wCh}2!+AN6Q=xSvDz4zJ_Du~}&s(M)=H z%=79aw4-2}(DYC%jDg%zacrag867b9(GWjT)72&t;P=PY! zvGTQk;DhMNwQp>&GLG9Ba_kr3C%61HD%wg%^=zL;pEgDUWFuAy@|u5T?jwC|zlYe> zIGf=eUI{&T$Y%`tyxt!8&o0N{ga!Q!lpkUrI*rjdVnSZAciBEIbWL2@uz%POiV>p-pmb# zii1QauI~f`HtfezF`3lYMZ50;MOc!(Awq~{_j6QjeetjOa1dvX5xuds^#zTA=sS2+ zD>p#wlI;)x0K8EUtV?eS-asiI(Yn?g?bH)LROTLiUJ1e(on)VDzB}jb-#sGU02A)+ zL$qFZR1GCA6W5elTXNYX6h|}m`6w{pU3({ArR=fG8)d3r2u#Isvz^Ix_AyLv>2pO=3Gsf7UfkoghS_Nh9yF!dh;5kAPg#`C5Mx{3vBtLy*BGqv(9?tEGoc*6R~uE z`O9<>p_Owh@JxZ)Ha(1@Vri6|S0+CD?k%-pPBv#J7=ZcJ4=cmF58^Awxs53 zW>2yek}{ZD2XqUppcFuj<$x$;*Z2SNLIsvM$9^|n) z7jLn?{WgT0KwNv)a}U@92w!Qxg46BA3z=W`0dx!dF=tPhIP*w(d)^wI==h z^V#~BbDf$Q!_LZXi#^cS<|66Ti@O6xJ4_e0B* zSXOE@VNSumZmB;Bxu7C6bL0wt@w%2r^m#RiWa_O>ixxwnPWxrjP`NUBu|+~F!a{Ro zvmngabOb1K+(A-U%Xg4mJDzk3<#h;v#iL&=DAEQw8=3{~~tNG13ccE#AYsV)}-?8#OpALYK6Tle9`zCMk?@nDOKV)^Q z^Nr7dE|vlaWb=Syt>o51dh~7w2U{L1EmOq<9xnNa-ENBt`O);gX6veBY|88o-DdHlNNuEDyM0s|dQnJ!J*=*#e z;l6Vntd+@F&re59dgb#cgxglDZ@$e@xU7h@@c|Mj^ch{F?GHSt{55oTd%1U4D`;Fk zNhL``n;&F|8U&~yW6T2A%GX%A?!dTkuO?lhpGy{Qy8T>~Aph9$<&R$-{6Z*^bOEQr zW2OFU`We1ifm8Yzk>Cnn)IEEd=IcZ+=m@cD{L-r^5k$|AJ@EU0U2nhN?eXn)Y@ zpljqH-Y=2A-kJrj@0Sa4i?dOO-jxD|8J%yQwm$u5#Cz-)`TPXT%B?<_viFB579fn> zgN6P3CUTkz*Sm-0YE~nXBb1W2cW^gFf3gi(pMaq>3K&W+a#6Y;A@l;U4C6>Q5&fA8o!p4&SESuV;^^Ij2QTE8IN{_%D|2mJDFMFTfn- z=a2&=@Qk~UP#evjXql#c>l&;#ElIO-f&{I?qppv2<5*&#_4C|?5Vvu(e&p=Ze3hnG z^EEkS6Lw}oi1GU0s~(^8^-I@2;_tcc_}_lx$Dl~<37>Eelg%&>e8A#EVB(YqkFpzU zo{wNqH)#1&?zhQ>Zj}$Y2nzeWNB&0 zP3*JrX3kl^IyvOf8{4tenF&Eg*&F)~ZG{!5AbyioAJCU2t~!yPaUGB!35I{@sU<|& zj@3Od0$sD=s>Y$`*x|(5h(b#(VNJh!o~M~_WIHf&E0*(GytfL01r%VB=l{lf&;Ps@ gaK!&n!X~HtD5B5t)@cDi7oe*KW>=~&-}>`^0nsm+tN;K2 literal 0 HcmV?d00001 diff --git a/docs/pics/only_show_display.png b/docs/pics/only_show_display.png new file mode 100644 index 0000000000000000000000000000000000000000..d027fade2d1c839d209e92a3c63afc9f72001e45 GIT binary patch literal 17047 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 literal 0 HcmV?d00001 diff --git a/docs/pics/vertical_mode.png b/docs/pics/vertical_mode.png new file mode 100644 index 0000000000000000000000000000000000000000..9cac64b16285efdbb4fd8f3da70d5438ddd818d1 GIT binary patch literal 15115 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 Date: Wed, 9 Apr 2025 14:35:00 +0800 Subject: [PATCH 29/61] =?UTF-8?q?feat:=20=E8=93=9D=E9=B2=B8=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E6=8E=88=E6=9D=83=E4=BA=8C=E6=AC=A1=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E9=97=AE=E9=A2=98=E4=BF=AE=E5=A4=8D=20--stor?= =?UTF-8?q?y=3D122414341=20#=20Reviewed,=20transaction=20id:=2038031?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/constants/route.js | 2 +- frontend/src/views/admin/Plugin/index.vue | 4 ++-- .../template/TemplateEdit/NodeConfig/SelectPanel/plugin.vue | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/constants/route.js b/frontend/src/constants/route.js index ab6a918c..21697c70 100644 --- a/frontend/src/constants/route.js +++ b/frontend/src/constants/route.js @@ -60,7 +60,7 @@ export const MODULES_LIST = [ export const PLUGIN_LIST = [ { name: i18n.t('蓝鲸插件'), - icon: 'common-icon-space', + icon: 'common-icon-module', id: 'plugin', disabled: false, }, diff --git a/frontend/src/views/admin/Plugin/index.vue b/frontend/src/views/admin/Plugin/index.vue index 07ba9944..cac8c26b 100644 --- a/frontend/src/views/admin/Plugin/index.vue +++ b/frontend/src/views/admin/Plugin/index.vue @@ -85,6 +85,7 @@ import tableCommon from '../Space/mixins/tableCommon.js'; import { mapActions } from 'vuex'; import i18n from '@/config/i18n/index.js'; + import tools from '@/utils/tools.js'; const TABLE_FIELDS = [ { @@ -176,7 +177,7 @@ }; }, mounted() { - window.addEventListener('resize', this.handleResize); + window.addEventListener('resize', tools.debounce(this.handleResize, 300)); this.$nextTick(() => { this.handleResize(); }); @@ -194,7 +195,6 @@ this.listLoading = true; const data = this.getQueryData(); - console.log(data); const resp = await this.loadPluginManagerList(data); this.pluginList = resp.data.plugins; this.pagination.count = resp.data.count; diff --git a/frontend/src/views/template/TemplateEdit/NodeConfig/SelectPanel/plugin.vue b/frontend/src/views/template/TemplateEdit/NodeConfig/SelectPanel/plugin.vue index 73018f4f..4d4b7785 100644 --- a/frontend/src/views/template/TemplateEdit/NodeConfig/SelectPanel/plugin.vue +++ b/frontend/src/views/template/TemplateEdit/NodeConfig/SelectPanel/plugin.vue @@ -301,7 +301,7 @@ offset: (current - 1) * limit, tag: this.thirdActiveGroup || undefined, space_id: this.spaceId, - name__icontains: this.searchStr || undefined, + search_term: this.searchStr || undefined, }; const resp = await this.$store.dispatch('plugin/loadBkPluginList', params); const { plugins, count } = resp.data; From 2f61f6b88edcab2f2233baeecc376d51af40d10e Mon Sep 17 00:00:00 2001 From: v_xugzhou <941071842@qq.com> Date: Wed, 9 Apr 2025 14:33:56 +0800 Subject: [PATCH 30/61] =?UTF-8?q?feat:=20=E7=A9=BA=E9=97=B4=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=AB=AF=E6=A8=A1=E7=89=88=E6=94=AF=E6=8C=81=E6=A8=A1?= =?UTF-8?q?=E7=89=88=E5=A4=8D=E5=88=B6=20--story=3D121000058=20#=20Reviewe?= =?UTF-8?q?d,=20transaction=20id:=2038030?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/config/i18n/cn.js | 3 ++ frontend/src/config/i18n/en.js | 3 ++ frontend/src/store/modules/templateList.js | 3 ++ .../src/views/admin/Space/Template/index.vue | 43 +++++++++++++++++++ 4 files changed, 52 insertions(+) diff --git a/frontend/src/config/i18n/cn.js b/frontend/src/config/i18n/cn.js index 033ca31c..bb06d802 100644 --- a/frontend/src/config/i18n/cn.js +++ b/frontend/src/config/i18n/cn.js @@ -955,6 +955,9 @@ const cn = { '取消后,流程配置页面将不能再看到对应的插件信息。': '取消后,流程配置页面将不能再看到对应的插件信息。', '注意:对于已经配置在流程中的插件节点,BKFlow 仍然会触发调用。如果希望完全不提供给 BKFlow 调用,请直接在 PaaS 开发者中心取消对 BKFlow 的应用授权。': '注意:对于已经配置在流程中的插件节点,BKFlow 仍然会触发调用。如果希望完全不提供给 BKFlow 调用,请直接在 PaaS 开发者中心取消对 BKFlow 的应用授权。', 取消授权成功: '取消授权成功', + '是否复制该流程?': '是否复制该流程?', + '注意:关联的mock 数据不会同步复制,暂不支持复制带有决策表节点的流程': '注意:关联的mock 数据不会同步复制,暂不支持复制带有决策表节点的流程', + '流程复制成功!': '流程复制成功!', }; export default cn; diff --git a/frontend/src/config/i18n/en.js b/frontend/src/config/i18n/en.js index ca85e90d..2bbf6236 100644 --- a/frontend/src/config/i18n/en.js +++ b/frontend/src/config/i18n/en.js @@ -954,6 +954,9 @@ const en = { '取消后,流程配置页面将不能再看到对应的插件信息。': 'After revocation, the corresponding plugin information will no longer be visible on the process configuration page.', '注意:对于已经配置在流程中的插件节点,BKFlow 仍然会触发调用。如果希望完全不提供给 BKFlow 调用,请直接在 PaaS 开发者中心取消对 BKFlow 的应用授权。': 'Note: BKFlow will still trigger calls for plugin nodes already configured in the process. To completely prevent BKFlow from calling, please directly revoke the application authorization to BKFlow in the PaaS Developer Center.', 取消授权成功: 'Authorization Revoked Successfully', + '是否复制该流程?': 'Do you want to copy this process?', + '注意:关联的mock 数据不会同步复制,暂不支持复制带有决策表节点的流程': 'Note: Associated mock data will not be copied, and copying processes with decision table nodes is not supported.', + '流程复制成功!': 'Process copied successfully!', }; export default en; diff --git a/frontend/src/store/modules/templateList.js b/frontend/src/store/modules/templateList.js index d634e3a6..8b159387 100644 --- a/frontend/src/store/modules/templateList.js +++ b/frontend/src/store/modules/templateList.js @@ -29,6 +29,9 @@ const templateList = { deleteTemplate({}, data) { return axios.post('/api/template/admin/batch_delete/', data).then(response => response.data); }, + copyTemplate({}, data) { + return axios.post('/api/template/admin/template_copy/', data).then(response => response.data); + }, }, }; diff --git a/frontend/src/views/admin/Space/Template/index.vue b/frontend/src/views/admin/Space/Template/index.vue index 47a73693..09ed1642 100644 --- a/frontend/src/views/admin/Space/Template/index.vue +++ b/frontend/src/views/admin/Space/Template/index.vue @@ -78,6 +78,13 @@ @click="onCreateTask(props.row)"> {{ $t('新建任务') }} + + {{ $t('复制') }} + { + try { + await this.copyTemplate({ + space_id: this.spaceId, + template_id: template.id, + }); + this.getTemplateList(); + this.$bkMessage({ + message: this.$t('流程复制成功!'), + theme: 'success', + }); + } catch (error) { + console.warn(error); + } + }, + }); + }, onDeleteTemplate(template) { const h = this.$createElement; this.$bkInfo({ From c500ccc930dc6e6dde08f3208d9fb1d5f0ca8535 Mon Sep 17 00:00:00 2001 From: ZC-A <57583928+ZC-A@users.noreply.github.com> Date: Thu, 10 Apr 2025 10:59:24 +0800 Subject: [PATCH 31/61] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E6=A8=A1=E7=89=88=E5=A4=8D=E5=88=B6=20#157=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 支持流程模版复制 #157 * feat: 支持流程模版复制 #157 * feat: 支持流程模版复制 #157 * feat: 支持流程模版复制 #157 --- bkflow/template/models.py | 13 ++++++++++--- bkflow/template/views/template.py | 9 +++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/bkflow/template/models.py b/bkflow/template/models.py index 0535bee7..76d67cef 100644 --- a/bkflow/template/models.py +++ b/bkflow/template/models.py @@ -25,6 +25,7 @@ from bkflow.constants import TemplateOperationSource, TemplateOperationType from bkflow.contrib.operation_record.models import BaseOperateRecord +from bkflow.exceptions import ValidationError from bkflow.utils.md5 import compute_pipeline_md5 from bkflow.utils.models import CommonModel, CommonSnapshot @@ -32,20 +33,26 @@ class TemplateManager(models.Manager): - def copy_template(self, template_id, space_id): + def copy_template(self, template_id, space_id, operator): """ 复制流程模版 snapshot 深拷贝复制 其他浅拷贝复制 其他关联资源如 mock 数据、决策表数据等暂不拷贝 + 暂不支持拷贝带决策表插件的流程 """ - template = self.get(id=template_id, space_id=space_id) # 复制逻辑 snapshot 需要深拷贝 + decision_table = template.pipeline_tree + for node in decision_table["activities"].values(): + if node["component"]["code"] == "dmn_plugin": + raise ValidationError("流程中存在决策节点 暂不支持拷贝") template.pk = None template.name = f"{template.name} Copy" snapshot = TemplateSnapshot.objects.get(id=template.snapshot_id) with transaction.atomic(): - # 开启事物 确保都创建成功 + # 开启事务 确保都创建成功 copyed_snapshot = TemplateSnapshot.create_snapshot(snapshot.data) template.snapshot_id = copyed_snapshot.id + template.updated_by = operator + template.creator = operator template.save() copyed_snapshot.template_id = template.id copyed_snapshot.save(update_fields=["template_id"]) diff --git a/bkflow/template/views/template.py b/bkflow/template/views/template.py index e125dccc..2b3abd86 100644 --- a/bkflow/template/views/template.py +++ b/bkflow/template/views/template.py @@ -184,9 +184,14 @@ def copy_template(self, request, *args, **kwargs): ser.is_valid(raise_exception=True) space_id, template_id = ser.validated_data["space_id"], ser.validated_data["template_id"] try: - template = Template.objects.copy_template(template_id, space_id) + template = Template.objects.copy_template(template_id, space_id, request.user.username) except Template.DoesNotExist: - return Response(exception=True, data={"detail": f"模版不存在, space_id={space_id}, template_id={template_id}"}) + err_msg = f"模版不存在, space_id={space_id}, template_id={template_id}" + logger.error(str(err_msg)) + return Response(exception=True, data={"detail": err_msg}) + except ValidationError as e: + logger.error(str(e)) + return Response(exception=True, data={"detail": str(e)}) return Response(data={"template_id": template.id, "template_name": template.name}) From 7ea2e977058c47135dd46165a859096619310294 Mon Sep 17 00:00:00 2001 From: jackvideo <13226110808@163.com> Date: Thu, 10 Apr 2025 14:14:07 +0800 Subject: [PATCH 32/61] =?UTF-8?q?fix:=20=E8=93=9D=E9=B2=B8=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E4=BA=8C=E6=AC=A1=E6=8E=88=E6=9D=83=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E4=BF=AE=E5=A4=8D=20#132=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 蓝鲸插件二次授权校验流程修复 #132 * fix: 蓝鲸插件二次授权校验流程修复 #132 * fix: 蓝鲸插件二次授权校验流程修复 #132 --- bkflow/bk_plugin/models.py | 7 ++++--- bkflow/template/serializers/template.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bkflow/bk_plugin/models.py b/bkflow/bk_plugin/models.py index e22fcac1..70815dcd 100644 --- a/bkflow/bk_plugin/models.py +++ b/bkflow/bk_plugin/models.py @@ -121,12 +121,13 @@ def get_codes_by_space_id(self, space_id: str): return result_codes # 批量检查插件授权状态 - def batch_check_authorization(self, exist_code_list): + def batch_check_authorization(self, exist_code_list, space_id: str): if not env.ENABLE_BK_PLUGIN_AUTHORIZATION: return - authorized_codes = set( - self.filter(code__in=exist_code_list, status=AuthStatus.authorized).values_list("code", flat=True) + authorized_codes = set(self.filter(code__in=exist_code_list).values_list("code", flat=True)) & set( + self.get_codes_by_space_id(space_id) ) + unauthorized_plugins = list(set(exist_code_list) - authorized_codes) if unauthorized_plugins: logger.exception(f"流程中存在未授权插件:{unauthorized_plugins}") diff --git a/bkflow/template/serializers/template.py b/bkflow/template/serializers/template.py index ce9bf232..fe9624d0 100644 --- a/bkflow/template/serializers/template.py +++ b/bkflow/template/serializers/template.py @@ -125,7 +125,7 @@ def update(self, instance, validated_data): for node in pipeline_tree["activities"].values() if node["component"]["data"].get("plugin_code") ] - BKPluginAuthorization.objects.batch_check_authorization(exist_code_list) + BKPluginAuthorization.objects.batch_check_authorization(exist_code_list, str(instance.space_id)) except Exception as e: logger.exception("TemplateSerializer update error, err = {}".format(e)) raise serializers.ValidationError(detail={"msg": ("更新失败,{}".format(e))}) From eac114344e806bc5ddd93c27b589a274d3553a9b Mon Sep 17 00:00:00 2001 From: caiZhou <67539158+ywywZhou@users.noreply.github.com> Date: Thu, 10 Apr 2025 17:21:36 +0800 Subject: [PATCH 33/61] =?UTF-8?q?fix:=20=E6=8F=92=E4=BB=B6=E7=A9=BA?= =?UTF-8?q?=E9=97=B4=E8=8C=83=E5=9B=B4=C2=B7=E7=BC=96=E8=BE=91=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E9=97=AE=E9=A2=98=E4=BC=98=E5=8C=96=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20--story=3D123407520=20=20(#169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/MoreTags.vue} | 23 ++++++--- frontend/src/config/i18n/cn.js | 2 +- frontend/src/config/i18n/en.js | 2 +- .../src/views/admin/Plugin/AuthorizeBtn.vue | 1 - .../src/views/admin/Plugin/RangEditDialog.vue | 12 ++--- frontend/src/views/admin/Plugin/index.vue | 49 ++++++++++--------- .../src/views/admin/Space/Template/index.vue | 10 +--- 7 files changed, 49 insertions(+), 50 deletions(-) rename frontend/src/{views/admin/Plugin/ManagerTags.vue => components/common/MoreTags.vue} (88%) diff --git a/frontend/src/views/admin/Plugin/ManagerTags.vue b/frontend/src/components/common/MoreTags.vue similarity index 88% rename from frontend/src/views/admin/Plugin/ManagerTags.vue rename to frontend/src/components/common/MoreTags.vue index 8dae34b7..2617c664 100644 --- a/frontend/src/views/admin/Plugin/ManagerTags.vue +++ b/frontend/src/components/common/MoreTags.vue @@ -1,5 +1,5 @@