diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f0be09e62b..20c0f628a9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,7 @@ repos: additional_dependencies: [ '@commitlint/config-conventional' ] #【可选】提升 Python 代码风格,符合更高版本的 Python 语法 - repo: https://github.com/asottile/pyupgrade - rev: v3.19.0 + rev: v3.15.0 hooks: - id: pyupgrade + args: [--py38-plus] diff --git a/app_desc.yaml b/app_desc.yaml index c095646b84..2bbf6de07f 100644 --- a/app_desc.yaml +++ b/app_desc.yaml @@ -1,5 +1,5 @@ spec_version: 2 -app_version: "1.11.9" +app_version: "1.11.10" app: region: default bk_app_code: &APP_CODE bk_flow_engine diff --git a/bkflow/apigw/serializers/task.py b/bkflow/apigw/serializers/task.py index 7d7b499dbb..1c4d551eef 100644 --- a/bkflow/apigw/serializers/task.py +++ b/bkflow/apigw/serializers/task.py @@ -81,6 +81,7 @@ class CreateTaskSerializer(CredentialsValidationMixin, serializers.Serializer): custom_span_attributes = serializers.DictField( help_text=_("自定义 Span 属性,会添加到所有节点上报的 Span 中"), required=False, default={} ) + label_ids = serializers.ListField(help_text=_("标签ID列表"), child=serializers.IntegerField(), required=False) class CreateTaskByAppSerializer(serializers.Serializer): diff --git a/bkflow/apigw/serializers/template.py b/bkflow/apigw/serializers/template.py index 854b7ef0e2..a9d5215f53 100644 --- a/bkflow/apigw/serializers/template.py +++ b/bkflow/apigw/serializers/template.py @@ -47,6 +47,7 @@ class CreateTemplateSerializer(serializers.Serializer): ) extra_info = serializers.JSONField(help_text=_("额外扩展信息"), required=False) pipeline_tree = serializers.JSONField(help_text=_("任务树"), required=False) + label_ids = serializers.ListField(help_text=_("标签"), child=serializers.IntegerField(), required=False) def validate(self, attrs): # 将 bind_app_code 映射到 bk_app_code 字段(models 中的字段名) diff --git a/bkflow/contrib/api/collections/task.py b/bkflow/contrib/api/collections/task.py index 4966bad0ea..1e1908ba93 100644 --- a/bkflow/contrib/api/collections/task.py +++ b/bkflow/contrib/api/collections/task.py @@ -59,6 +59,25 @@ def _get_task_url(self, api_name): def task_list(self, data): return self._request(method="get", url=self._get_task_url("task/"), data=data) + def update_labels(self, task_id, data): + return self._request(method="post", url=self._get_task_url("task/{}/update_labels/".format(task_id)), data=data) + + def get_task_label_ref_count(self, space_id, label_ids): + return self._request( + method="get", + url=self._get_task_url( + "task/get_task_label_ref_count/?space_id={}&label_ids={}".format(space_id, label_ids) + ), + data=None, + ) + + def delete_task_label_relation(self, data): + return self._request( + method="post", + url=self._get_task_url("task/delete_task_label_relation/"), + data=data, + ) + def create_task(self, data): return self._request(method="post", url=self._get_task_url("task/"), data=data) diff --git a/bkflow/interface/task/view.py b/bkflow/interface/task/view.py index 9902eeda06..2d31eb96d6 100644 --- a/bkflow/interface/task/view.py +++ b/bkflow/interface/task/view.py @@ -40,6 +40,7 @@ TaskTokenPermission, ) from bkflow.interface.task.utils import StageConstantHandler, StageJobStateHandler +from bkflow.label.models import Label from bkflow.permission.models import TASK_PERMISSION_TYPE, Token from bkflow.space.configs import SuperusersConfig from bkflow.space.models import SpaceConfig @@ -56,7 +57,35 @@ class TaskInterfaceAdminViewSet(GenericViewSet): @action(methods=["GET"], detail=False, url_path="get_task_list/(?P\\d+)") def get_task_list(self, request, space_id): client = TaskComponentClient(space_id=space_id) - result = client.task_list(data={**request.query_params, "space_id": space_id}) + # 把标签名称转换为id进行搜索 + query_params = request.query_params.copy() + labels = request.query_params.get("label", "") + label_ids = Label.get_label_ids_by_names(labels) + if label_ids: + query_params["label"] = ",".join([str(label_id) for label_id in label_ids]) + result = client.task_list(data={**query_params, "space_id": space_id}) + + label_ids = [] + for item in result["data"]["results"]: + label_ids.extend(item["labels"]) + + labels_map = Label.objects.get_labels_map(set(label_ids)) + + for item in result["data"]["results"]: + item["labels"] = [labels_map.get(label_id) for label_id in item["labels"]] + + return Response(result) + + @action(methods=["POST"], detail=False, url_path="update_labels/(?P\\d+)/(?P\\d+)") + def update_labels(self, request, space_id, pk=None): + """ + 更新特定任务(pk指定)的标签列表。 + 请求体期望格式:{"label_ids": [1, 2, 5]} + """ + client = TaskComponentClient(space_id=space_id) + result = client.update_labels(pk, data={**request.data, "space_id": space_id}) + labels_map = Label.objects.get_labels_map(set(result["data"])) + result["data"] = [labels_map.get(label_id) for label_id in result["data"]] return Response(result) @swagger_auto_schema(methods=["post"], operation_description="任务状态查询", request_body=GetTasksStatesBodySerializer) diff --git a/bkflow/label/__init__.py b/bkflow/label/__init__.py new file mode 100644 index 0000000000..732d2b8d52 --- /dev/null +++ b/bkflow/label/__init__.py @@ -0,0 +1,18 @@ +""" +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/label/admin.py b/bkflow/label/admin.py new file mode 100644 index 0000000000..e1d6811fc0 --- /dev/null +++ b/bkflow/label/admin.py @@ -0,0 +1,70 @@ +""" +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 .models import Label, TemplateLabelRelation + + +@admin.register(Label) +class LabelAdmin(admin.ModelAdmin): + """Admin configuration for Label model.""" + + list_display = ( + "id", + "name", + "space_id", + "parent_label", + "is_default", + "color", + "full_path", + "created_at", + "updated_at", + ) + list_filter = ("space_id", "is_default") + search_fields = ("name", "creator", "updated_by", "description") + readonly_fields = ("created_at", "updated_at", "full_path") + ordering = ("space_id", "parent_id", "name") + list_per_page = 50 + + def parent_label(self, obj): + """Show parent label name in list_display.""" + parent = obj.get_parent_label() + return parent.name if parent else "-" + + parent_label.short_description = "Parent label" + + +@admin.register(TemplateLabelRelation) +class TemplateLabelRelationAdmin(admin.ModelAdmin): + """Admin for template-label relations.""" + + list_display = ("id", "template_id", "label_id", "label_name") + list_filter = ("template_id",) + search_fields = ("template_id", "label_id") + list_per_page = 50 + + def label_name(self, obj): + """Resolve label name from label_id.""" + try: + label = Label.objects.get(id=obj.label_id) + return label.name + except Label.DoesNotExist: + return "-" + + label_name.short_description = "Label" diff --git a/bkflow/label/apps.py b/bkflow/label/apps.py new file mode 100644 index 0000000000..120bb04da7 --- /dev/null +++ b/bkflow/label/apps.py @@ -0,0 +1,24 @@ +""" +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 LabelConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "bkflow.label" diff --git a/bkflow/label/migrations/0001_initial.py b/bkflow/label/migrations/0001_initial.py new file mode 100644 index 0000000000..48f2537321 --- /dev/null +++ b/bkflow/label/migrations/0001_initial.py @@ -0,0 +1,84 @@ +""" +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.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="TemplateLabelRelation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("template_id", models.IntegerField(db_index=True, verbose_name="模版ID")), + ("label_id", models.IntegerField(db_index=True, verbose_name="标签ID")), + ], + options={ + "verbose_name": "模版标签关系 TemplateLabelRelation", + "verbose_name_plural": "模版标签关系 TemplateLabelRelation", + "unique_together": {("template_id", "label_id")}, + }, + ), + migrations.CreateModel( + name="Label", + fields=[ + ("id", models.BigAutoField(primary_key=True, serialize=False, verbose_name="标签ID")), + ("name", models.CharField(db_index=True, help_text="标签名称", max_length=255, verbose_name="标签名称")), + ("creator", models.CharField(help_text="标签创建人", max_length=255, verbose_name="创建者")), + ("updated_by", models.CharField(help_text="标签更新人", max_length=255, verbose_name="更新者")), + ( + "space_id", + models.IntegerField(default=-1, help_text="标签对应的空间id(默认标签时space_id=-1)", verbose_name="空间ID"), + ), + ("is_default", models.BooleanField(default=False, help_text="是否是默认标签", verbose_name="默认标签")), + ( + "color", + models.CharField(default="#dcffe2", help_text="标签颜色值(如#ffffff)", max_length=7, verbose_name="标签颜色"), + ), + ( + "description", + models.CharField(blank=True, help_text="标签描述", max_length=255, null=True, verbose_name="标签描述"), + ), + ( + "label_scope", + models.JSONField( + default="template", help_text="标签范围(支持多选,如['task', 'common'])", verbose_name="标签范围" + ), + ), + ( + "parent_id", + models.IntegerField( + blank=True, default=None, help_text="父标签ID(根标签填null或留空)", null=True, verbose_name="父标签ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="更新时间")), + ], + options={ + "verbose_name": "用户标签 Label", + "verbose_name_plural": "用户标签 Label", + "ordering": ["space_id", "parent_id", "name"], + "unique_together": {("space_id", "parent_id", "name")}, + }, + ), + ] diff --git a/bkflow/label/migrations/__init__.py b/bkflow/label/migrations/__init__.py new file mode 100644 index 0000000000..732d2b8d52 --- /dev/null +++ b/bkflow/label/migrations/__init__.py @@ -0,0 +1,18 @@ +""" +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/label/models.py b/bkflow/label/models.py new file mode 100644 index 0000000000..75bd321032 --- /dev/null +++ b/bkflow/label/models.py @@ -0,0 +1,296 @@ +""" +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 re +from collections import defaultdict + +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Q +from django.utils.translation import ugettext_lazy as _ + + +class LabelManager(models.Manager): + """自定义管理器,添加层级相关查询方法""" + + def check_label_ids(self, label_ids): + if len(label_ids) != self.filter(id__in=label_ids).count(): + return False + return True + + def get_root_labels(self, space_id=-1, label_scope=None): + """获取指定空间、指定范围的根标签(父ID为null或-1)""" + filters = {"parent_id__isnull": True, "space_id": space_id} + # 兼容可能的-1存储(如果根标签存-1,需调整过滤条件为 parent_id=-1) + # filters = Q(parent_id__isnull=True) | Q(parent_id=-1) + if label_scope: + filters["label_scope__contains"] = label_scope + return self.filter(**filters).order_by("name") + + def get_sub_labels(self, parent_id, recursive=False): + """ + 获取指定父标签ID的子标签(手动过滤parent_id) + :param parent_id: 父标签ID + :param recursive: 是否递归查询所有子孙标签 + :return: QuerySet(非递归)或列表(递归) + """ + if not recursive: + # 非递归:直接过滤parent_id等于目标ID + return self.filter(parent_id=parent_id).order_by("name") + + # 递归查询:手动遍历子标签,累计所有子孙 + sub_labels = list(self.filter(parent_id=parent_id).order_by("name")) + for label in sub_labels: + # 递归查询当前标签的子标签,并入结果 + sub_labels.extend(self.get_sub_labels(label.id, recursive=True)) + return sub_labels + + def get_parent_label(self, label_id): + """通过标签ID获取其父标签(手动查询parent_id对应的记录)""" + try: + # 当前标签的parent_id字段存储父标签ID + current_label = self.get(id=label_id) + if not current_label.parent_id: # 根标签无父标签 + return None + # 通过parent_id查询父标签记录 + return self.get(id=current_label.parent_id) + except self.model.DoesNotExist: + return None + + def get_labels_map(self, label_ids): + """通过标签ID获取其完整信息""" + labels = Label.objects.filter(id__in=label_ids) + labels_map = {} + for label in labels: + label_dict = {"id": label.id, "name": label.name, "color": label.color, "full_path": label.full_path} + labels_map[label.id] = label_dict + return labels_map + + +class Label(models.Model): + LABEL_SCOPE_CHOICES = ( + ("task", _("任务")), + ("template", _("模板")), + ("common", _("通用")), + ) + + id = models.BigAutoField(_("标签ID"), primary_key=True) + name = models.CharField(_("标签名称"), max_length=255, db_index=True, help_text="标签名称") + creator = models.CharField(_("创建者"), max_length=255, help_text="标签创建人") + updated_by = models.CharField(_("更新者"), max_length=255, help_text="标签更新人") + space_id = models.IntegerField(_("空间ID"), default=-1, help_text="标签对应的空间id(默认标签时space_id=-1)") + is_default = models.BooleanField(_("默认标签"), default=False, help_text="是否是默认标签") + color = models.CharField(_("标签颜色"), max_length=7, default="#dcffe2", help_text="标签颜色值(如#ffffff)") + description = models.CharField(_("标签描述"), max_length=255, blank=True, null=True, help_text="标签描述") + label_scope = models.JSONField( + verbose_name=_("标签范围"), default=["template", "task"], help_text="标签范围(支持多选,如['task', 'common'])" + ) + + # 核心修改:用IntegerField存储父标签ID,替代外键 + parent_id = models.IntegerField(_("父标签ID"), null=True, blank=True, default=None, help_text="父标签ID(根标签填null或留空)") + + created_at = models.DateTimeField(_("创建时间"), auto_now_add=True) + updated_at = models.DateTimeField(_("更新时间"), auto_now=True) + + objects = LabelManager() + + class Meta: + verbose_name = _("用户标签 Label") + verbose_name_plural = _("用户标签 Label") + # 唯一约束:同一空间、同一父ID下,标签名称不能重复 + unique_together = ("space_id", "parent_id", "name") + ordering = ["space_id", "parent_id", "name"] + + def __str__(self): + # 手动查询父标签名称(替代外键的self.parent.name) + parent_label = self.get_parent_label() + parent_name = parent_label.name if parent_label else "无" + return f"标签:{self.name}(父标签:{parent_name},空间:{self.space_id})" + + def get_parent_label(self): + """获取父标签实例(手动通过parent_id查询)""" + if not self.parent_id: + return None + try: + return Label.objects.get(id=self.parent_id) + except Label.DoesNotExist: + # 父标签已删除(数据不一致),返回None + return None + + def clean(self): + """数据验证:保持原有约束,适配手动关联""" + # 1. 子标签的space_id必须与父标签一致(如果有父标签) + if self.parent_id: + parent_label = self.get_parent_label() + if not parent_label: + raise ValidationError(_("父标签不存在(父ID:{})".format(self.parent_id))) + if self.space_id != parent_label.space_id: + raise ValidationError(_("子标签的空间ID必须与父标签一致")) + # 2. 禁止循环引用(如A→B→C→A) + if self.parent_id: + current_parent_id = self.parent_id + # 遍历所有祖先标签,检查是否包含自身ID + while current_parent_id: + if current_parent_id == self.id: + raise ValidationError(_("禁止循环引用:标签不能作为自身的祖先")) + # 手动获取上一级父标签ID + try: + ancestor = Label.objects.get(id=current_parent_id) + current_parent_id = ancestor.parent_id + except Label.DoesNotExist: + break # 父标签不存在,终止检查 + + def save(self, *args, **kwargs): + """保存前执行验证""" + self.clean() + super().save(*args, **kwargs) + + @staticmethod + def get_label_ids_by_names(names): + """通过标签名称列表获取对应的标签ID列表""" + labels = [s.strip() for s in re.split(r"[,\s]+", names) if s.strip()] + + label_ids = [] + if labels: + q_objects = Q() + for keyword in labels: + q_objects |= Q(name__icontains=keyword) + label_ids = list(Label.objects.filter(q_objects).values_list("id", flat=True)) + + return label_ids + + @property + def full_path(self): + """获取标签完整路径(手动递归查询父标签)""" + path = [self.name] + current_parent = self.get_parent_label() + while current_parent: + path.insert(0, current_parent.name) + # 继续查询上一级父标签 + current_parent = current_parent.get_parent_label() + return "/".join(path) + + def is_root(self): + """判断是否为根标签(parent_id为null或无对应父标签)""" + return not self.parent_id or not self.get_parent_label() + + def get_all_children(self, recursive=True): + """获取所有子标签(手动过滤parent_id)""" + return Label.objects.get_sub_labels(parent_id=self.id, recursive=recursive) + + +class BaseLabelRelationManager(models.Manager): + """ + 通用的标签关系管理器 + 核心思想:将变化的字段名 (task_id/template_id) 抽象为 self.fk_field + """ + + def __init__(self, fk_field): + super().__init__() + self.fk_field = fk_field + + def set_labels(self, obj_id, label_ids): + """ + 设置对象的标签(增量更新) + """ + # 1. 构造查询参数,例如: {"template_id": 1} 或 {"task_id": 1} + filter_kwargs = {self.fk_field: obj_id} + + # 2. 获取已有标签 + existing_labels = self.filter(**filter_kwargs).values_list("label_id", flat=True) + + # 3. 计算差异 + existing_set = set(existing_labels) + new_set = set(label_ids) + + add_ids = list(new_set - existing_set) + remove_ids = list(existing_set - new_set) + + # 4. 执行删除 + if remove_ids: + # 构造删除查询: template_id=1, label_id__in=[...] + delete_kwargs = {self.fk_field: obj_id, "label_id__in": remove_ids} + self.filter(**delete_kwargs).delete() + + # 5. 执行批量添加 + if add_ids: + # 动态创建模型实例: TaskLabelRelation(task_id=1, label_id=xx) + new_relations = [self.model(**{self.fk_field: obj_id, "label_id": label_id}) for label_id in add_ids] + self.bulk_create(new_relations) + + def fetch_labels(self, obj_id): + """ + 获取单个对象的标签列表 + """ + filter_kwargs = {self.fk_field: obj_id} + label_ids = self.filter(**filter_kwargs).distinct().values_list("label_id", flat=True) + labels = Label.objects.filter(id__in=label_ids) + labels_list_of_dicts = [] + + for label in labels: + label_dict = { + "id": label.id, + "name": label.name, + "color": label.color, + } + label_dict["full_path"] = label.full_path + labels_list_of_dicts.append(label_dict) + + return labels_list_of_dicts + + def fetch_objects_labels(self, obj_ids, label_fields=("name", "color")): + """ + 批量获取多个对象的标签字典 + 返回格式: {obj_id: [label_dict, ...]} + """ + # 1. 构造 __in 查询,例如 template_id__in=[1,2,3] + filter_kwargs = {f"{self.fk_field}__in": obj_ids} + # 2. 获取所有关系 + relations = self.filter(**filter_kwargs).values(self.fk_field, "label_id") + if not relations: + return {} + # 3. 提取标签详情 + label_ids = {rel["label_id"] for rel in relations} + labels_map = { + label.id: { + "id": label.id, + **{field: getattr(label, field) for field in label_fields}, + "full_path": label.full_path, + } + for label in Label.objects.filter(id__in=label_ids) + } + # 4. 组装结果 + result = defaultdict(list) + for rel in relations: + oid = rel[self.fk_field] + lid = rel["label_id"] + if lid in labels_map: + result[oid].append(labels_map[lid]) + return dict(result) + + +class TemplateLabelRelation(models.Model): + template_id = models.IntegerField(_("模版ID"), db_index=True) + label_id = models.IntegerField(_("标签ID"), db_index=True) + + objects = BaseLabelRelationManager(fk_field="template_id") + + class Meta: + verbose_name = _("模版标签关系 TemplateLabelRelation") + verbose_name_plural = _("模版标签关系 TemplateLabelRelation") + unique_together = ("template_id", "label_id") diff --git a/bkflow/label/permissions.py b/bkflow/label/permissions.py new file mode 100644 index 0000000000..1aadf0e1e0 --- /dev/null +++ b/bkflow/label/permissions.py @@ -0,0 +1,33 @@ +""" +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 bkflow.permission.permissions import BaseTokenPermission + + +class LabelPermission(BaseTokenPermission): + def get_resource_type(self): + return "LABEL" + + def has_object_permission(self, request, view, obj): + has_edit_permission = self.has_edit_permission(request.user.username, obj.space_id, obj.id, request.token) + + if view.action in view.EDIT_ABOVE_ACTIONS: + return has_edit_permission + + has_view_permission = self.has_view_permission(request.user.username, obj.space_id, obj.id, request.token) + return has_view_permission or has_edit_permission diff --git a/bkflow/label/serializers.py b/bkflow/label/serializers.py new file mode 100644 index 0000000000..ec41ede8f3 --- /dev/null +++ b/bkflow/label/serializers.py @@ -0,0 +1,164 @@ +""" +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 re + +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from .models import Label + + +class LabelSerializer(serializers.ModelSerializer): + """标签序列化器""" + + label_scope = serializers.ListField( + child=serializers.ChoiceField( + choices=Label.LABEL_SCOPE_CHOICES, + allow_blank=False, + ), + min_length=1, + required=True, + ) + has_children = serializers.BooleanField(read_only=True, help_text="是否有子标签") + full_path = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # 检查是否是创建场景 (instance 为 None) + if self.instance is None: + # 1. 必填字段设置 + required_fields = ["name", "space_id", "color", "label_scope"] + for field_name in required_fields: + if field_name in self.fields: + self.fields[field_name].required = True + + # 2. 可选字段设置 + optional_fields = ["description", "parent_id"] + for field_name in optional_fields: + if field_name in self.fields: + field = self.fields[field_name] + field.required = False + field.allow_null = True + + # 只有字符串类型的字段才设置 allow_blank + if isinstance(field, (serializers.CharField)): + field.allow_blank = True + + class Meta: + model = Label + fields = [ + "id", + "name", + "creator", + "updated_by", + "space_id", + "color", + "description", + "created_at", + "updated_at", + "label_scope", + "is_default", + "has_children", + "full_path", + "parent_id", + ] + read_only_fields = [ + "id", + "created_at", + "updated_at", + "is_default", + "creator", + "updated_by", + "has_children", + "full_path", + ] + + def validate_color(self, value): + if not re.match(r"^#([0-9A-Fa-f]{6})$", value): + raise serializers.ValidationError(_("颜色格式错误")) + return value + + def validate_name(self, value): + """验证标签名称非空且去重(结合 space_id)""" + value = value.strip() + if not value: + raise serializers.ValidationError(_("标签名称不能为空")) + # 新增时:检查同一 space_id 下名称是否重复 + if self.instance is None: + space_id = self.initial_data.get("space_id", -1) + if Label.objects.filter( + space_id=space_id, name=value, parent_id=self.initial_data.get("parent_id", None) + ).exists(): + raise serializers.ValidationError(_(f"该空间下已存在名称为「{value}」的标签")) + # 修改时:排除自身,检查同一 space_id 下名称是否重复 + else: + space_id = self.initial_data.get("space_id", self.instance.space_id) + if ( + Label.objects.filter(space_id=space_id, name=value, parent_id=self.initial_data.get("parent_id", None)) + .exclude(id=self.instance.id) + .exists() + ): + raise serializers.ValidationError(_(f"该空间下已存在名称为「{value}」的标签")) + return value + + def validate_label_scope(self, value): + """验证标签范围非空且去重""" + parent_id = self.initial_data.get("parent_id", None) + if parent_id: + # 有父标签时,标签范围必须与父标签一致 + try: + parent_label = Label.objects.get(id=parent_id) + except Label.DoesNotExist: + raise serializers.ValidationError(_("父标签不存在")) + if not set(value).issubset(set(parent_label.label_scope)): + raise serializers.ValidationError(_("子标签的范围必须是父标签的子集")) + return value + + +class LabelRefSerializer(serializers.Serializer): + """标签引用序列化器""" + + space_id = serializers.IntegerField(required=True, help_text="空间ID") + label_ids = serializers.CharField(required=True, help_text="标签ID") + + def validate_label_ids(self, value): + """ + 字段级验证:验证 label_ids 格式(仅允许数字和逗号,或列表/元组类型的数字) + """ + # 处理列表/元组类型的入参(如果前端传的是列表) + if isinstance(value, (list, tuple)): + # 先验证列表中的每个元素都是数字 + for item in value: + if not isinstance(item, (int, str)) or (isinstance(item, str) and not item.isdigit()): + raise serializers.ValidationError("label_ids 列表中仅允许包含数字") + label_ids_str = ",".join(map(str, value)) + else: + # 处理字符串类型的入参 + label_ids_str = str(value).strip() # 去除首尾空格 + + # 正则验证:仅包含数字和逗号,且不以逗号开头/结尾,也不是空字符串 + if not label_ids_str: + raise serializers.ValidationError("label_ids 不能为空") + if not re.match(r"^[\d,]+$", label_ids_str) or label_ids_str.startswith(",") or label_ids_str.endswith(","): + raise serializers.ValidationError("label_ids 格式非法,仅允许数字和逗号(如 1,2,3)") + + # 可选:去重 + 排序,保证格式统一 + label_ids_list = list(set(label_ids_str.split(","))) + label_ids_list.sort() + return ",".join(label_ids_list) diff --git a/bkflow/label/tests.py b/bkflow/label/tests.py new file mode 100644 index 0000000000..6d73b95e17 --- /dev/null +++ b/bkflow/label/tests.py @@ -0,0 +1,19 @@ +""" +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. +""" +# Create your tests here. diff --git a/bkflow/label/urls.py b/bkflow/label/urls.py new file mode 100644 index 0000000000..0b4b9a22ec --- /dev/null +++ b/bkflow/label/urls.py @@ -0,0 +1,29 @@ +""" +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.conf.urls import include, url +from rest_framework.routers import DefaultRouter + +from .views import LabelViewSet + +router = DefaultRouter() +router.register(r"", LabelViewSet) + +urlpatterns = [ + url("^", include(router.urls)), +] diff --git a/bkflow/label/views.py b/bkflow/label/views.py new file mode 100644 index 0000000000..5bb00234dd --- /dev/null +++ b/bkflow/label/views.py @@ -0,0 +1,216 @@ +""" +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 django_filters +from django.db.models import Count, Exists, OuterRef, Q +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, status +from rest_framework.decorators import action +from rest_framework.response import Response + +from bkflow.contrib.api.collections.task import TaskComponentClient +from bkflow.label.models import TemplateLabelRelation +from bkflow.label.permissions import LabelPermission +from bkflow.space.permissions import SpaceSuperuserPermission +from bkflow.utils.mixins import BKFLOWNoMaxLimitPagination +from bkflow.utils.permissions import AdminPermission +from bkflow.utils.views import AdminModelViewSet + +from .models import Label +from .serializers import LabelRefSerializer, LabelSerializer + + +class LabelFilter(django_filters.FilterSet): + """标签过滤集:指定必须过滤字段""" + + space_id = django_filters.NumberFilter(required=True, method="filter_space_id") + label_scope = django_filters.CharFilter(method="filter_label_scope") + parent_id = django_filters.NumberFilter(method="filter_parent_id") + name = django_filters.CharFilter(method="filter_name") + is_default = django_filters.BooleanFilter(method="filter_is_default") + + class Meta: + model = Label + fields = ["space_id", "label_scope", "parent_id", "name", "is_default"] + + def filter_space_id(self, queryset, name, value): + return queryset.filter(space_id__in=[-1, value]) + + def filter_label_scope(self, queryset, name, value): + return queryset.filter(Q(label_scope__contains=[value]) | Q(label_scope__contains=["common"])) + + def filter_parent_id(self, queryset, name, value): + return queryset.filter(parent_id=value) + + def filter_name(self, queryset, name, value): + return queryset.filter(name__icontains=value) + + def filter_is_default(self, queryset, name, value): + return queryset.filter(is_default=value) + + def filter_queryset(self, queryset): + """ + 重写父类方法,处理默认值逻辑 + 搜索兼容 + """ + queryset = super().filter_queryset(queryset) + + params = self.data + + has_parent_id = "parent_id" in params + + if not has_parent_id: + # 过滤出根标签 + root_label_ids = [] + for label in queryset: + if label.parent_id is None: + root_label_ids.append(label.id) + else: + root_label_ids.append(label.parent_id) + return self.Meta.model.objects.filter(id__in=root_label_ids) + + return queryset + + +class LabelViewSet(AdminModelViewSet): + """ + 标签管理 ViewSet + """ + + swagger_tags = ["label"] + queryset = Label.objects.all().order_by("-updated_at") + filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] + search_fields = ["name", "description"] + ordering_fields = ["created_at", "updated_at", "name"] + serializer_class = LabelSerializer + pagination_class = BKFLOWNoMaxLimitPagination + EDIT_ABOVE_ACTIONS = ["create", "update", "partial_update", "destroy"] + permission_classes = [AdminPermission | SpaceSuperuserPermission | LabelPermission] + + def get_filterset_class(self): + if self.action in ["list", "retrieve"]: + return LabelFilter + return None + + def get_queryset(self): + queryset = super().get_queryset() + child_subquery = Label.objects.filter(parent_id=OuterRef("pk")) + + filterset_class = self.get_filterset_class() + if filterset_class: + filterset = filterset_class(self.request.query_params, queryset=queryset, request=self.request) + if not filterset.is_valid(): + return queryset.none() + queryset = filterset.qs + + queryset = queryset.annotate(has_children=Exists(child_subquery)) + return queryset + + def create(self, request, *args, **kwargs): + """ + 覆盖 create 方法: + 1. 如果 name 是 '一级/二级' 格式,则查找或自动创建 '一级' 标签。 + 2. 将 '二级' 标签关联到其父级 ID 下。 + """ + data = request.data.copy() + name_parts = data.get("name", "").split("/") + + if len(name_parts) == 2: + parent_name = name_parts[0].strip() # 一级标签名称 + child_name = name_parts[1].strip() # 二级标签名称 + + parent_label_pk = None + + try: + # 尝试查找已存在的父标签 (一级标签,parent_id为空) + parent_label = Label.objects.get( + name=parent_name, space_id=data.get("space_id"), parent_id__isnull=True + ) + parent_label_pk = parent_label.pk + except Label.DoesNotExist: + # 如果一级标签不存在,则自动创建 + data["name"] = parent_name + parent_serializer = self.get_serializer(data=data) + # 校验并保存一级标签 + parent_serializer.is_valid(raise_exception=True) + parent_instance = parent_serializer.save() + parent_label_pk = parent_instance.pk + + # 填充 parent_id 并修正 name 字段 (用于创建二级标签) + data["parent_id"] = parent_label_pk + data["name"] = child_name + + # 创建一级/二级标签 + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def destroy(self, request, *args, **kwargs): + """删除标签,级联删除子标签""" + instance = self.get_object() + + need_delete_label_ids = [instance.pk] + # 如果是一级标签,删除所有子标签 + if instance.parent_id is None: + sub_label_ids = Label.objects.filter(parent_id=instance.pk).values_list("id", flat=True) + need_delete_label_ids.extend(sub_label_ids) + + # 删除任务标签关系通过任务组件接口完成,此处不做处理 + client = TaskComponentClient(space_id=instance.space_id) + result = client.delete_task_label_relation({"label_ids": need_delete_label_ids}) + if not result["result"]: + return Response(result, status=status.HTTP_400_BAD_REQUEST) + + # 删除模板标签关系 + TemplateLabelRelation.objects.filter(label_id__in=need_delete_label_ids).delete() + + Label.objects.filter(id__in=need_delete_label_ids).delete() + + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=False, methods=["get"], serializer_class=LabelRefSerializer) + def get_label_ref_count(self, request, *args, **kwargs): + """获取标签引用数量""" + ser = LabelRefSerializer(data=request.query_params) + ser.is_valid(raise_exception=True) + validated_params = ser.validated_data + + # 调用任务组件接口获取任务引用数量 + client = TaskComponentClient(space_id=validated_params["space_id"]) + result = client.get_task_label_ref_count(validated_params["space_id"], validated_params["label_ids"]) + if not result["result"]: + return Response(result) + label_task_ref_count_map = result["data"] + + # 获取模板引用数量 + label_ids = validated_params["label_ids"].split(",") + aggregation_qs = ( + TemplateLabelRelation.objects.filter(label_id__in=label_ids).values("label_id").annotate(count=Count("id")) + ) + label_template_count_map = {item["label_id"]: item["count"] for item in aggregation_qs} + + # 合并结果 + ref_result = {} + for label_id in label_ids: + ref_result[label_id] = { + "template_count": label_template_count_map.get(int(label_id), 0), + "task_count": label_task_ref_count_map.get(label_id, 0), + } + + return Response(ref_result) diff --git a/bkflow/permission/models.py b/bkflow/permission/models.py index 32ef89ad42..95bce876ee 100644 --- a/bkflow/permission/models.py +++ b/bkflow/permission/models.py @@ -42,6 +42,8 @@ class ResourceType(Enum): TEMPLATE = "TEMPLATE" # 作用域 SCOPE = "SCOPE" + # 标签 + LABEL = "LABEL" class PermissionType(Enum): @@ -85,6 +87,7 @@ class Token(models.Model): (ResourceType.TASK.value, _("任务")), (ResourceType.TEMPLATE.value, _("流程")), (ResourceType.SCOPE.value, _("作用域")), + (ResourceType.LABEL.value, _("标签")), ) PERMISSION_TYPE = ( diff --git a/bkflow/task/migrations/0015_auto_20251204_1656.py b/bkflow/task/migrations/0015_auto_20251204_1656.py new file mode 100644 index 0000000000..5a339fc328 --- /dev/null +++ b/bkflow/task/migrations/0015_auto_20251204_1656.py @@ -0,0 +1,46 @@ +""" +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.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("task", "0014_taskflowrelation"), + ] + + operations = [ + migrations.AlterModelOptions( + name="tasktreeinfo", + options={"ordering": ["-id"], "verbose_name": "任务流程树信息", "verbose_name_plural": "任务流程树信息"}, + ), + migrations.CreateModel( + name="TaskLabelRelation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("task_id", models.IntegerField(db_index=True, verbose_name="任务ID")), + ("label_id", models.IntegerField(db_index=True, verbose_name="标签ID")), + ], + options={ + "verbose_name": "任务标签关系 TaskLabelRelation", + "verbose_name_plural": "任务标签关系 TaskLabelRelation", + "unique_together": {("task_id", "label_id")}, + }, + ), + ] diff --git a/bkflow/task/models.py b/bkflow/task/models.py index 61f4c41d17..92dd5f779e 100644 --- a/bkflow/task/models.py +++ b/bkflow/task/models.py @@ -16,6 +16,7 @@ to the current version of the project delivered to anyone in the future. """ +from collections import defaultdict import json import logging @@ -600,3 +601,73 @@ class TaskFlowRelation(models.Model): class Meta: verbose_name = verbose_name_plural = _("任务关系") + + +class BaseLabelRelationManager(models.Manager): + """ + 标签关系管理器 + """ + + def set_labels(self, obj_id, label_ids): + """ + 设置对象的标签(增量更新) + """ + # 1. 构造查询参数,例如: {"template_id": 1} 或 {"task_id": 1} + filter_kwargs = {"task_id": obj_id} + + # 2. 获取已有标签 + existing_labels = self.filter(**filter_kwargs).values_list("label_id", flat=True) + + # 3. 计算差异 + existing_set = set(existing_labels) + new_set = set(label_ids) + + add_ids = list(new_set - existing_set) + remove_ids = list(existing_set - new_set) + + # 4. 执行删除 + if remove_ids: + # 构造删除查询: template_id=1, label_id__in=[...] + delete_kwargs = { + "task_id": obj_id, + "label_id__in": remove_ids + } + self.filter(**delete_kwargs).delete() + + # 5. 执行批量添加 + if add_ids: + # 动态创建模型实例: TaskLabelRelation(task_id=1, label_id=xx) + new_relations = [ + self.model(**{"task_id": obj_id, "label_id": label_id}) + for label_id in add_ids + ] + self.bulk_create(new_relations) + + def fetch_tasks_labels(self, task_ids): + """ + 批量获取多个对象的标签字典 + 返回格式: {obj_id: [label_dict, ...]} + """ + filter_kwargs = {"task_id__in": task_ids} + relations = self.filter(**filter_kwargs).values("task_id", "label_id") + + if not relations: + return {} + + result = defaultdict(list) + for rel in relations: + result[rel["task_id"]].append(rel["label_id"]) + + return dict(result) + + +class TaskLabelRelation(models.Model): + task_id = models.BigIntegerField(verbose_name=_("任务ID"), db_index=True) + label_id = models.IntegerField(verbose_name=_("标签ID"), db_index=True) + + objects = BaseLabelRelationManager() + + class Meta: + verbose_name = _("任务标签关系 TaskLabelRelation") + verbose_name_plural = _("任务标签关系 TaskLabelRelation") + unique_together = ("task_id", "label_id") diff --git a/bkflow/task/serializers.py b/bkflow/task/serializers.py index ea72778e5d..7301da2463 100644 --- a/bkflow/task/serializers.py +++ b/bkflow/task/serializers.py @@ -68,6 +68,7 @@ class CreateTaskInstanceSerializer(serializers.ModelSerializer): pipeline_tree = serializers.JSONField(required=True) constants = serializers.JSONField(required=False, default={}) mock_data = serializers.JSONField(required=False, default={}) + label_ids = serializers.ListField(required=False, child=serializers.IntegerField()) def validate(self, value): if value.get("extra_info", {}).get("notify_config") is not None: @@ -112,6 +113,7 @@ class Meta: "scope_value", "constants", "extra_info", + "label_ids", ] @@ -288,3 +290,12 @@ def validate_cron(self, cron_data): class BatchDeletePeriodicTaskSerializer(serializers.Serializer): trigger_ids = serializers.ListField(child=serializers.IntegerField(), help_text="触发器ID列表", required=True) + + +class LabelRefSerializer(serializers.Serializer): + label_ids = serializers.CharField(help_text="标签ID", required=True) + space_id = serializers.IntegerField(help_text="空间ID", required=True) + + +class DeleteTaskLabelRelationSerializer(serializers.Serializer): + label_ids = serializers.ListField(child=serializers.IntegerField(), help_text="标签ID", required=True) diff --git a/bkflow/task/views.py b/bkflow/task/views.py index 42232af140..6c0cac8251 100644 --- a/bkflow/task/views.py +++ b/bkflow/task/views.py @@ -20,8 +20,9 @@ from blueapps.account.decorators import login_exempt from django.conf import settings +from django.db.models import Count, Subquery from django.utils.decorators import method_decorator -from django_filters import FilterSet +from django_filters import CharFilter, FilterSet from django_filters.rest_framework import DjangoFilterBackend from drf_yasg.utils import swagger_auto_schema from rest_framework import mixins, status @@ -48,6 +49,7 @@ EngineSpaceConfigValueType, PeriodicTask, TaskInstance, + TaskLabelRelation, TaskMockData, TaskOperationRecord, ) @@ -57,9 +59,11 @@ BatchDeletePeriodicTaskSerializer, CreatePeriodicTaskSerializer, CreateTaskInstanceSerializer, + DeleteTaskLabelRelationSerializer, EngineSpaceConfigSerializer, GetEngineSpaceConfigSerializer, GetTaskOperationRecordSerializer, + LabelRefSerializer, NodeSnapshotQuerySerializer, NodeSnapshotResponseSerializer, PeriodicTaskSerializer, @@ -76,6 +80,8 @@ class TaskInstanceFilterSet(FilterSet): + label = CharFilter(method="filter_by_labels") + class Meta: model = TaskInstance fields = { @@ -96,6 +102,23 @@ class Meta: "is_finished": ["exact"], } + def filter_by_labels(self, queryset, name, value): + """ + 根据逗号分隔的 label_id 字符串过滤任务。 + URL Query Param 示例: ?label=1,2,3 + """ + try: + label_ids = [int(lid) for lid in value.split(",")] + except ValueError: + return queryset.none() + + if not label_ids: + return queryset + + task_ids_subquery = TaskLabelRelation.objects.filter(label_id__in=label_ids).values("task_id") + + return queryset.filter(id__in=Subquery(task_ids_subquery)) + def validate_task_info(func): @wraps(func) @@ -148,11 +171,60 @@ def get_serializer_class(self): return RetrieveTaskInstanceSerializer return super().get_serializer_class() + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + + serializer = self.get_serializer(page, many=True) + task_ids = [task["id"] for task in serializer.data] + tasks_labels = TaskLabelRelation.objects.fetch_tasks_labels(task_ids) + for task in serializer.data: + task["labels"] = tasks_labels.get(task["id"], []) + + return self.get_paginated_response(serializer.data) + + @action(detail=True, methods=["post"], url_path="update_labels") + def update_labels(self, request, *args, **kwargs): + task_instance = self.get_object() + label_ids = request.data.get("label_ids", []) + TaskLabelRelation.objects.set_labels(task_instance.id, label_ids) + return Response(label_ids) + + @action(detail=False, methods=["get"], serializer_class=LabelRefSerializer) + def get_task_label_ref_count(self, request, *args, **kwargs): + """获取标签引用数量""" + ser = LabelRefSerializer(data=request.query_params) + ser.is_valid(raise_exception=True) + validated_params = ser.validated_data + label_ids = validated_params["label_ids"].split(",") + queryset = ( + TaskLabelRelation.objects.filter(label_id__in=label_ids).values("label_id").annotate(count=Count("id")) + ) + label_template_count_map = {item["label_id"]: item["count"] for item in queryset} + result = {} + for label_id in label_ids: + result[label_id] = label_template_count_map.get(int(label_id), 0) + + return Response(result) + + @action(detail=False, methods=["post"], serializer_class=DeleteTaskLabelRelationSerializer) + def delete_task_label_relation(self, request, *args, **kwargs): + """删除任务标签关联""" + ser = DeleteTaskLabelRelationSerializer(data=request.data) + ser.is_valid(raise_exception=True) + validated_params = ser.validated_data + label_ids = validated_params["label_ids"] + TaskLabelRelation.objects.filter(label_id__in=label_ids).delete() + return Response({"label_ids": label_ids}) + @record_operation(RecordType.task.name, TaskOperationType.create.name, TaskOperationSource.api.name) def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + label_ids = serializer.validated_data.pop("label_ids", []) instance = TaskInstance.objects.create_instance(**serializer.validated_data) + TaskLabelRelation.objects.set_labels(instance.id, label_ids) new_serializer = TaskInstanceSerializer(instance) headers = self.get_success_headers(new_serializer.data) response_data = new_serializer.data @@ -176,6 +248,7 @@ def batch_delete(self, request, *args, **kwargs): else: task_ids = serializer.validated_data["task_ids"] TaskInstance.objects.filter(space_id=space_id, id__in=task_ids, is_deleted=False).update(is_deleted=True) + TaskLabelRelation.objects.filter(task_id__in=task_ids).delete() return Response({"result": True, "data": None, "message": "success"}) @swagger_auto_schema(methods=["post"], operation_description="任务操作", request_body=EmptyBodySerializer) diff --git a/bkflow/template/serializers/template.py b/bkflow/template/serializers/template.py index 45f240db63..7ba0622cb0 100644 --- a/bkflow/template/serializers/template.py +++ b/bkflow/template/serializers/template.py @@ -35,6 +35,7 @@ WebhookEventType, WebhookScopeType, ) +from bkflow.label.models import Label, TemplateLabelRelation from bkflow.permission.models import TEMPLATE_PERMISSION_TYPE, Token from bkflow.pipeline_web.preview_base import PipelineTemplateWebPreviewer from bkflow.space.configs import FlowVersioning, TemplateTriggerConfig @@ -95,6 +96,7 @@ class TemplateSerializer(serializers.ModelSerializer): desc = serializers.CharField(help_text=_("流程说明"), required=False, allow_blank=True, allow_null=True) triggers = TriggerSerializer(many=True, required=True, allow_null=True) subprocess_info = serializers.JSONField(help_text=_("子流程信息"), read_only=True) + labels = serializers.CharField(help_text=_("模板标签"), required=False, allow_blank=True, allow_null=True) def validate_space_id(self, space_id): if not Space.objects.filter(id=space_id).exists(): @@ -163,10 +165,26 @@ def create(self, validated_data): ) return template + def _sync_template_lables(self, template_id, label_ids): + """ + 创建或更新模板时同步模板标签数据 + """ + label_ids = list(set(label_ids)) + if not Label.objects.check_label_ids(label_ids): + message = _("流程保存失败: 流程设置的标签不存在, 请检查配置后重试") + logger.error(message) + raise serializers.ValidationError(message) + try: + TemplateLabelRelation.objects.set_labels(template_id, label_ids) + except Exception as e: + logger.error("TemplateLabelRelation set_labels error: {}".format(e)) + raise serializers.ValidationError(_("流程保存失败: 标签设置失败, 请检查配置后重试")) + @transaction.atomic() def update(self, instance, validated_data): # TODO: 需要校验哪些字段是不可以更新的 pipeline_tree = validated_data.pop("pipeline_tree", None) + template_labels = validated_data.pop("labels", []) # 检查新建任务的流程中是否有未二次授权的蓝鲸插件 try: exist_code_list = [ @@ -192,6 +210,7 @@ def update(self, instance, validated_data): snapshot.template_id = instance.id snapshot.save(update_fields=["template_id"]) instance = super().update(instance, validated_data) + self._sync_template_lables(instance.id, template_labels) # 批量修改流程绑定的触发器: try: Trigger.objects.compare_constants( @@ -362,3 +381,7 @@ class Meta: "operator", "md5sum", ] + + +class TemplateUpdateLabelSerializer(serializers.Serializer): + label_ids = serializers.ListField(help_text=_("标签ID列表"), required=True, child=serializers.IntegerField()) diff --git a/bkflow/template/views/template.py b/bkflow/template/views/template.py index b81667da35..fec7df38fc 100644 --- a/bkflow/template/views/template.py +++ b/bkflow/template/views/template.py @@ -22,9 +22,10 @@ import django_filters from blueapps.account.decorators import login_exempt from django.db import transaction +from django.db.models import Subquery from django.utils.decorators import method_decorator from django.utils.translation import ugettext_lazy as _ -from django_filters.rest_framework import DjangoFilterBackend, FilterSet +from django_filters.rest_framework import CharFilter, DjangoFilterBackend, FilterSet from drf_yasg.utils import swagger_auto_schema from rest_framework import mixins from rest_framework.decorators import action @@ -49,6 +50,7 @@ from bkflow.contrib.operation_record.decorators import record_operation from bkflow.decision_table.models import DecisionTable from bkflow.exceptions import APIResponseError, ValidationError +from bkflow.label.models import Label, TemplateLabelRelation from bkflow.pipeline_web.drawing_new.constants import CANVAS_WIDTH, POSITION from bkflow.pipeline_web.drawing_new.drawing import draw_pipeline as draw_pipeline_tree from bkflow.pipeline_web.preview import preview_template_tree @@ -96,6 +98,7 @@ TemplateReleaseSerializer, TemplateSerializer, TemplateSnapshotSerializer, + TemplateUpdateLabelSerializer, ) from bkflow.template.utils import analysis_pipeline_constants_ref from bkflow.utils.mixins import BKFLOWCommonMixin, BKFLOWNoMaxLimitPagination @@ -108,6 +111,8 @@ class TemplateFilterSet(FilterSet): + label = CharFilter(method="filter_by_labels") + class Meta: model = Template fields = { @@ -123,6 +128,20 @@ class Meta: "update_at": ["gte", "lte"], } + def filter_by_labels(self, queryset, name, value): + """ + 根据逗号/加号/换行分隔的 label 字符串过滤任务。 + URL Query Param 示例: ?label=tag1,tag2+tag3\ntag4 + """ + # 支持逗号、加号或换行分隔,并去除空项与两端空白 + label_ids = Label.get_label_ids_by_names(value) + if not label_ids: + return queryset + + ttemplate_ids_subquery = TemplateLabelRelation.objects.filter(label_id__in=label_ids).values("template_id") + + return queryset.filter(id__in=Subquery(ttemplate_ids_subquery)) + class TemplateSnapshotFilterSet(FilterSet): desc = django_filters.CharFilter(field_name="desc", lookup_expr="icontains") @@ -150,11 +169,14 @@ def list(self, request, *args, **kwargs): serializer = self.get_serializer(page if page is not None else queryset, many=True) data = [] has_trigger_template_ids = set(Trigger.objects.all().values_list("template_id", flat=True)) + template_ids = [obj["id"] for obj in serializer.data] + templates_labels = TemplateLabelRelation.objects.fetch_objects_labels(template_ids) for template in serializer.data: if template["id"] in has_trigger_template_ids: template["has_interval_trigger"] = True else: template["has_interval_trigger"] = False + template["labels"] = templates_labels.get(template["id"], []) data.append(template) if page is not None: return self.get_paginated_response(data) @@ -169,6 +191,7 @@ def create_template(self, request, space_id, *args, **kwargs): pipeline_tree = build_default_pipeline_tree_with_space_id(space_id) # 涉及到两张表的创建,需要那个开启事物,确保两张表全部都创建成功 with transaction.atomic(): + label_ids = ser.validated_data.pop("label_ids", []) username = request.user.username if SpaceConfig.get_config(space_id=space_id, config_name=FlowVersioning.name) == "true": snapshot = TemplateSnapshot.create_draft_snapshot(pipeline_tree, username) @@ -179,8 +202,14 @@ def create_template(self, request, space_id, *args, **kwargs): ) snapshot.template_id = template.id snapshot.save(update_fields=["template_id"]) + # 同步标签 + TemplateLabelRelation.objects.set_labels(template.id, label_ids) + return Response({"result": True, "data": template.to_json()}) + def update(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + @swagger_auto_schema(method="POST", operation_description="创建任务", request_body=CreateTaskSerializer) @action(methods=["POST"], detail=False, url_path="create_task/(?P\\d+)") def create_task(self, request, space_id, *args, **kwargs): @@ -303,6 +332,7 @@ def batch_delete(self, request, *args, **kwargs): "id", flat=True ) Trigger.objects.batch_delete_by_ids(space_id=space_id, trigger_ids=list(trigger_ids), is_full=is_full) + TemplateLabelRelation.objects.filter(template_id__in=template_ids).delete() return Response({"delete_num": update_num}) @swagger_auto_schema(method="POST", operation_description="流程模版复制", request_body=TemplateCopySerializer) @@ -427,7 +457,24 @@ def list_template(self, request, *args, **kwargs): @record_operation(RecordType.template.name, TemplateOperationType.update.name, TemplateOperationSource.app.name) def update(self, request, *args, **kwargs): - return super().update(request, *args, **kwargs) + partial = kwargs.pop("partial", False) + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + labels = TemplateLabelRelation.objects.fetch_labels(instance.id) + data = deepcopy(serializer.data) + data["labels"] = labels + return Response(data) + + @swagger_auto_schema(method="POST", operation_description="更新标签", request_body=TemplateUpdateLabelSerializer) + @action(detail=True, methods=["post"], url_path="update_labels") + def update_labels(self, request, *args, **kwargs): + task_instance = self.get_object() + label_ids = request.data.get("label_ids", []) + with transaction.atomic(): + TemplateLabelRelation.objects.set_labels(task_instance.id, label_ids) + return Response(label_ids) @action(methods=["POST"], detail=False) def analysis_constants_ref(self, request, *args, **kwargs): diff --git a/bkflow/urls.py b/bkflow/urls.py index b8c5240f50..f1b34c80f3 100644 --- a/bkflow/urls.py +++ b/bkflow/urls.py @@ -41,6 +41,7 @@ url(r"^api/plugin_query/", include("bkflow.pipeline_plugins.query.urls")), url(r"^api/plugin_service/", include("plugin_service.urls")), url(r"^api/api_plugin_demo/", include("bkflow.api_plugin_demo.urls")), + url(r"^api/label/", include("bkflow.label.urls")), url(r"^notice/", include("bk_notice_sdk.urls")), url(r"^version_log/", include("version_log.urls", namespace="version_log")), ] diff --git a/frontend/src/config/i18n/cn.js b/frontend/src/config/i18n/cn.js index 1578a507a9..4232426d77 100644 --- a/frontend/src/config/i18n/cn.js +++ b/frontend/src/config/i18n/cn.js @@ -396,7 +396,7 @@ const cn = { '数据格式不正确,应为JSON格式': '数据格式不正确,应为JSON格式', '确认" {0} "恢复默认值?': '确认" {0} "恢复默认值?', '删除成功!': '删除成功!', - 'ID/任务名称/创建人/执行人/所属模板 ID/所属作用域类型/所属作用域值': 'ID/任务名称/创建人/执行人/所属模板 ID/所属作用域类型/所属作用域值', + 'ID/任务名称/标签/创建人/执行人/所属模板 ID/所属作用域类型/所属作用域值': 'ID/任务名称/标签/创建人/执行人/所属模板 ID/所属作用域类型/所属作用域值', 引擎操作: '引擎操作', 任务名称: '任务名称', 执行人: '执行人', @@ -413,7 +413,7 @@ const cn = { 新建流程: '新建流程', 流程名称: '流程名称', 流程创建成功: '流程创建成功', - 'ID/流程名称/创建人/更新人/启用/所属作用域类型/所属作用域值': 'ID/流程名称/创建人/更新人/启用/所属作用域类型/所属作用域值', + 'ID/流程名称/标签/创建人/更新人/启用/所属作用域类型/所属作用域值': 'ID/流程名称/标签/创建人/更新人/启用/所属作用域类型/所属作用域值', 创建流程: '创建流程', '当前已选择 x 条数据': '当前已选择 {num} 条数据', ',': ',', @@ -1059,6 +1059,23 @@ const cn = { 参数名不能为空: '参数名不能为空', 参数名不能重复: '参数名不能重复', '请输入值或 $ 选择变量': '请输入值或 $ 选择变量', + labelReference: '{0} 个流程在引用,{1} 个任务在引用', + '搜索标签名称、标签范围、系统默认标签': '搜索标签名称、标签范围、系统默认标签', + 新增标签: '新增标签', + 标签名称: '标签名称', + 标签描述: '标签描述', + 标签范围: '标签范围', + 标签引用: '标签引用', + 系统默认标签: '系统默认标签', + 是: '是', + 否: '否', + 任务: '任务', + 流程: '流程', + '确认删除该标签?': '确认删除该标签?', + 关联的流程将同时移出本标签: '关联的流程将同时移出本标签', + '标签删除成功!': '标签删除成功!', + 编辑标签: '编辑标签', + 标签颜色: '标签颜色', }; export default cn; diff --git a/frontend/src/config/i18n/en.js b/frontend/src/config/i18n/en.js index 9ba229c05f..4ffb75a47f 100644 --- a/frontend/src/config/i18n/en.js +++ b/frontend/src/config/i18n/en.js @@ -396,7 +396,7 @@ const en = { '数据格式不正确,应为JSON格式': 'Incorrect data format, should be in JSON format', '确认" {0} "恢复默认值?': 'Confirm to reset value for "{0}"?', '删除成功!': 'Deleted Successfully!', - 'ID/任务名称/创建人/执行人/所属模板 ID/所属作用域类型/所属作用域值': 'ID/Task Name/Created by/Executor/Template ID/Scope Type/Scope Value', + 'ID/任务名称/标签/创建人/执行人/所属模板 ID/所属作用域类型/所属作用域值': 'ID/Task Name/Label/Created by/Executor/Template ID/Scope Type/Scope Value', 引擎操作: 'Engine Operation', 任务名称: 'Task Name', 执行人: 'Executor', @@ -413,7 +413,7 @@ const en = { 新建流程: 'Add', 流程名称: 'Process Name', 流程创建成功: 'Process Created Successfully', - 'ID/流程名称/创建人/更新人/启用/所属作用域类型/所属作用域值': 'ID/Flow Name/Created by/Updater/Enabled/Scope Type/Scope Value', + 'ID/流程名称/标签/创建人/更新人/启用/所属作用域类型/所属作用域值': 'ID/Flow Name/Label/Created by/Updater/Enabled/Scope Type/Scope Value', 创建流程: 'Add', '当前已选择 x 条数据': 'Currently Selected {num} Items', ',': ', ', @@ -1057,6 +1057,23 @@ const en = { 参数名不能为空: 'Parameter name cannot be empty', 参数名不能重复: 'Parameter name cannot be duplicated', '请输入值或 $ 选择变量': 'Enter value or $ to select variable', + labelReference: '{0} template is being referenced,{1} task is being referenced', + '搜索标签名称、标签范围、系统默认标签': 'Search Label Name, Label Scope, System Default Label', + 新增标签: 'Add Label', + 标签名称: 'Label Name', + 标签描述: 'Label Description', + 标签范围: 'Label Scope', + 标签引用: 'Label Reference', + 系统默认标签: 'System Default Label', + 是: 'Yes', + 否: 'No', + 任务: 'Task', + 流程: 'Template', + '确认删除该标签?': 'Confirm delete this label?', + 关联的流程将同时移出本标签: 'The associated process will also be removed from this label', + '标签删除成功!': 'Label deleted successfully!', + 编辑标签: 'Edit Label', + 标签颜色: 'Label Color', }; export default en; diff --git a/frontend/src/constants/index.js b/frontend/src/constants/index.js index 039873ec8d..eaf9489a5a 100644 --- a/frontend/src/constants/index.js +++ b/frontend/src/constants/index.js @@ -165,6 +165,12 @@ const COLOR_BLOCK_LIST = [ }, ]; +// 标签范围 +const LABEL_SCOPE = { + template: i18n.t('流程'), + task: i18n.t('任务'), +}; + const NAME_REG = /^[^'"‘’“”$<>]+$/; const PACKAGE_NAME_REG = /^[^\d][\w]*?$/; // celery的crontab时间表达式正则表达式(分钟 小时 星期 日 月)(以空格分割) @@ -178,5 +184,5 @@ const URL_REG = new RegExp('^(https?|ftp|file)://[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[- export { TASK_STATE_DICT, NODE_DICT, SYSTEM_GROUP_ICON, BK_PLUGIN_ICON, NAME_REG, INVALID_NAME_CHAR, PACKAGE_NAME_REG, URL_REG, PERIODIC_REG, STRING_LENGTH, - LABEL_COLOR_LIST, DARK_COLOR_LIST, TASK_CATEGORIES, COLOR_BLOCK_LIST, + LABEL_COLOR_LIST, DARK_COLOR_LIST, TASK_CATEGORIES, COLOR_BLOCK_LIST, LABEL_SCOPE, }; diff --git a/frontend/src/constants/route.js b/frontend/src/constants/route.js index 21697c7097..efec90906f 100644 --- a/frontend/src/constants/route.js +++ b/frontend/src/constants/route.js @@ -40,6 +40,12 @@ export const SPACE_LIST = [ id: 'credential', disabled: false, }, + { + name: i18n.t('标签管理'), + icon: 'common-icon-credential', + id: 'labelManage', + disabled: false, + }, ]; export const MODULES_LIST = [ diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index 393d366e4a..b8a0be9509 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -21,6 +21,7 @@ import decisionTable from './modules/decisionTable'; import credentialConfig from './modules/credentialConfig'; import plugin from './modules/plugin'; import stageCanvas from './modules/stageCanvas'; +import label from './modules/label'; Vue.use(Vuex); const getAppLang = () => { @@ -44,6 +45,7 @@ const store = new Vuex.Store({ credentialConfig, plugin, stageCanvas, + label, }, // 公共 store state: { diff --git a/frontend/src/store/modules/label.js b/frontend/src/store/modules/label.js new file mode 100644 index 0000000000..1f949b37a9 --- /dev/null +++ b/frontend/src/store/modules/label.js @@ -0,0 +1,36 @@ +/** +* Tencent is pleased to support the open source community by making 蓝鲸智云PaaS平台社区版 (BlueKing PaaS Community +* Edition) available. +* Copyright (C) 2017 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. +*/ +import axios from 'axios'; + +export default { + namespaced: true, + actions: { + // 获取标签列表 二级标签传入parent_id + loadLabelList({}, params) { + return axios.get('/api/label/', { params }).then(response => response.data); + }, + createLabel({}, data) { + return axios.post('/api/label/', data).then(response => response.data); + }, + updateLabel({}, { id, data }) { + return axios.put(`/api/label/${id}/`, data).then(response => response.data); + }, + deleteLabel({}, id) { + return axios.delete(`/api/label/${id}/`).then(response => response.data); + }, + // 获取标签引用参数 + loadLabelReference({}, params) { + return axios.get('/api/label/get_label_ref_count/', { params }).then(response => response.data); + }, + }, +}; + diff --git a/frontend/src/store/modules/taskList.js b/frontend/src/store/modules/taskList.js index 33ab0f6e30..ce5f6428a2 100644 --- a/frontend/src/store/modules/taskList.js +++ b/frontend/src/store/modules/taskList.js @@ -34,6 +34,9 @@ const taskList = { deleteTask({}, data) { return axios.post('/task_admin/batch_delete_tasks/', data).then(response => response.data); }, + updateTaskLabel({}, data) { + return axios.post(`/task_admin/update_labels/${data.space_id}/${data.task_id}/`, { label_ids: data.label_ids }).then(response => response.data); + }, }, }; diff --git a/frontend/src/store/modules/template.js b/frontend/src/store/modules/template.js index 1bf93ec7c2..5fa8e8aadb 100644 --- a/frontend/src/store/modules/template.js +++ b/frontend/src/store/modules/template.js @@ -9,7 +9,7 @@ * 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. */ -import Vue, { ref } from 'vue'; +import Vue, { ref } from 'vue'; import nodeFilter from '@/utils/nodeFilter.js'; import { uuid, random4 } from '@/utils/uuid.js'; import tools from '@/utils/tools.js'; @@ -186,7 +186,7 @@ const template = { category: '', description: '', executor_proxy: '', - template_labels: '', + template_labels: [], subprocess_info: { details: [], subproc_has_update: false, @@ -202,6 +202,9 @@ const template = { setTemplateName(state, name) { state.name = name; }, + setTemplateLabel(state, labels) { + state.template_labels = labels; + }, setReceiversGroup(state, data) { state.notify_receivers.receiver_group = data; }, @@ -326,7 +329,6 @@ const template = { name, id: templateId, pipeline_tree: pipelineData, - template_labels: templateLabels, notify_config: notifyConfig, description, executor_proxy: executorProxy, @@ -351,7 +353,6 @@ const template = { state.notify_type = typeof notifyType === 'string' ? { success: JSON.parse(notifyType), fail: [] } : notifyType; state.description = description; state.executor_proxy = executorProxy; - state.template_labels = templateLabels || []; state.time_out = timeOut; state.category = category; state.subprocess_info = subprocessInfo; @@ -396,7 +397,6 @@ const template = { }; state.description = ''; state.executor_proxy = ''; - state.template_labels = []; state.default_flow_type = 'common'; }, // 重置模板数据 @@ -1050,7 +1050,7 @@ const template = { timeout, description, executor_proxy, - template_labels, + labels: template_labels.map(label => label.id), default_flow_type, pipeline_tree: pipelineTree, space_id: spaceId, diff --git a/frontend/src/store/modules/templateList.js b/frontend/src/store/modules/templateList.js index d0f9c7e8d1..dff502c911 100644 --- a/frontend/src/store/modules/templateList.js +++ b/frontend/src/store/modules/templateList.js @@ -37,6 +37,9 @@ const templateList = { copyTemplate({}, data) { return axios.post('/api/template/admin/template_copy/', data).then(response => response.data); }, + updateTemplateLabel({}, data) { + return axios.post(`/api/template/${data.template_id}/update_labels/`, { label_ids: data.label_ids }).then(response => response.data); + }, }, }; diff --git a/frontend/src/views/admin/Space/Task/index.vue b/frontend/src/views/admin/Space/Task/index.vue index ad2a3439fa..5352fd96bb 100644 --- a/frontend/src/views/admin/Space/Task/index.vue +++ b/frontend/src/views/admin/Space/Task/index.vue @@ -3,7 +3,11 @@ @@ -12,7 +16,7 @@ theme="primary" :disabled="!spaceId" @click="onEngineOperate"> - {{ $t('引擎操作') }} + {{ $t("引擎操作") }} @@ -73,7 +120,7 @@ theme="primary" text @click="onDeleteTask(props.row)"> - {{ $t('删除') }} + {{ $t("删除") }} @@ -97,355 +144,435 @@ diff --git a/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue b/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue index ab3ca2f73a..20d5c1ef8c 100644 --- a/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue +++ b/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue @@ -27,14 +27,22 @@ property="constants">
- {{ $t('表单模式') }} + {{ $t("表单模式") }} - {{ $t('json模式') }} + {{ $t("json模式") }}
- {{ $t('查看未引用变量') }} + {{ $t("查看未引用变量") }}
+ :options="{ + language: 'json', + placeholder: { '${key}': 'value' }, + }" />

+ + + + + diff --git a/frontend/src/views/admin/Space/Template/CreateTemplateDialog.vue b/frontend/src/views/admin/Space/Template/CreateTemplateDialog.vue index ea057b1e47..35d4783ca9 100644 --- a/frontend/src/views/admin/Space/Template/CreateTemplateDialog.vue +++ b/frontend/src/views/admin/Space/Template/CreateTemplateDialog.vue @@ -42,19 +42,49 @@ :maxlength="64" :show-word-limit="true" /> + + + + + - {{ $t('提交') }} + {{ $t("提交") }} - {{ $t('取消') }} + {{ $t("取消") }} @@ -62,102 +92,161 @@ diff --git a/frontend/src/views/admin/Space/Template/index.vue b/frontend/src/views/admin/Space/Template/index.vue index a463f18a84..8ba91ff6a8 100644 --- a/frontend/src/views/admin/Space/Template/index.vue +++ b/frontend/src/views/admin/Space/Template/index.vue @@ -3,7 +3,9 @@ @@ -12,12 +14,12 @@ :disabled="!spaceId" class="mr10" @click="showCreateTplDialog = true"> - {{ $t('创建流程') }} + {{ $t("创建流程") }} - {{ $t('删除') }} + {{ $t("删除") }} @@ -45,21 +51,52 @@ :prop="item.id" :render-header="renderTableHeader" :width="item.width" - show-overflow-tooltip + :show-overflow-tooltip="item.id !== 'label'" :min-width="item.min_width"> @@ -120,21 +167,21 @@ theme="primary" text @click="onCreateTask(props.row)"> - {{ $t('新建任务') }} + {{ $t("新建任务") }} - {{ $t('复制') }} + {{ $t("复制") }} - {{ $t('删除') }} + {{ $t("删除") }} @@ -149,11 +196,12 @@ v-if="selectedTpls.length > 0" slot="prepend" class="selected-tpl-num"> - {{ $t('当前已选择 x 条数据', { num: selectedTpls.length }) }}{{ $t(',') }} + {{ $t("当前已选择 x 条数据", { num: selectedTpls.length }) + }}{{ $t(",") }} - {{ $t('清除选择') }} + {{ $t("清除选择") }}
- {{ $t('是否复制该流程?') }} + {{ $t("是否复制该流程?") }}
-
{{ $t('关联的mock数据不会同步复制,暂不支持复制带有决策表节点的流程') }}
+
+ {{ + $t( + "关联的mock数据不会同步复制,暂不支持复制带有决策表节点的流程" + ) + }} +
- - {{ $t('复制包含的子流程') }} + + {{ $t("复制包含的子流程") }} @@ -213,25 +266,52 @@ -
- {{ $t('删除失败') }} +
+ {{ $t("删除失败") }}
-
- - +
+
{{ $t("当前流程被以下流程引用:") }}
+
+ + + + + +
@@ -239,515 +319,556 @@ + diff --git a/frontend/src/views/admin/Space/Template/label-cell.vue b/frontend/src/views/admin/Space/Template/label-cell.vue new file mode 100644 index 0000000000..68e1445722 --- /dev/null +++ b/frontend/src/views/admin/Space/Template/label-cell.vue @@ -0,0 +1,191 @@ + + + + + + diff --git a/frontend/src/views/admin/Space/common/LabelCascade.vue b/frontend/src/views/admin/Space/common/LabelCascade.vue new file mode 100644 index 0000000000..1dedb0f22d --- /dev/null +++ b/frontend/src/views/admin/Space/common/LabelCascade.vue @@ -0,0 +1,441 @@ + + + + + + + diff --git a/frontend/src/views/admin/Space/index.vue b/frontend/src/views/admin/Space/index.vue index 2b4aa12952..79b8f21831 100644 --- a/frontend/src/views/admin/Space/index.vue +++ b/frontend/src/views/admin/Space/index.vue @@ -14,6 +14,7 @@ import SpaceConfigList from './SpaceConfig/index.vue'; import DecisionTable from './DecisionTable/index.vue'; import CredentialList from './Credential/index.vue'; + import LabelManage from './labelManage/index.vue'; import { mapState } from 'vuex'; export default { @@ -24,6 +25,7 @@ SpaceConfigList, DecisionTable, CredentialList, + LabelManage, }, data() { const { activeTab = 'template' } = this.$route.query; @@ -41,7 +43,7 @@ component = tab === 'decisionTable' ? 'DecisionTable' : component; component = tab === 'template' ? 'TemplateList' : component; component = tab === 'credential' ? 'CredentialList' : component; - + component = tab === 'labelManage' ? 'LabelManage' : component; return component; }, }, diff --git a/frontend/src/views/admin/Space/labelManage/CreateLabelDialog.vue b/frontend/src/views/admin/Space/labelManage/CreateLabelDialog.vue new file mode 100644 index 0000000000..40b7a038bb --- /dev/null +++ b/frontend/src/views/admin/Space/labelManage/CreateLabelDialog.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/frontend/src/views/admin/Space/labelManage/index.vue b/frontend/src/views/admin/Space/labelManage/index.vue new file mode 100644 index 0000000000..7de8ffc648 --- /dev/null +++ b/frontend/src/views/admin/Space/labelManage/index.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/frontend/src/views/template/TemplateEdit/TemplateSetting/TabTemplateConfig.vue b/frontend/src/views/template/TemplateEdit/TemplateSetting/TabTemplateConfig.vue index 973fb01b38..19fb449a7f 100644 --- a/frontend/src/views/template/TemplateEdit/TemplateSetting/TabTemplateConfig.vue +++ b/frontend/src/views/template/TemplateEdit/TemplateSetting/TabTemplateConfig.vue @@ -1,14 +1,13 @@ -/** -* Tencent is pleased to support the open source community by making 蓝鲸智云PaaS平台社区版 (BlueKing PaaS Community -* Edition) available. -* Copyright (C) 2017 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. -*/ +/** * Tencent is pleased to support the open source community by making +蓝鲸智云PaaS平台社区版 (BlueKing PaaS Community * Edition) available. * +Copyright (C) 2017 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. */