Date: Tue, 27 Jan 2026 16:11:59 +0800
Subject: [PATCH 04/13] =?UTF-8?q?fix:=20=E6=B5=81=E7=A8=8B=E5=92=8C?=
=?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=88=97=E8=A1=A8=E6=B7=BB=E5=8A=A0=E6=A0=87?=
=?UTF-8?q?=E7=AD=BE=E6=90=9C=E7=B4=A2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/config/i18n/cn.js | 4 ++--
frontend/src/config/i18n/en.js | 4 ++--
frontend/src/views/admin/Space/Task/index.vue | 8 +++++++-
.../views/admin/Space/Template/CreateTemplateDialog.vue | 3 ++-
frontend/src/views/admin/Space/Template/index.vue | 8 +++++++-
5 files changed, 20 insertions(+), 7 deletions(-)
diff --git a/frontend/src/config/i18n/cn.js b/frontend/src/config/i18n/cn.js
index 7cb3dcded1..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} 条数据',
',': ',',
diff --git a/frontend/src/config/i18n/en.js b/frontend/src/config/i18n/en.js
index 3dd136f8ec..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',
',': ', ',
diff --git a/frontend/src/views/admin/Space/Task/index.vue b/frontend/src/views/admin/Space/Task/index.vue
index 2b5e6ad9f0..5352fd96bb 100644
--- a/frontend/src/views/admin/Space/Task/index.vue
+++ b/frontend/src/views/admin/Space/Task/index.vue
@@ -5,7 +5,7 @@
:space-id="spaceId"
:placeholder="
$t(
- 'ID/任务名称/创建人/执行人/所属模板 ID/所属作用域类型/所属作用域值'
+ 'ID/任务名称/标签/创建人/执行人/所属模板 ID/所属作用域类型/所属作用域值'
)
"
:search-list="searchList"
@@ -239,6 +239,10 @@ const SEARCH_LIST = [
name: i18n.t('任务名称'),
isDefaultOption: true,
},
+ {
+ id: 'label',
+ name: i18n.t('标签'),
+ },
{
id: 'creator',
name: i18n.t('创建人'),
@@ -402,6 +406,7 @@ export default {
const {
name,
id,
+ label,
creator,
create_time: createTime,
start_time: startTime,
@@ -418,6 +423,7 @@ export default {
offset: (this.pagination.current - 1) * limit,
name__icontains: name,
id,
+ label,
creator,
executor,
scope_type,
diff --git a/frontend/src/views/admin/Space/Template/CreateTemplateDialog.vue b/frontend/src/views/admin/Space/Template/CreateTemplateDialog.vue
index 20f0865366..e9f837817c 100644
--- a/frontend/src/views/admin/Space/Template/CreateTemplateDialog.vue
+++ b/frontend/src/views/admin/Space/Template/CreateTemplateDialog.vue
@@ -49,7 +49,7 @@
+ @confirm="handleSelected">
@@ -194,6 +194,7 @@ export default {
handleSelected(val) {
this.selectedLabels = val;
this.templateFormData.label_ids = val.map(item => item.id);
+ console.log(this.templateFormData.label_ids);
},
handleDeleteLabel(val) {
this.selectedLabels = this.selectedLabels.filter(item => item.id !== val);
diff --git a/frontend/src/views/admin/Space/Template/index.vue b/frontend/src/views/admin/Space/Template/index.vue
index b7c66a1245..8ba91ff6a8 100644
--- a/frontend/src/views/admin/Space/Template/index.vue
+++ b/frontend/src/views/admin/Space/Template/index.vue
@@ -4,7 +4,7 @@
ref="tableOperate"
:space-id="spaceId"
:placeholder="
- $t('ID/流程名称/创建人/更新人/启用/所属作用域类型/所属作用域值')
+ $t('ID/流程名称/标签/创建人/更新人/启用/所属作用域类型/所属作用域值')
"
:search-list="searchList"
@updateSearchValue="searchValue = $event"
@@ -401,6 +401,10 @@ const SEARCH_LIST = [
name: i18n.t('流程名称'),
isDefaultOption: true,
},
+ {
+ id: 'label',
+ name: i18n.t('标签'),
+ },
{
id: 'creator',
name: i18n.t('创建人'),
@@ -534,6 +538,7 @@ export default {
const {
name,
id,
+ label,
creator,
updated_by,
create_at: createAt,
@@ -549,6 +554,7 @@ export default {
limit,
offset: (current - 1) * limit,
name__icontains: name,
+ label,
id,
creator,
updated_by,
From ee3a075a79b31c7b2366e12a7bde7f7faeecf04f Mon Sep 17 00:00:00 2001
From: Yuikill <1191184301@qq.com>
Date: Wed, 28 Jan 2026 09:42:06 +0800
Subject: [PATCH 05/13] =?UTF-8?q?fix:=20=E6=96=B0=E5=BB=BA=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=E5=8F=96=E6=B6=88=E9=BB=98=E8=AE=A4=E9=80=89=E4=B8=AD?=
=?UTF-8?q?=E6=B5=81=E7=A8=8B=E6=A0=87=E7=AD=BE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/views/admin/Space/Template/CreateTaskSideslider.vue | 2 +-
.../src/views/admin/Space/Template/CreateTemplateDialog.vue | 1 -
2 files changed, 1 insertion(+), 2 deletions(-)
diff --git a/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue b/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue
index 03af41a137..22f67cd043 100644
--- a/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue
+++ b/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue
@@ -223,7 +223,7 @@ export default {
name: `${this.row.name}_${moment().format('YYYYMMDDHHmmss')}`,
creator: this.username,
constants: '',
- labels: this.row.labels,
+ labels: [],
};
const resp = await this.getPreviewTaskTree({
templateId: this.row.id,
diff --git a/frontend/src/views/admin/Space/Template/CreateTemplateDialog.vue b/frontend/src/views/admin/Space/Template/CreateTemplateDialog.vue
index e9f837817c..35d4783ca9 100644
--- a/frontend/src/views/admin/Space/Template/CreateTemplateDialog.vue
+++ b/frontend/src/views/admin/Space/Template/CreateTemplateDialog.vue
@@ -194,7 +194,6 @@ export default {
handleSelected(val) {
this.selectedLabels = val;
this.templateFormData.label_ids = val.map(item => item.id);
- console.log(this.templateFormData.label_ids);
},
handleDeleteLabel(val) {
this.selectedLabels = this.selectedLabels.filter(item => item.id !== val);
From b6d8bd85a313fc72c11baf51fcc35b51c8bf8b8b Mon Sep 17 00:00:00 2001
From: yunchao
Date: Fri, 12 Dec 2025 11:49:24 +0800
Subject: [PATCH 06/13] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=A0=87?=
=?UTF-8?q?=E7=AD=BE=E5=8A=9F=E8=83=BD=20--story=3D129277621?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
bkflow/apigw/serializers/task.py | 1 +
bkflow/apigw/serializers/template.py | 1 +
bkflow/contrib/api/collections/task.py | 12 +
bkflow/interface/task/view.py | 32 +-
bkflow/label/__init__.py | 18 +
bkflow/label/admin.py | 70 ++++
bkflow/label/apps.py | 24 ++
bkflow/label/migrations/0001_initial.py | 84 +++++
bkflow/label/migrations/__init__.py | 18 +
bkflow/label/models.py | 295 ++++++++++++++++
bkflow/label/permissions.py | 33 ++
bkflow/label/serializers.py | 119 +++++++
bkflow/label/tests.py | 19 +
bkflow/label/urls.py | 29 ++
bkflow/label/views.py | 202 +++++++++++
bkflow/permission/models.py | 3 +
.../migrations/0015_auto_20251204_1656.py | 46 +++
bkflow/task/models.py | 71 ++++
bkflow/task/serializers.py | 7 +
bkflow/task/views.py | 64 +++-
bkflow/template/serializers/template.py | 23 ++
bkflow/template/views/template.py | 51 ++-
bkflow/urls.py | 1 +
module_settings.py | 1 +
tests/label/__init__.py | 18 +
tests/label/test_label.py | 328 ++++++++++++++++++
tests/label/test_label_viewset.py | 125 +++++++
27 files changed, 1691 insertions(+), 4 deletions(-)
create mode 100644 bkflow/label/__init__.py
create mode 100644 bkflow/label/admin.py
create mode 100644 bkflow/label/apps.py
create mode 100644 bkflow/label/migrations/0001_initial.py
create mode 100644 bkflow/label/migrations/__init__.py
create mode 100644 bkflow/label/models.py
create mode 100644 bkflow/label/permissions.py
create mode 100644 bkflow/label/serializers.py
create mode 100644 bkflow/label/tests.py
create mode 100644 bkflow/label/urls.py
create mode 100644 bkflow/label/views.py
create mode 100644 bkflow/task/migrations/0015_auto_20251204_1656.py
create mode 100644 tests/label/__init__.py
create mode 100644 tests/label/test_label.py
create mode 100644 tests/label/test_label_viewset.py
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..d756598423 100644
--- a/bkflow/contrib/api/collections/task.py
+++ b/bkflow/contrib/api/collections/task.py
@@ -59,6 +59,18 @@ 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 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..fd1a5e1646 100644
--- a/bkflow/interface/task/view.py
+++ b/bkflow/interface/task/view.py
@@ -17,6 +17,7 @@
to the current version of the project delivered to anyone in the future.
"""
import logging
+from copy import deepcopy
from django.db.models import Q
from django.utils import timezone
@@ -40,6 +41,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 +58,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 = deepcopy(request.query_params)
+ 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..17da1cb559
--- /dev/null
+++ b/bkflow/label/models.py
@@ -0,0 +1,295 @@
+"""
+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", 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_name = self.get_parent_label().name if self.get_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..9b326daf6d
--- /dev/null
+++ b/bkflow/label/serializers.py
@@ -0,0 +1,119 @@
+"""
+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).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).exclude(id=self.instance.id).exists():
+ raise serializers.ValidationError(_(f"该空间下已存在名称为「{value}」的标签"))
+ return value
+
+
+class LabelRefSerializer(serializers.Serializer):
+ """标签引用序列化器"""
+
+ space_id = serializers.IntegerField(required=True, help_text="空间ID")
+ label_ids = serializers.CharField(required=True, help_text="标签ID")
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..4b5d40945d
--- /dev/null
+++ b/bkflow/label/views.py
@@ -0,0 +1,202 @@
+"""
+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, 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()
+
+ # 如果是一级标签,删除所有子标签
+ if instance.parent_id is None:
+ Label.objects.filter(parent_id=instance.pk).delete()
+
+ self.perform_destroy(instance)
+ 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
+ label_ids = validated_params["label_ids"].split(",")
+
+ # 调用任务组件接口获取任务引用数量
+ 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"]
+
+ # 获取模板引用数量
+ 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..011b8b5504 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..629f1bc8f6 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,8 @@ 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)
diff --git a/bkflow/task/views.py b/bkflow/task/views.py
index 42232af140..2232116673 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,8 +49,10 @@
EngineSpaceConfigValueType,
PeriodicTask,
TaskInstance,
+ TaskLabelRelation,
TaskMockData,
TaskOperationRecord,
+ TaskLabelRelation,
)
from bkflow.task.node_log import NodeLogDataSourceFactory
from bkflow.task.operations import TaskNodeOperation, TaskOperation
@@ -60,6 +63,7 @@
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)
@@ -147,12 +170,51 @@ def get_serializer_class(self):
elif self.action == "retrieve":
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)
@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
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..c44dcb9d31 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,8 @@ def create_template(self, request, space_id, *args, **kwargs):
pipeline_tree = build_default_pipeline_tree_with_space_id(space_id)
# 涉及到两张表的创建,需要那个开启事物,确保两张表全部都创建成功
with transaction.atomic():
+ validate_data = ser.data.copy()
+ label_ids = validate_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 +203,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):
@@ -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/module_settings.py b/module_settings.py
index 1392cf067a..e5b2f94d4f 100644
--- a/module_settings.py
+++ b/module_settings.py
@@ -243,6 +243,7 @@ def check_engine_admin_permission(request, *args, **kwargs):
"bk_notice_sdk",
"bkflow.bk_plugin",
"bkflow.pipeline_web",
+ "bkflow.label",
)
VARIABLE_KEY_BLACKLIST = (
diff --git a/tests/label/__init__.py b/tests/label/__init__.py
new file mode 100644
index 0000000000..732d2b8d52
--- /dev/null
+++ b/tests/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/tests/label/test_label.py b/tests/label/test_label.py
new file mode 100644
index 0000000000..3145efca27
--- /dev/null
+++ b/tests/label/test_label.py
@@ -0,0 +1,328 @@
+"""
+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 pytest
+from django.core.exceptions import ValidationError
+
+from bkflow.label.models import Label, TaskLabelRelation, TemplateLabelRelation
+
+# Enable database access for all tests in this module
+pytestmark = pytest.mark.django_db
+
+
+def make_label(
+ name,
+ space_id=1,
+ parent_id=None,
+ label_scope=None,
+ creator="tester",
+ updated_by="tester",
+ color="#ffffff",
+ is_default=False,
+):
+ """
+ Helper factory for creating Label instances with sensible defaults.
+ """
+ if label_scope is None:
+ label_scope = ["task"]
+ return Label.objects.create(
+ name=name,
+ space_id=space_id,
+ parent_id=parent_id,
+ label_scope=label_scope,
+ creator=creator,
+ updated_by=updated_by,
+ color=color,
+ is_default=is_default,
+ )
+
+
+class TestLabelManager:
+ """
+ Tests for custom methods defined in LabelManager.
+ """
+
+ def test_check_label_ids_true_and_false(self):
+ """
+ check_label_ids should return True only when all ids exist.
+ """
+ l1 = make_label("label1")
+ l2 = make_label("label2")
+
+ assert Label.objects.check_label_ids([l1.id, l2.id]) is True
+ # add a non-existing id
+ assert Label.objects.check_label_ids([l1.id, l2.id, 999999]) is False
+
+ def test_get_root_labels_filters_by_space_and_scope(self):
+ """
+ get_root_labels should respect space_id and optional label_scope.
+ """
+ # space 1 roots
+ root_task = make_label("root_task", space_id=1, label_scope=["task"])
+ root_template = make_label("root_template", space_id=1, label_scope=["template"])
+ # different space root
+ root_other_space = make_label("root_other_space", space_id=2, label_scope=["task"])
+ # child in space 1
+ child = make_label("child", space_id=1, parent_id=root_task.id, label_scope=["task"])
+
+ roots_task = Label.objects.get_root_labels(space_id=1, label_scope="task")
+ roots_all_space1 = Label.objects.get_root_labels(space_id=1)
+
+ # only the task root should appear when label_scope="task"
+ assert list(roots_task.values_list("id", flat=True)) == [root_task.id]
+
+ # all roots in space 1 (both task and template), children must not appear
+ assert set(roots_all_space1.values_list("id", flat=True)) == {
+ root_task.id,
+ root_template.id,
+ }
+ assert child.id not in roots_all_space1.values_list("id", flat=True)
+ assert root_other_space.id not in roots_all_space1.values_list("id", flat=True)
+
+ def test_get_sub_labels_recursive_and_non_recursive(self):
+ """
+ get_sub_labels should support both direct and recursive queries.
+ """
+ root = make_label("root")
+ child1 = make_label("child1", parent_id=root.id)
+ child2 = make_label("child2", parent_id=root.id)
+ grandchild = make_label("grandchild", parent_id=child1.id)
+
+ # non-recursive: only direct children
+ direct_children = Label.objects.get_sub_labels(root.id, recursive=False)
+ names = list(direct_children.values_list("name", flat=True))
+ assert names == ["child1", "child2"]
+
+ # recursive: all descendants as list
+ all_descendants = Label.objects.get_sub_labels(root.id, recursive=True)
+ ids = {label.id for label in all_descendants}
+ assert ids == {child1.id, child2.id, grandchild.id}
+ assert isinstance(all_descendants, list)
+
+ def test_get_parent_label_and_is_root(self):
+ """
+ get_parent_label should return parent for children and None for roots.
+ is_root should be True only for roots.
+ """
+ root = make_label("root")
+ child = make_label("child", parent_id=root.id)
+
+ assert Label.objects.get_parent_label(child.id) == root
+ assert Label.objects.get_parent_label(root.id) is None
+ assert Label.objects.get_parent_label(999999) is None
+
+ assert root.is_root() is True
+ assert child.is_root() is False
+
+ def test_get_labels_map_returns_full_info(self):
+ """
+ get_labels_map should return label info including full_path.
+ """
+ root = make_label("root")
+ child = make_label("child", parent_id=root.id, color="#123456")
+
+ labels_map = Label.objects.get_labels_map([child.id])
+
+ assert set(labels_map.keys()) == {child.id}
+ info = labels_map[child.id]
+ assert info["id"] == child.id
+ assert info["name"] == "child"
+ assert info["color"] == "#123456"
+ assert info["full_path"] == child.full_path
+ # full_path should include both root and child names
+ assert info["full_path"].split("/") == ["root", "child"]
+
+
+class TestLabelModel:
+ """
+ Tests for Label model instance methods and validation logic.
+ """
+
+ def test_full_path_builds_hierarchy(self):
+ """
+ full_path should join names from root to current label with "/".
+ """
+ root = make_label("root")
+ child = make_label("child", parent_id=root.id)
+ grandchild = make_label("grandchild", parent_id=child.id)
+
+ assert root.full_path == "root"
+ assert child.full_path == "root/child"
+ assert grandchild.full_path == "root/child/grandchild"
+
+ def test_label_clean_validates_parent_space(self):
+ """
+ save should fail when child's space_id does not match parent space_id.
+ """
+ parent = make_label("parent", space_id=1)
+ # do not save child yet; we expect validation error on save()
+ child = Label(
+ name="child_wrong_space",
+ space_id=2, # different space from parent
+ parent_id=parent.id,
+ label_scope=["task"],
+ creator="tester",
+ updated_by="tester",
+ )
+
+ with pytest.raises(ValidationError) as exc:
+ child.save()
+
+ msg = str(exc.value)
+ assert "子标签的空间ID必须与父标签一致" in msg
+
+ def test_label_clean_raises_for_missing_parent(self):
+ """
+ save should fail when parent_id points to a non-existing label.
+ """
+ child = Label(
+ name="child_missing_parent",
+ space_id=1,
+ parent_id=999999, # non-existent
+ label_scope=["task"],
+ creator="tester",
+ updated_by="tester",
+ )
+
+ with pytest.raises(ValidationError) as exc:
+ child.save()
+
+ msg = str(exc.value)
+ assert "父标签不存在" in msg
+
+ def test_label_clean_prevents_cycle_reference(self):
+ """
+ save should prevent cycles like A -> B -> A.
+ """
+ parent = make_label("parent")
+ child = make_label("child", parent_id=parent.id)
+
+ # introduce a cycle: parent -> child, child -> parent already exists
+ parent.parent_id = child.id
+
+ with pytest.raises(ValidationError) as exc:
+ parent.save()
+
+ msg = str(exc.value)
+ assert "禁止循环引用" in msg
+
+ def test_get_all_children_delegates_to_manager(self):
+ """
+ get_all_children should delegate to LabelManager.get_sub_labels.
+ """
+ root = make_label("root")
+ child1 = make_label("child1", parent_id=root.id)
+ child2 = make_label("child2", parent_id=root.id)
+ grandchild = make_label("grandchild", parent_id=child1.id)
+
+ # non-recursive: only direct children
+ direct_children = root.get_all_children(recursive=False)
+ assert set(direct_children.values_list("id", flat=True)) == {
+ child1.id,
+ child2.id,
+ }
+
+ # recursive: all descendants as list
+ all_descendants = root.get_all_children(recursive=True)
+ ids = {label.id for label in all_descendants}
+ assert ids == {child1.id, child2.id, grandchild.id}
+
+
+class TestTemplateLabelRelationManager:
+ """
+ Tests for BaseLabelRelationManager via TemplateLabelRelation.
+ """
+
+ def test_set_and_fetch_labels(self):
+ """
+ set_labels and fetch_labels should correctly manage template-label relations.
+ """
+ l1 = make_label("label1")
+ l2 = make_label("label2")
+ l3 = make_label("label3")
+
+ template_id = 100
+
+ # initial set: {l1, l2}
+ TemplateLabelRelation.objects.set_labels(template_id, [l1.id, l2.id])
+ current_ids = set(
+ TemplateLabelRelation.objects.filter(template_id=template_id).values_list("label_id", flat=True)
+ )
+ assert current_ids == {l1.id, l2.id}
+
+ # update set: {l2, l3} -> l1 removed, l3 added
+ TemplateLabelRelation.objects.set_labels(template_id, [l2.id, l3.id])
+ current_ids = set(
+ TemplateLabelRelation.objects.filter(template_id=template_id).values_list("label_id", flat=True)
+ )
+ assert current_ids == {l2.id, l3.id}
+
+ # fetch_labels should return label descriptions with full_path
+ labels = TemplateLabelRelation.objects.fetch_labels(template_id)
+ returned_ids = {item["id"] for item in labels}
+ assert returned_ids == {l2.id, l3.id}
+ for item in labels:
+ assert "name" in item
+ assert "color" in item
+ assert "full_path" in item
+
+ def test_fetch_objects_labels(self):
+ """
+ fetch_objects_labels should return a mapping {obj_id: [label_info, ...]}.
+ """
+ l1 = make_label("label1")
+ l2 = make_label("label2")
+ l3 = make_label("label3")
+
+ t1 = 101
+ t2 = 102
+
+ TemplateLabelRelation.objects.set_labels(t1, [l1.id, l2.id])
+ TemplateLabelRelation.objects.set_labels(t2, [l2.id, l3.id])
+
+ result = TemplateLabelRelation.objects.fetch_objects_labels([t1, t2], label_fields=("name", "color"))
+
+ assert set(result.keys()) == {t1, t2}
+ ids_t1 = {label["id"] for label in result[t1]}
+ ids_t2 = {label["id"] for label in result[t2]}
+ assert ids_t1 == {l1.id, l2.id}
+ assert ids_t2 == {l2.id, l3.id}
+
+ # each label dict should contain requested fields and full_path
+ for labels in result.values():
+ for item in labels:
+ assert set(item.keys()) >= {"id", "name", "color", "full_path"}
+
+
+class TestTaskLabelRelationManager:
+ """
+ Tests for BaseLabelRelationManager via TaskLabelRelation.
+ """
+
+ def test_manager_uses_task_id(self):
+ """
+ set_labels should use 'task_id' as fk_field for TaskLabelRelation.
+ """
+ label = make_label("task_label")
+ task_id = 200
+
+ TaskLabelRelation.objects.set_labels(task_id, [label.id])
+
+ relations = TaskLabelRelation.objects.filter(task_id=task_id)
+ assert relations.count() == 1
+ assert relations.first().label_id == label.id
diff --git a/tests/label/test_label_viewset.py b/tests/label/test_label_viewset.py
new file mode 100644
index 0000000000..b704966dca
--- /dev/null
+++ b/tests/label/test_label_viewset.py
@@ -0,0 +1,125 @@
+"""
+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 pytest
+from blueapps.account.models import User
+from rest_framework import status
+from rest_framework.test import APIRequestFactory
+
+from bkflow.label.models import Label
+from bkflow.label.views import LabelViewSet
+
+from .test_label import make_label
+
+# Enable database access for all tests in this module
+pytestmark = pytest.mark.django_db
+
+
+class TestLabelViewSet:
+ """Tests for LabelViewSet API behavior."""
+
+ def setup_method(self):
+ """Prepare APIRequestFactory and an admin user for each test."""
+ self.factory = APIRequestFactory()
+ # Admin user to pass AdminPermission and provide username for CustomViewSetMixin
+ self.admin_user, created = User.objects.get_or_create(
+ username="label_admin",
+ defaults={
+ "is_superuser": True,
+ "is_staff": True,
+ },
+ )
+
+ self.list_view = LabelViewSet.as_view({"get": "list"})
+ self.create_view = LabelViewSet.as_view({"post": "create"})
+ self.destroy_view = LabelViewSet.as_view({"delete": "destroy"})
+
+ def test_list_returns_root_labels_with_has_children_flag(self):
+ """list should return only root labels when parent_id is not provided."""
+ root = make_label("root", space_id=1)
+ child = make_label("child", space_id=1, parent_id=root.id)
+ other_space_root = make_label("other_space_root", space_id=2)
+
+ url = "/api/label/"
+ request = self.factory.get(
+ url,
+ {"space_id": 1, "label_scope": "task", "limit": -1},
+ )
+ request.user = self.admin_user
+
+ response = self.list_view(request)
+ assert response.status_code == status.HTTP_200_OK
+
+ # SimpleGenericViewSet wraps DRF response data into {"result": True, "data": ..., "code": "0", "message": ""}
+ data = response.data["data"]
+ results = data["results"]
+ returned_ids = {item["id"] for item in results}
+
+ # Only root labels in space 1 should be returned
+ assert root.id in returned_ids
+ assert other_space_root.id not in returned_ids
+ assert child.id not in returned_ids
+
+ # All returned labels should be roots; root with children should have has_children=True
+ for item in results:
+ label_obj = Label.objects.get(id=item["id"])
+ assert label_obj.parent_id is None
+ if item["id"] == root.id:
+ assert item["has_children"] is True
+
+ def test_create_nested_name_creates_parent_and_child(self):
+ """create should support 'parent/child' name format and auto-create parent label."""
+ url = "/api/label/"
+ payload = {
+ "name": "ParentLabel/ChildLabel",
+ "space_id": 1,
+ "color": "#123456",
+ "label_scope": ["task"],
+ }
+ request = self.factory.post(url, payload, format="json")
+ request.user = self.admin_user
+
+ response = self.create_view(request)
+ assert response.status_code == status.HTTP_201_CREATED
+
+ # SimpleGenericViewSet wraps data under "data"
+ resp_data = response.data["data"]
+ assert resp_data["name"] == "ChildLabel"
+
+ # Parent should be auto-created as a root label
+ parent = Label.objects.get(name="ParentLabel", parent_id__isnull=True)
+ child = Label.objects.get(id=resp_data["id"])
+
+ assert child.parent_id == parent.id
+ assert child.full_path == "ParentLabel/ChildLabel"
+
+ def test_destroy_root_label_cascades_children(self):
+ """destroy should delete root label and all of its direct children."""
+ root = make_label("root_to_delete", space_id=1)
+ child1 = make_label("child1", space_id=1, parent_id=root.id)
+ child2 = make_label("child2", space_id=1, parent_id=root.id)
+
+ url = f"/api/label/{root.id}/"
+ request = self.factory.delete(url)
+ request.user = self.admin_user
+
+ response = self.destroy_view(request, pk=root.id)
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+
+ assert not Label.objects.filter(id=root.id).exists()
+ assert not Label.objects.filter(id__in=[child1.id, child2.id]).exists()
From 7ce4d9746f5339abbc6fa021a19a8f7820af3b7a Mon Sep 17 00:00:00 2001
From: Yuikill <1191184301@qq.com>
Date: Tue, 3 Feb 2026 17:37:11 +0800
Subject: [PATCH 07/13] =?UTF-8?q?fix:=20=E6=A0=87=E7=AD=BE=E9=80=89?=
=?UTF-8?q?=E6=8B=A9=E5=99=A8=E4=BA=A4=E4=BA=92=E8=B0=83=E6=95=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Space/Template/CreateTaskSideslider.vue | 2 +-
.../views/admin/Space/Template/label-cell.vue | 4 -
.../views/admin/Space/common/LabelCascade.vue | 89 +++++++++++++------
.../TemplateSetting/TabTemplateConfig.vue | 3 +
4 files changed, 68 insertions(+), 30 deletions(-)
diff --git a/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue b/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue
index 22f67cd043..20d5c1ef8c 100644
--- a/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue
+++ b/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue
@@ -303,7 +303,7 @@ export default {
this.taskFormData.labels = val;
},
handleDeleteLabel(val) {
- this.taskFormData.labels = this.taskFormData.labels.filter(item => item.id !== val.id);
+ this.taskFormData.labels = this.taskFormData.labels.filter(item => item.id !== val);
},
},
};
diff --git a/frontend/src/views/admin/Space/Template/label-cell.vue b/frontend/src/views/admin/Space/Template/label-cell.vue
index eb6e85a571..68e1445722 100644
--- a/frontend/src/views/admin/Space/Template/label-cell.vue
+++ b/frontend/src/views/admin/Space/Template/label-cell.vue
@@ -114,10 +114,6 @@ export default {
hidden.push(info);
}
}
- if (hidden.length && visible.length) {
- const last = visible.pop();
- hidden.unshift(last);
- }
this.visibleTags = visible
.sort((a, b) => a.index - b.index)
.map(i => i.tag);
diff --git a/frontend/src/views/admin/Space/common/LabelCascade.vue b/frontend/src/views/admin/Space/common/LabelCascade.vue
index b42bec69ca..1dedb0f22d 100644
--- a/frontend/src/views/admin/Space/common/LabelCascade.vue
+++ b/frontend/src/views/admin/Space/common/LabelCascade.vue
@@ -119,8 +119,8 @@ export default {
return {
showCreateLabelDialog: false,
labelList: [],
- selectLabelList: [],
- labelIds: [],
+ selectLabelList: [], // 实际选中的标签列表
+ labelIds: [], // 绑定列表选中态
isShow: false,
secondLabelList: [],
foucsId: 0,
@@ -128,6 +128,7 @@ export default {
isInitialized: false,
isShowCreate: false,
searchStr: '',
+ needReload: true, // 是否需要重新绑定一级标签选中态(刷新列表后重新绑定)
};
},
computed: {
@@ -168,11 +169,12 @@ export default {
const parentChecked = this.labelIds.includes(parentLabel.id);
if (parentChecked) {
children.forEach((child) => {
- this.addLabel(child);
+ this.addLabelId(child);
+ this.addToSelect(child);
});
} else {
children.forEach((child) => {
- this.removeLabel(child.id);
+ this.removeLabelId(child.id);
});
}
return;
@@ -188,20 +190,35 @@ export default {
getPopoverInstance() {
return this.$refs.labelPopover.instance;
},
- isVisible(val) {
+ async isVisible(val) {
const popover = this.getPopoverInstance();
if (val) {
popover.show();
this.isShow = true;
- if (this.isInitialized) return;
- this.getLabelList();
- this.isInitialized = true;
+ if (!this.isInitialized) {
+ // 初始化
+ await this.getLabelList();
+ this.isInitialized = true;
+ }
+ if (this.needReload) {
+ // 有二级标签选中时,选中其一级标签
+ this.selectLabelList.forEach((label) => {
+ if (label.full_path.includes('/')) {
+ const parentFullPath = label.full_path.split('/')[0];
+ const parentLabel = this.labelList.find(item => item.name === parentFullPath);
+ if (parentLabel) {
+ this.addLabelId(parentLabel);
+ }
+ }
+ });
+ this.needReload = false;
+ }
} else {
popover.hide();
this.isShow = false;
}
},
- handleClickFirstLabel(label) {
+ async handleClickFirstLabel(label) {
this.foucsId = label.id;
if (!label.has_children) {
this.secondLabelList = [];
@@ -209,7 +226,7 @@ export default {
if (label.children) {
this.secondLabelList = label.children;
} else {
- this.getLabelList(label);
+ await this.getLabelList(label);
}
}
},
@@ -221,52 +238,73 @@ export default {
}
},
handleCheck(label) {
- this.addLabel(label);
+ this.addLabelId(label);
// 选中一级 → 选中所有二级
if (label.has_children && Array.isArray(label.children)) {
label.children.forEach((child) => {
- this.addLabel(child);
+ this.addLabelId(child);
+ this.addToSelect(child);
});
+ return;
}
- // 选中二级 → 选中一级
+ // 选中二级
if (label.parent_id) {
const parent = this.labelList.find(item => item.id === label.parent_id);
if (parent) {
- this.addLabel(parent);
+ // 父级只进 labelIds
+ this.addLabelId(parent);
+ // 展示列表里如果有父级,移除
+ this.removeFromSelect(parent.id);
}
+ // 二级进展示
+ this.addToSelect(label);
+ return;
+ }
+ // 普通一级(没有 children 的)
+ if (!label.has_children) {
+ this.addToSelect(label);
}
},
handleUncheck(label) {
- this.removeLabel(label.id);
+ this.removeLabelId(label.id);
// 取消一级 → 取消所有二级
if (label.has_children && Array.isArray(label.children)) {
label.children.forEach((child) => {
- this.removeLabel(child.id);
+ this.removeLabelId(child.id);
});
+ // 确保一级也不在 select 中
+ this.removeFromSelect(label.id);
}
+
// 取消二级 → 如果没有兄弟被选中,取消一级
if (label.parent_id) {
const parent = this.labelList.find(item => item.id === label.parent_id);
if (!parent || !Array.isArray(parent.children)) return;
const hasSelectedSibling = parent.children.some(child => this.labelIds.includes(child.id));
if (!hasSelectedSibling) {
- this.removeLabel(parent.id);
+ this.removeLabelId(parent.id);
}
}
},
- addLabel(label) {
+ addLabelId(label) {
if (!this.labelIds.includes(label.id)) {
- this.selectLabelList.push({
- id: label.id,
- name: label.name,
- color: label.color,
- full_path: label.full_path,
- });
this.labelIds.push(label.id);
}
},
- removeLabel(id) {
+ addToSelect(label) {
+ if (this.selectLabelList.some(item => item.id === label.id)) return;
+ this.selectLabelList.push({
+ id: label.id,
+ name: label.name,
+ color: label.color,
+ full_path: label.full_path,
+ });
+ },
+ removeFromSelect(id) {
this.selectLabelList = this.selectLabelList.filter(item => item.id !== id);
+ },
+ removeLabelId(id) {
+ this.removeFromSelect(id);
this.labelIds = this.labelIds.filter(labelId => labelId !== id);
},
hide() {
@@ -274,6 +312,7 @@ export default {
const isEqual = tools.isDataEqual(this.value, this.selectLabelList);
if (isEqual) return;
this.$emit('confirm', this.selectLabelList);
+ this.needReload = true;
},
onCreateLabel() {
this.isShowCreate = true;
diff --git a/frontend/src/views/template/TemplateEdit/TemplateSetting/TabTemplateConfig.vue b/frontend/src/views/template/TemplateEdit/TemplateSetting/TabTemplateConfig.vue
index 3c89da6c38..19fb449a7f 100644
--- a/frontend/src/views/template/TemplateEdit/TemplateSetting/TabTemplateConfig.vue
+++ b/frontend/src/views/template/TemplateEdit/TemplateSetting/TabTemplateConfig.vue
@@ -692,6 +692,9 @@ export default {
onSelectLabel(labels) {
this.formData.template_labels = labels;
},
+ handleDeleteLabel(id) {
+ this.formData.template_labels = this.formData.template_labels.filter(item => item.id !== id);
+ },
},
};
From b9b1eb07e020c32e54de0ab73f67d7ab76818558 Mon Sep 17 00:00:00 2001
From: yunchao
Date: Fri, 6 Feb 2026 14:56:22 +0800
Subject: [PATCH 08/13] =?UTF-8?q?feat:=20=E6=B5=8B=E8=AF=95=E5=92=8C?=
=?UTF-8?q?=E5=88=A0=E9=99=A4=E6=A0=87=E7=AD=BE=E5=8A=9F=E8=83=BD=E8=A1=A5?=
=?UTF-8?q?=E5=85=85=20--story=3D129277621?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
bkflow/contrib/api/collections/task.py | 7 +
bkflow/label/serializers.py | 23 +-
bkflow/label/views.py | 16 +-
bkflow/task/serializers.py | 4 +
bkflow/task/views.py | 17 +-
bkflow/template/views/template.py | 4 +-
tests/label/test_label.py | 231 ++++++++++++++++++--
tests/label/test_label_viewset.py | 286 ++++++++++++++++++++++++-
8 files changed, 557 insertions(+), 31 deletions(-)
diff --git a/bkflow/contrib/api/collections/task.py b/bkflow/contrib/api/collections/task.py
index d756598423..1e1908ba93 100644
--- a/bkflow/contrib/api/collections/task.py
+++ b/bkflow/contrib/api/collections/task.py
@@ -71,6 +71,13 @@ def get_task_label_ref_count(self, 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/label/serializers.py b/bkflow/label/serializers.py
index 9b326daf6d..f558eaec6f 100644
--- a/bkflow/label/serializers.py
+++ b/bkflow/label/serializers.py
@@ -102,15 +102,34 @@ def validate_name(self, value):
# 新增时:检查同一 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).exists():
+ 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).exclude(id=self.instance.id).exists():
+ 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):
"""标签引用序列化器"""
diff --git a/bkflow/label/views.py b/bkflow/label/views.py
index 4b5d40945d..34cf3c9b89 100644
--- a/bkflow/label/views.py
+++ b/bkflow/label/views.py
@@ -163,11 +163,23 @@ def destroy(self, request, *args, **kwargs):
"""删除标签,级联删除子标签"""
instance = self.get_object()
+ need_delete_label_ids = [instance.pk]
# 如果是一级标签,删除所有子标签
if instance.parent_id is None:
- Label.objects.filter(parent_id=instance.pk).delete()
+ 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()
- self.perform_destroy(instance)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["get"], serializer_class=LabelRefSerializer)
diff --git a/bkflow/task/serializers.py b/bkflow/task/serializers.py
index 629f1bc8f6..7301da2463 100644
--- a/bkflow/task/serializers.py
+++ b/bkflow/task/serializers.py
@@ -295,3 +295,7 @@ class BatchDeletePeriodicTaskSerializer(serializers.Serializer):
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 2232116673..4051bc3cb6 100644
--- a/bkflow/task/views.py
+++ b/bkflow/task/views.py
@@ -52,7 +52,6 @@
TaskLabelRelation,
TaskMockData,
TaskOperationRecord,
- TaskLabelRelation,
)
from bkflow.task.node_log import NodeLogDataSourceFactory
from bkflow.task.operations import TaskNodeOperation, TaskOperation
@@ -60,6 +59,7 @@
BatchDeletePeriodicTaskSerializer,
CreatePeriodicTaskSerializer,
CreateTaskInstanceSerializer,
+ DeleteTaskLabelRelationSerializer,
EngineSpaceConfigSerializer,
GetEngineSpaceConfigSerializer,
GetTaskOperationRecordSerializer,
@@ -170,7 +170,7 @@ def get_serializer_class(self):
elif self.action == "retrieve":
return RetrieveTaskInstanceSerializer
return super().get_serializer_class()
-
+
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
@@ -178,7 +178,7 @@ def list(self, request, *args, **kwargs):
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)
+ tasks_labels = TaskLabelRelation.objects.fetch_tasks_labels(task_ids)
for task in serializer.data:
task["labels"] = tasks_labels.get(task["id"], [])
@@ -208,6 +208,16 @@ def get_task_label_ref_count(self, request, *args, **kwargs):
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)
@@ -238,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=serializer.validated_data["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/views/template.py b/bkflow/template/views/template.py
index c44dcb9d31..fec7df38fc 100644
--- a/bkflow/template/views/template.py
+++ b/bkflow/template/views/template.py
@@ -191,8 +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():
- validate_data = ser.data.copy()
- label_ids = validate_data.pop("label_ids", [])
+ 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)
@@ -333,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)
diff --git a/tests/label/test_label.py b/tests/label/test_label.py
index 3145efca27..c3bc9fc25e 100644
--- a/tests/label/test_label.py
+++ b/tests/label/test_label.py
@@ -17,9 +17,10 @@
to the current version of the project delivered to anyone in the future.
"""
import pytest
-from django.core.exceptions import ValidationError
+from django.core.exceptions import ValidationError as DjangoValidationError
+from rest_framework.exceptions import ValidationError as DRFValidationError
-from bkflow.label.models import Label, TaskLabelRelation, TemplateLabelRelation
+from bkflow.label.models import Label, TemplateLabelRelation
# Enable database access for all tests in this module
pytestmark = pytest.mark.django_db
@@ -180,7 +181,7 @@ def test_label_clean_validates_parent_space(self):
updated_by="tester",
)
- with pytest.raises(ValidationError) as exc:
+ with pytest.raises(DjangoValidationError) as exc:
child.save()
msg = str(exc.value)
@@ -199,7 +200,7 @@ def test_label_clean_raises_for_missing_parent(self):
updated_by="tester",
)
- with pytest.raises(ValidationError) as exc:
+ with pytest.raises(DjangoValidationError) as exc:
child.save()
msg = str(exc.value)
@@ -215,7 +216,7 @@ def test_label_clean_prevents_cycle_reference(self):
# introduce a cycle: parent -> child, child -> parent already exists
parent.parent_id = child.id
- with pytest.raises(ValidationError) as exc:
+ with pytest.raises(DjangoValidationError) as exc:
parent.save()
msg = str(exc.value)
@@ -242,6 +243,43 @@ def test_get_all_children_delegates_to_manager(self):
ids = {label.id for label in all_descendants}
assert ids == {child1.id, child2.id, grandchild.id}
+ def test_get_label_ids_by_names(self):
+ """get_label_ids_by_names should return label IDs matching given names."""
+ l1 = make_label("apple")
+ l2 = make_label("banana")
+
+ # Test exact name matching
+ ids = Label.get_label_ids_by_names("apple")
+ assert ids == [l1.id]
+
+ # Test multiple names separated by commas
+ ids = Label.get_label_ids_by_names("apple,banana")
+ assert set(ids) == {l1.id, l2.id}
+
+ # Test multiple names separated by spaces
+ ids = Label.get_label_ids_by_names("apple banana")
+ assert set(ids) == {l1.id, l2.id}
+
+ # Test case insensitive matching
+ ids = Label.get_label_ids_by_names("APPLE")
+ assert ids == [l1.id]
+
+ # Test partial matching
+ ids = Label.get_label_ids_by_names("app")
+ assert ids == [l1.id]
+
+ # Test no matches
+ ids = Label.get_label_ids_by_names("grape")
+ assert ids == []
+
+ # Test empty input
+ ids = Label.get_label_ids_by_names("")
+ assert ids == []
+
+ # Test with extra whitespace
+ ids = Label.get_label_ids_by_names(" apple , banana ")
+ assert set(ids) == {l1.id, l2.id}
+
class TestTemplateLabelRelationManager:
"""
@@ -309,20 +347,175 @@ def test_fetch_objects_labels(self):
assert set(item.keys()) >= {"id", "name", "color", "full_path"}
-class TestTaskLabelRelationManager:
- """
- Tests for BaseLabelRelationManager via TaskLabelRelation.
- """
+class TestLabelSerializer:
+ """Tests for LabelSerializer validation logic."""
- def test_manager_uses_task_id(self):
- """
- set_labels should use 'task_id' as fk_field for TaskLabelRelation.
- """
- label = make_label("task_label")
- task_id = 200
+ def test_validate_color_valid_and_invalid_formats(self):
+ """validate_color should accept valid hex colors and reject invalid ones."""
+ from rest_framework.test import APIRequestFactory
+
+ from bkflow.label.serializers import LabelSerializer
+
+ factory = APIRequestFactory()
+ request = factory.post("/api/label/", data={"space_id": 1})
+
+ # Test individual validation method
+ serializer = LabelSerializer(context={"request": request})
+
+ # Valid hex colors should pass
+ assert serializer.validate_color("#ffffff") == "#ffffff"
+ assert serializer.validate_color("#000000") == "#000000"
+ assert serializer.validate_color("#FF00FF") == "#FF00FF"
+
+ # Invalid hex colors should raise ValidationError - this is CORRECT behavior
+ with pytest.raises(DRFValidationError) as exc:
+ serializer.validate_color("ffffff") # missing #
+ assert "颜色格式错误" == str(exc.value.detail[0])
+
+ with pytest.raises(DRFValidationError) as exc:
+ serializer.validate_color("#fff") # wrong length
+ assert "颜色格式错误" == str(exc.value.detail[0])
+
+ with pytest.raises(DRFValidationError) as exc:
+ serializer.validate_color("#gggggg") # invalid characters
+ assert "颜色格式错误" == str(exc.value.detail[0])
+
+ with pytest.raises(DRFValidationError) as exc:
+ serializer.validate_color("#12345") # wrong length
+ assert "颜色格式错误" == str(exc.value.detail[0])
+
+ def test_validate_name_duplicate_check(self):
+ """validate_name should prevent duplicate names within same space and parent."""
+ from rest_framework.test import APIRequestFactory
+
+ from bkflow.label.serializers import LabelSerializer
+
+ # Create initial label
+ existing_label = make_label("existing_label", space_id=1, color="#ffffff", label_scope=["task"])
+
+ factory = APIRequestFactory()
+
+ # Test duplicate detection on create
+ request = factory.post("/api/label/", data={"space_id": 1})
+
+ # Provide all required fields
+ serializer = LabelSerializer(
+ data={"space_id": 1, "name": "existing_label", "color": "#ffffff", "label_scope": ["task"]},
+ context={"request": request},
+ )
+
+ # Should raise validation error for duplicate name - this is CORRECT behavior
+ with pytest.raises(DRFValidationError) as exc:
+ serializer.is_valid(raise_exception=True)
+ assert "该空间下已存在名称为「existing_label」的标签" == exc.value.detail["name"][0]
+
+ # Test duplicate detection on update (should exclude self)
+ request = factory.put(f"/api/label/{existing_label.id}/", data={"space_id": 1})
+ serializer = LabelSerializer(
+ instance=existing_label,
+ data={"space_id": 1, "name": "existing_label", "color": "#ffffff", "label_scope": ["task"]},
+ context={"request": request},
+ )
+ # Should not raise for same name when updating self
+ assert serializer.is_valid() is True
+
+ # Should raise for duplicate with different label
+ existing_label2 = make_label("existing_label2", space_id=1, color="#ffffff", label_scope=["task"])
+ request = factory.put(f"/api/label/{existing_label2.id}/", data={"space_id": 1})
+ serializer = LabelSerializer(
+ instance=existing_label2,
+ data={"space_id": 1, "name": "existing_label", "color": "#ffffff", "label_scope": ["task"]},
+ context={"request": request},
+ )
+ with pytest.raises(DRFValidationError) as exc:
+ serializer.is_valid(raise_exception=True)
+ error_message = str(exc.value)
+ assert "该空间下已存在名称为「existing_label」的标签" in error_message
+
+ def test_validate_label_scope_parent_validation(self):
+ """validate_label_scope should enforce parent-child scope consistency."""
+ from rest_framework.test import APIRequestFactory
+
+ from bkflow.label.serializers import LabelSerializer
+
+ # Create parent with specific scope
+ parent = make_label("parent", label_scope=["task", "template"], color="#ffffff", space_id=1)
+
+ factory = APIRequestFactory()
+
+ # Valid child scope (subset of parent)
+ request = factory.post("/api/label/", data={"parent_id": parent.id})
+
+ # Valid: child scope is subset of parent scope
+ serializer = LabelSerializer(
+ data={"parent_id": parent.id, "name": "child", "color": "#ffffff", "label_scope": ["task"], "space_id": 1},
+ context={"request": request},
+ )
+
+ # The validation should pass for valid subset
+ assert serializer.is_valid() is True
+
+ # Invalid: child scope not subset of parent scope
+ serializer = LabelSerializer(
+ data={
+ "parent_id": parent.id,
+ "name": "child",
+ "color": "#ffffff",
+ "label_scope": ["common"],
+ "space_id": 1,
+ },
+ context={"request": request},
+ )
+
+ # Should raise ValidationError for invalid scope - this is CORRECT behavior
+ with pytest.raises(DRFValidationError) as exc:
+ serializer.is_valid(raise_exception=True)
+ assert "子标签的范围必须是父标签的子集" == exc.value.detail["label_scope"][0]
- TaskLabelRelation.objects.set_labels(task_id, [label.id])
+ # Invalid: parent doesn't exist
+ request = factory.post("/api/label/", data={"parent_id": 999999})
+ serializer = LabelSerializer(
+ data={"parent_id": 999999, "name": "child", "color": "#ffffff", "label_scope": ["task"], "space_id": 1},
+ context={"request": request},
+ )
+
+ with pytest.raises(DRFValidationError) as exc:
+ serializer.is_valid(raise_exception=True)
+ assert "父标签不存在" in str(exc.value)
+
+ def test_validate_name_empty_and_whitespace(self):
+ """validate_name should reject empty and whitespace-only names."""
+ from rest_framework.test import APIRequestFactory
+
+ from bkflow.label.serializers import LabelSerializer
+
+ factory = APIRequestFactory()
+ request = factory.post("/api/label/", data={"space_id": 1})
+
+ # Test with empty name
+ serializer = LabelSerializer(
+ data={"space_id": 1, "name": "", "color": "#ffffff", "label_scope": ["task"]}, context={"request": request}
+ )
+
+ # Should raise validation error for empty name - this is CORRECT behavior
+ with pytest.raises(DRFValidationError) as exc:
+ serializer.is_valid(raise_exception=True)
+ assert "该字段不能为空。" == exc.value.detail["name"][0]
+
+ # Test with whitespace-only name
+ serializer = LabelSerializer(
+ data={"space_id": 1, "name": " ", "color": "#ffffff", "label_scope": ["task"]},
+ context={"request": request},
+ )
+
+ with pytest.raises(DRFValidationError) as exc:
+ serializer.is_valid(raise_exception=True)
+
+ # Valid name with surrounding whitespace should be trimmed
+ serializer = LabelSerializer(
+ data={"space_id": 1, "name": " valid_name ", "color": "#ffffff", "label_scope": ["task"]},
+ context={"request": request},
+ )
- relations = TaskLabelRelation.objects.filter(task_id=task_id)
- assert relations.count() == 1
- assert relations.first().label_id == label.id
+ assert serializer.is_valid() is True
+ assert serializer.validated_data["name"] == "valid_name"
diff --git a/tests/label/test_label_viewset.py b/tests/label/test_label_viewset.py
index b704966dca..6425855e5b 100644
--- a/tests/label/test_label_viewset.py
+++ b/tests/label/test_label_viewset.py
@@ -16,12 +16,15 @@
to the current version of the project delivered to anyone in the future.
"""
+from unittest.mock import patch
+
import pytest
from blueapps.account.models import User
from rest_framework import status
from rest_framework.test import APIRequestFactory
-from bkflow.label.models import Label
+from bkflow.admin.models import IsolationLevel, ModuleInfo, ModuleType
+from bkflow.label.models import Label, TemplateLabelRelation
from bkflow.label.views import LabelViewSet
from .test_label import make_label
@@ -45,9 +48,22 @@ def setup_method(self):
},
)
+ # Create ModuleInfo for space_id=1 to avoid DoesNotExist error in TaskComponentClient
+ ModuleInfo.objects.get_or_create(
+ space_id=1,
+ defaults={
+ "code": "test_task",
+ "url": "http://test.example.com",
+ "token": "test_token",
+ "type": ModuleType.TASK.value,
+ "isolation_level": IsolationLevel.ONLY_CALCULATION.value,
+ },
+ )
+
self.list_view = LabelViewSet.as_view({"get": "list"})
self.create_view = LabelViewSet.as_view({"post": "create"})
self.destroy_view = LabelViewSet.as_view({"delete": "destroy"})
+ self.get_label_ref_count_view = LabelViewSet.as_view({"get": "get_label_ref_count"})
def test_list_returns_root_labels_with_has_children_flag(self):
"""list should return only root labels when parent_id is not provided."""
@@ -108,6 +124,70 @@ def test_create_nested_name_creates_parent_and_child(self):
assert child.parent_id == parent.id
assert child.full_path == "ParentLabel/ChildLabel"
+ def test_create_with_hierarchical_name_format(self):
+ """create should auto-create parent labels when name contains '/' separator."""
+ # Test creating hierarchical label: "parent/child"
+ data = {
+ "name": "一级标签/二级标签",
+ "space_id": 1,
+ "color": "#ffffff",
+ "label_scope": ["task"],
+ "creator": "test_user",
+ "updated_by": "test_user",
+ }
+
+ request = self.factory.post("/api/label/", data=data)
+ request.user = self.admin_user
+
+ response = self.create_view(request)
+ assert response.status_code == status.HTTP_201_CREATED
+
+ # Verify parent label was created
+ parent_label = Label.objects.get(name="一级标签", parent_id__isnull=True)
+ assert parent_label is not None
+
+ # Verify child label was created with correct parent reference
+ child_label = Label.objects.get(name="二级标签", parent_id=parent_label.id)
+ assert child_label is not None
+ assert child_label.parent_id == parent_label.id
+
+ # Verify response contains child label data
+ response_data = response.data
+ # Adapt to actual API response format
+ assert "data" in response_data
+ assert response_data["data"]["name"] == "二级标签"
+ assert response_data["data"]["parent_id"] == parent_label.id
+
+ def test_create_with_existing_parent_label(self):
+ """create should use existing parent label when available."""
+ # Create parent label first
+ existing_parent = make_label("existing_parent", space_id=1)
+
+ # Test creating child with existing parent
+ data = {
+ "name": "existing_parent/child_label",
+ "space_id": 1,
+ "color": "#ffffff",
+ "label_scope": ["task"],
+ "creator": "test_user",
+ "updated_by": "test_user",
+ }
+
+ request = self.factory.post("/api/label/", data=data)
+ request.user = self.admin_user
+
+ response = self.create_view(request)
+ assert response.status_code == status.HTTP_201_CREATED
+
+ # Verify child was created with existing parent
+ child_label = Label.objects.get(name="child_label", parent_id=existing_parent.id)
+ assert child_label is not None
+ assert child_label.parent_id == existing_parent.id
+
+ # Verify no duplicate parent was created
+ parent_count = Label.objects.filter(name="existing_parent", parent_id__isnull=True).count()
+ assert parent_count == 1
+
def test_destroy_root_label_cascades_children(self):
"""destroy should delete root label and all of its direct children."""
root = make_label("root_to_delete", space_id=1)
@@ -118,8 +198,208 @@ def test_destroy_root_label_cascades_children(self):
request = self.factory.delete(url)
request.user = self.admin_user
- response = self.destroy_view(request, pk=root.id)
- assert response.status_code == status.HTTP_204_NO_CONTENT
+ # Mock TaskComponentClient.delete_task_label_relation to avoid external API call
+ with patch("bkflow.label.views.TaskComponentClient") as mock_client:
+ mock_instance = mock_client.return_value
+ mock_instance.delete_task_label_relation.return_value = {"result": True}
+
+ response = self.destroy_view(request, pk=root.id)
+ assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Label.objects.filter(id=root.id).exists()
assert not Label.objects.filter(id__in=[child1.id, child2.id]).exists()
+
+ def test_get_label_ref_count_success(self):
+ """get_label_ref_count should return template and task reference counts."""
+ # Create test labels
+ l1 = make_label("label1", space_id=1)
+ l2 = make_label("label2", space_id=1)
+
+ # Create template label relations
+ TemplateLabelRelation.objects.create(template_id=100, label_id=l1.id)
+ TemplateLabelRelation.objects.create(template_id=100, label_id=l2.id)
+ TemplateLabelRelation.objects.create(template_id=101, label_id=l1.id)
+
+ # Mock TaskComponentClient to return task reference counts
+ with patch("bkflow.label.views.TaskComponentClient") as mock_client:
+ mock_instance = mock_client.return_value
+ mock_instance.get_task_label_ref_count.return_value = {
+ "result": True,
+ "data": {str(l1.id): 3, str(l2.id): 1}, # l1 has 3 task references # l2 has 1 task reference
+ }
+
+ # Make API request
+ request = self.factory.get(f"/api/label/get_label_ref_count/?space_id=1&label_ids={l1.id},{l2.id}")
+ request.user = self.admin_user
+
+ response = self.get_label_ref_count_view(request)
+ assert response.status_code == status.HTTP_200_OK
+
+ # Verify response structure - adapt to actual API format
+ data = response.data
+ assert data["result"] is True
+ assert "data" in data
+
+ # Verify reference counts in response data
+ ref_data = data["data"]
+ assert str(l1.id) in ref_data
+ assert str(l2.id) in ref_data
+
+ # Verify template reference counts
+ assert ref_data[str(l1.id)]["template_count"] == 2 # l1 has 2 template references
+ assert ref_data[str(l2.id)]["template_count"] == 1 # l2 has 1 template reference
+
+ # Verify task reference counts from mock
+ assert ref_data[str(l1.id)]["task_count"] == 3
+ assert ref_data[str(l2.id)]["task_count"] == 1
+
+ def test_get_label_ref_count_client_error(self):
+ """get_label_ref_count should handle TaskComponentClient errors gracefully."""
+ l1 = make_label("label1", space_id=1)
+
+ with patch("bkflow.label.views.TaskComponentClient") as mock_client:
+ mock_instance = mock_client.return_value
+ mock_instance.get_task_label_ref_count.return_value = {
+ "result": False,
+ "message": "Task service unavailable",
+ }
+
+ request = self.factory.get(f"/api/label/get_label_ref_count/?space_id=1&label_ids={l1.id}")
+ request.user = self.admin_user
+
+ response = self.get_label_ref_count_view(request)
+ # The API returns 200 even when task service fails
+ assert response.status_code == status.HTTP_200_OK
+
+ # Check the actual response structure
+ response_data = response.data
+
+ # The API might return the error message directly
+ # or handle it differently. Let's check the structure
+ if "result" in response_data:
+ # If result is False, that's expected for service errors
+ if response_data["result"] is False:
+ assert "message" in response_data
+ assert "Task service unavailable" in str(response_data["message"])
+ else:
+ # If result is True, check that data structure is present
+ assert "data" in response_data
+ assert isinstance(response_data["data"], dict)
+ else:
+ # Alternative response format
+ assert "message" in response_data
+ assert "Task service unavailable" in str(response_data["message"])
+
+ # The important thing is that the API doesn't crash
+ # and returns a consistent response structure
+
+
+class TestLabelFilter:
+ """Tests for LabelFilter filtering logic."""
+
+ def test_filter_space_id_includes_default_and_specific_space(self):
+ """filter_space_id should include both default (-1) and specific space labels."""
+ from bkflow.label.views import LabelFilter
+
+ # Create labels in different spaces
+ default_label = make_label("default", space_id=-1)
+ space1_label = make_label("space1", space_id=1)
+ space2_label = make_label("space2", space_id=2)
+
+ # Filter for space_id=1 should include default (-1) and space1 labels
+ filter_instance = LabelFilter()
+ queryset = Label.objects.all()
+ filtered = filter_instance.filter_space_id(queryset, "space_id", 1)
+
+ filtered_ids = list(filtered.values_list("id", flat=True))
+ assert default_label.id in filtered_ids
+ assert space1_label.id in filtered_ids
+ assert space2_label.id not in filtered_ids
+
+ def test_filter_label_scope_includes_common_and_specific_scope(self):
+ """filter_label_scope should include both common and specific scope labels."""
+ from bkflow.label.views import LabelFilter
+
+ # Create labels with different scopes
+ task_label = make_label("task", label_scope=["task"])
+ template_label = make_label("template", label_scope=["template"])
+ common_label = make_label("common", label_scope=["common"])
+ multi_label = make_label("multi", label_scope=["task", "common"])
+
+ filter_instance = LabelFilter()
+ queryset = Label.objects.all()
+
+ # Filter for "task" scope should include task and common labels
+ filtered = filter_instance.filter_label_scope(queryset, "label_scope", "task")
+ filtered_ids = list(filtered.values_list("id", flat=True))
+
+ assert task_label.id in filtered_ids
+ assert common_label.id in filtered_ids
+ assert multi_label.id in filtered_ids
+ assert template_label.id not in filtered_ids
+
+ def test_filter_parent_id_direct_match(self):
+ """filter_parent_id should filter by exact parent_id match."""
+ from bkflow.label.views import LabelFilter
+
+ parent = make_label("parent")
+ child1 = make_label("child1", parent_id=parent.id)
+ child2 = make_label("child2", parent_id=parent.id)
+
+ # Create another valid parent for testing
+ other_parent = make_label("other_parent")
+ other_child = make_label("other_child", parent_id=other_parent.id)
+
+ filter_instance = LabelFilter()
+ queryset = Label.objects.all()
+
+ # Filter by parent_id
+ filtered = filter_instance.filter_parent_id(queryset, "parent_id", parent.id)
+ filtered_ids = list(filtered.values_list("id", flat=True))
+
+ # Verify only children of the specified parent are returned
+ assert child1.id in filtered_ids
+ assert child2.id in filtered_ids
+ assert parent.id not in filtered_ids # parent itself should not be included
+ assert other_child.id not in filtered_ids # children of other parent should not be included
+
+ def test_filter_name_case_insensitive_search(self):
+ """filter_name should perform case-insensitive partial matching."""
+ from bkflow.label.views import LabelFilter
+
+ apple_label = make_label("Apple")
+ banana_label = make_label("Banana")
+ pineapple_label = make_label("Pineapple")
+
+ filter_instance = LabelFilter()
+ queryset = Label.objects.all()
+
+ # Search for "apple" should match both Apple and Pineapple
+ filtered = filter_instance.filter_name(queryset, "name", "apple")
+ filtered_ids = list(filtered.values_list("id", flat=True))
+
+ assert apple_label.id in filtered_ids
+ assert pineapple_label.id in filtered_ids
+ assert banana_label.id not in filtered_ids
+
+ def test_filter_is_default_boolean_filter(self):
+ """filter_is_default should filter by boolean is_default field."""
+ from bkflow.label.views import LabelFilter
+
+ default_label = make_label("default", is_default=True)
+ non_default_label = make_label("non_default", is_default=False)
+
+ filter_instance = LabelFilter()
+ queryset = Label.objects.all()
+
+ # Filter for default labels
+ filtered = filter_instance.filter_is_default(queryset, "is_default", True)
+ filtered_ids = list(filtered.values_list("id", flat=True))
+ assert default_label.id in filtered_ids
+ assert non_default_label.id not in filtered_ids
+
+ # Filter for non-default labels
+ filtered = filter_instance.filter_is_default(queryset, "is_default", False)
+ filtered_ids = list(filtered.values_list("id", flat=True))
+ assert non_default_label.id in filtered_ids
+ assert default_label.id not in filtered_ids
From 0f89c17e9d0d9111217c123eaf5444f4687c03e3 Mon Sep 17 00:00:00 2001
From: Yuikill <1191184301@qq.com>
Date: Thu, 5 Feb 2026 16:54:29 +0800
Subject: [PATCH 09/13] =?UTF-8?q?fix:=20=E6=A0=87=E7=AD=BE=E7=AE=A1?=
=?UTF-8?q?=E7=90=86=E5=88=97=E8=A1=A8=E4=BA=8C=E7=BA=A7=E6=A0=87=E7=AD=BE?=
=?UTF-8?q?=E5=BC=95=E7=94=A8=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/views/admin/Space/labelManage/index.vue | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/frontend/src/views/admin/Space/labelManage/index.vue b/frontend/src/views/admin/Space/labelManage/index.vue
index e29d1d3293..3883a19cd0 100644
--- a/frontend/src/views/admin/Space/labelManage/index.vue
+++ b/frontend/src/views/admin/Space/labelManage/index.vue
@@ -56,8 +56,8 @@
- {{ 1 }}
- {{ 2 }}
+ {{ row.reference.template_count }}
+ {{ row.reference.task_count }}
From 49593bfe6caf84a7bbc43dfd09a49070629bd323 Mon Sep 17 00:00:00 2001
From: yunchao
Date: Fri, 6 Feb 2026 15:15:48 +0800
Subject: [PATCH 10/13] =?UTF-8?q?feat:=20=E5=8D=95=E5=85=83=E6=B5=8B?=
=?UTF-8?q?=E8=AF=95=E4=BF=AE=E5=A4=8D=20--story=3D129277621?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app_desc.yaml | 2 +-
bkflow/label/models.py | 5 +++--
bkflow/label/views.py | 4 +++-
bkflow/task/models.py | 16 ++++++++--------
bkflow/task/views.py | 2 +-
tests/interface/task/test_task_views.py | 7 +++++--
6 files changed, 21 insertions(+), 15 deletions(-)
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/label/models.py b/bkflow/label/models.py
index 17da1cb559..75bd321032 100644
--- a/bkflow/label/models.py
+++ b/bkflow/label/models.py
@@ -98,7 +98,7 @@ class Label(models.Model):
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", help_text="标签范围(支持多选,如['task', 'common'])"
+ verbose_name=_("标签范围"), default=["template", "task"], help_text="标签范围(支持多选,如['task', 'common'])"
)
# 核心修改:用IntegerField存储父标签ID,替代外键
@@ -118,7 +118,8 @@ class Meta:
def __str__(self):
# 手动查询父标签名称(替代外键的self.parent.name)
- parent_name = self.get_parent_label().name if self.get_parent_label() else "无"
+ 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):
diff --git a/bkflow/label/views.py b/bkflow/label/views.py
index 34cf3c9b89..7aabe775d5 100644
--- a/bkflow/label/views.py
+++ b/bkflow/label/views.py
@@ -137,7 +137,9 @@ def create(self, request, *args, **kwargs):
try:
# 尝试查找已存在的父标签 (一级标签,parent_id为空)
- parent_label = Label.objects.get(name=parent_name, parent_id__isnull=True)
+ 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:
# 如果一级标签不存在,则自动创建
diff --git a/bkflow/task/models.py b/bkflow/task/models.py
index 011b8b5504..92dd5f779e 100644
--- a/bkflow/task/models.py
+++ b/bkflow/task/models.py
@@ -614,26 +614,26 @@ 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,
+ "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)
@@ -650,14 +650,14 @@ def fetch_tasks_labels(self, task_ids):
"""
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)
diff --git a/bkflow/task/views.py b/bkflow/task/views.py
index 4051bc3cb6..6c0cac8251 100644
--- a/bkflow/task/views.py
+++ b/bkflow/task/views.py
@@ -248,7 +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=serializer.validated_data["task_ids"]).delete()
+ 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/tests/interface/task/test_task_views.py b/tests/interface/task/test_task_views.py
index 0f43cd7023..7b1a7ca79e 100644
--- a/tests/interface/task/test_task_views.py
+++ b/tests/interface/task/test_task_views.py
@@ -54,7 +54,10 @@ def setup_method(self):
def test_get_task_list(self, mock_client_class):
"""Test get_task_list method"""
mock_client = mock.Mock()
- mock_client.task_list.return_value = {"result": True, "data": [{"id": 1, "name": "Task 1"}]}
+ mock_client.task_list.return_value = {
+ "result": True,
+ "data": {"results": [{"id": 1, "name": "Task 1", "labels": []}]},
+ }
mock_client_class.return_value = mock_client
view = TaskInterfaceAdminViewSet.as_view({"get": "get_task_list"})
@@ -65,7 +68,7 @@ def test_get_task_list(self, mock_client_class):
assert response.status_code == 200
mock_client.task_list.assert_called_once()
- assert mock_client_class.called_with(space_id=self.space.id)
+ mock_client_class.assert_called_once_with(space_id=self.space.id)
@mock.patch("bkflow.interface.task.view.TaskComponentClient")
def test_get_tasks_states(self, mock_client_class):
From e0b1440647f4b2d6e89479b94119d21ad2f3ac78 Mon Sep 17 00:00:00 2001
From: Yuikill <1191184301@qq.com>
Date: Fri, 6 Feb 2026 17:43:53 +0800
Subject: [PATCH 11/13] =?UTF-8?q?fix:=20=E6=A0=87=E7=AD=BE=E5=88=97?=
=?UTF-8?q?=E8=A1=A8=E5=B1=95=E5=BC=80=E4=BA=8C=E7=BA=A7=E6=A0=87=E7=AD=BE?=
=?UTF-8?q?=E6=8A=A5=E9=94=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../views/admin/Space/labelManage/index.vue | 64 +++++++++++--------
1 file changed, 39 insertions(+), 25 deletions(-)
diff --git a/frontend/src/views/admin/Space/labelManage/index.vue b/frontend/src/views/admin/Space/labelManage/index.vue
index 3883a19cd0..7de8ffc648 100644
--- a/frontend/src/views/admin/Space/labelManage/index.vue
+++ b/frontend/src/views/admin/Space/labelManage/index.vue
@@ -53,12 +53,18 @@
-
- {{ row.reference.template_count }}
- {{ row.reference.task_count }}
-
+
+
+ {{
+ row.reference.template_count
+ }}
+ {{
+ row.reference.task_count
+ }}
+
+
@@ -216,9 +222,6 @@ export default {
deleting: false,
};
},
- mounted() {
- this.getLabelList();
- },
methods: {
...mapActions('label', [
'loadLabelList',
@@ -228,15 +231,7 @@ export default {
async getLabelList(parent = null) {
const parentId = parent ? parent.id : null;
// 二级标签拉取全量数据
- const params = {
- space_id: this.spaceId,
- parent_id: parentId,
- limit: parentId ? 1000 : this.pagination.limit,
- offset: parentId
- ? 0
- : (this.pagination.current - 1) * this.pagination.limit,
- ...this.requestData,
- };
+ const params = this.getQueryData(parentId);
try {
if (parentId) {
parent.childrenLoading = true;
@@ -247,12 +242,12 @@ export default {
// 1. 获取标签列表
const res = await this.loadLabelList(params);
const list = res.data?.results.map(item =>
- // 拼接 full_path
- ({
- ...item,
- childrenLoading: false,
- })
- ) || [];
+ // 拼接 full_path
+ ({
+ ...item,
+ childrenLoading: false,
+ })
+ ) || [];
// 2. 获取引用数据
let referenceMap = {};
@@ -267,7 +262,7 @@ export default {
// 3. 绑定 reference
const bindReference = (labels) => {
labels.forEach((label) => {
- label.reference = referenceMap[label.id];
+ label.reference = referenceMap[label.id] || { template_count: 0, task_count: 0 };
});
};
@@ -290,6 +285,25 @@ export default {
}
}
},
+ getQueryData(parentId) {
+ const {
+ name,
+ label_scope,
+ is_default,
+ } = this.requestData;
+ const data = {
+ name,
+ label_scope,
+ is_default,
+ space_id: this.spaceId,
+ parent_id: parentId,
+ limit: parentId ? 1000 : this.pagination.limit,
+ offset: parentId
+ ? 0
+ : (this.pagination.current - 1) * this.pagination.limit,
+ };
+ return data;
+ },
onEditLabel(label) {
this.editLabel = label;
this.isEdit = true;
From bf8aa6e1110c34e043633c18c845b1fa772ed6ed Mon Sep 17 00:00:00 2001
From: yunchao
Date: Mon, 9 Feb 2026 15:26:42 +0800
Subject: [PATCH 12/13] =?UTF-8?q?fix:=20=E4=BB=A3=E7=A0=81=E5=AE=A1?=
=?UTF-8?q?=E6=9F=A5=E4=BF=AE=E5=A4=8D=20--story=3D129277621?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
bkflow/interface/task/view.py | 3 +--
bkflow/label/serializers.py | 26 ++++++++++++++++++++++++++
bkflow/label/views.py | 2 +-
3 files changed, 28 insertions(+), 3 deletions(-)
diff --git a/bkflow/interface/task/view.py b/bkflow/interface/task/view.py
index fd1a5e1646..2d31eb96d6 100644
--- a/bkflow/interface/task/view.py
+++ b/bkflow/interface/task/view.py
@@ -17,7 +17,6 @@
to the current version of the project delivered to anyone in the future.
"""
import logging
-from copy import deepcopy
from django.db.models import Q
from django.utils import timezone
@@ -59,7 +58,7 @@ class TaskInterfaceAdminViewSet(GenericViewSet):
def get_task_list(self, request, space_id):
client = TaskComponentClient(space_id=space_id)
# 把标签名称转换为id进行搜索
- query_params = deepcopy(request.query_params)
+ query_params = request.query_params.copy()
labels = request.query_params.get("label", "")
label_ids = Label.get_label_ids_by_names(labels)
if label_ids:
diff --git a/bkflow/label/serializers.py b/bkflow/label/serializers.py
index f558eaec6f..ec41ede8f3 100644
--- a/bkflow/label/serializers.py
+++ b/bkflow/label/serializers.py
@@ -136,3 +136,29 @@ 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/views.py b/bkflow/label/views.py
index 7aabe775d5..5bb00234dd 100644
--- a/bkflow/label/views.py
+++ b/bkflow/label/views.py
@@ -190,7 +190,6 @@ 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
- label_ids = validated_params["label_ids"].split(",")
# 调用任务组件接口获取任务引用数量
client = TaskComponentClient(space_id=validated_params["space_id"])
@@ -200,6 +199,7 @@ def get_label_ref_count(self, request, *args, **kwargs):
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"))
)
From 292d1948a052488e174ad0b5cbab3eba690e38f0 Mon Sep 17 00:00:00 2001
From: yunchao
Date: Tue, 10 Feb 2026 10:35:42 +0800
Subject: [PATCH 13/13] =?UTF-8?q?feat:=20=E5=8D=95=E5=85=83=E6=B5=8B?=
=?UTF-8?q?=E8=AF=95=E8=A1=A5=E5=85=85=20--story=3D129277621?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.pre-commit-config.yaml | 3 +-
scripts/run_all_unit_test.sh | 2 +-
scripts/run_interface_unit_test.sh | 2 +-
tests/engine/task/test_models.py | 481 ++++++++++++-
tests/engine/task/test_task_views.py | 381 +++++++++-
tests/engine/utils/test_client.py | 245 +++++++
tests/interface/apigw/test_update_template.py | 229 ++++++
tests/interface/task/test_task_views.py | 83 +++
.../interface/template/test_template_views.py | 668 ++++++++++++++++++
tests/label/test_label.py | 161 +++++
tests/label/test_label_viewset.py | 85 +++
11 files changed, 2335 insertions(+), 5 deletions(-)
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/scripts/run_all_unit_test.sh b/scripts/run_all_unit_test.sh
index 0102243849..bc111aeb84 100644
--- a/scripts/run_all_unit_test.sh
+++ b/scripts/run_all_unit_test.sh
@@ -4,7 +4,7 @@ source /root/.envs/bkflow/bin/activate
set -e
export $(cat tests/interface.env | xargs)
echo "开始运行${BKFLOW_MODULE_TYPE}测试"
-pytest tests/interface tests/plugins tests/project_settings tests/contrib tests/decision_table
+pytest tests/interface tests/plugins tests/project_settings tests/contrib tests/decision_table tests/label
set +e
set -e
diff --git a/scripts/run_interface_unit_test.sh b/scripts/run_interface_unit_test.sh
index 08094496d6..6b0f4b79ea 100644
--- a/scripts/run_interface_unit_test.sh
+++ b/scripts/run_interface_unit_test.sh
@@ -2,4 +2,4 @@
set -e
export $(cat tests/interface.env | xargs)
echo $BKFLOW_MODULE_TYPE
-pytest tests/interface tests/plugins tests/project_settings tests/contrib tests/decision_table
+pytest tests/interface tests/plugins tests/project_settings tests/contrib tests/decision_table tests/label
diff --git a/tests/engine/task/test_models.py b/tests/engine/task/test_models.py
index af1a4db4a4..ec3f2b033a 100644
--- a/tests/engine/task/test_models.py
+++ b/tests/engine/task/test_models.py
@@ -16,13 +16,26 @@
to the current version of the project delivered to anyone in the future.
"""
-import pytest
+import json
+import pytest
+from bamboo_engine import states
+from django.utils import timezone
+from django_celery_beat.models import CrontabSchedule as DjangoCeleryBeatCrontabSchedule
+from django_celery_beat.models import PeriodicTask as DjangoCeleryBeatPeriodicTask
+from pipeline.core.constants import PE
+
+from bkflow.constants import TaskTriggerMethod
+from bkflow.task import models as task_models
from bkflow.task.models import (
AutoRetryNodeStrategy,
EngineSpaceConfig,
EngineSpaceConfigValueType,
+ PeriodicTask,
+ TaskExecutionSnapshot,
+ TaskFlowRelation,
TaskInstance,
+ TaskLabelRelation,
TaskMockData,
TaskOperationRecord,
TimeoutNodeConfig,
@@ -96,6 +109,280 @@ def test_get_notify_info(self):
assert "types" in notify_info
assert "test_executor" in notify_info["receivers"]
+ def test_manager_set_finished_and_revoked(self):
+ """Test TaskInstanceManager.set_finished/set_revoked"""
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+
+ TaskInstance.objects.set_finished(task_instance.instance_id)
+ task_instance.refresh_from_db()
+ assert task_instance.is_finished is True
+ assert task_instance.finish_time is not None
+
+ TaskInstance.objects.set_revoked(task_instance.instance_id)
+ task_instance.refresh_from_db()
+ assert task_instance.is_revoked is True
+ assert task_instance.finish_time is not None
+
+ def test_delete_instance_real_delete(self):
+ """Test TaskInstance.delete(real_delete=True)"""
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ task_id = task_instance.id
+
+ task_instance.delete(real_delete=True)
+ assert not TaskInstance.objects.filter(id=task_id).exists()
+
+ def test_set_execution_data_creates_snapshot_when_missing(self):
+ """Cover TaskInstance.set_execution_data DoesNotExist branch"""
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ TaskInstance.objects.filter(id=task_instance.id).update(execution_snapshot_id=99999999)
+ task_instance.refresh_from_db()
+
+ data = build_default_pipeline_tree()
+ task_instance.set_execution_data(data)
+ task_instance.refresh_from_db()
+
+ assert TaskExecutionSnapshot.objects.filter(id=task_instance.execution_snapshot_id).exists()
+
+ def test_calculate_tree_info_returns_when_execution_data_is_none(self):
+ """Cover TaskInstance.calculate_tree_info early return when execution_data is None"""
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ TaskInstance.objects.filter(id=task_instance.id).update(execution_snapshot_id=None, tree_info_id=None)
+ task_instance.refresh_from_db()
+
+ task_instance.calculate_tree_info()
+ task_instance.refresh_from_db()
+ assert task_instance.tree_info_id is None
+
+ def test_calculate_tree_info_updates_when_tree_info_exists(self):
+ """Cover TaskInstance.calculate_tree_info update branch"""
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ task_instance.calculate_tree_info()
+ tree_info_id = task_instance.tree_info_id
+
+ task_instance.calculate_tree_info()
+ task_instance.refresh_from_db()
+ assert task_instance.tree_info_id == tree_info_id
+
+ def test_create_instance_mock_creates_task_mock_data(self):
+ """Cover TaskInstanceManager.create_instance mock_data mapping branch"""
+ pipeline_tree = build_default_pipeline_tree()
+ node_id = list(pipeline_tree[PE.activities].keys())[0]
+ mock_data = {
+ "nodes": [node_id],
+ "outputs": {node_id: {"k": "v"}},
+ "mock_data_ids": {"foo": "bar"},
+ }
+ task_instance = TaskInstance.objects.create_instance(
+ space_id=1,
+ pipeline_tree=pipeline_tree,
+ create_method="MOCK",
+ mock_data=mock_data,
+ )
+
+ task_mock_data = TaskMockData.objects.get(taskflow_id=task_instance.id)
+ mapped_node_id = task_mock_data.data["nodes"][0]
+ assert mapped_node_id in task_instance.execution_data[PE.activities]
+ assert list(task_mock_data.data["outputs"].keys()) == [mapped_node_id]
+ assert task_mock_data.mock_data_ids == {"foo": "bar"}
+
+ def test_change_parent_task_node_state_to_running_without_relation(self):
+ """Cover TaskInstance.change_parent_task_node_state_to_running no relation branch"""
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ TaskInstance.objects.filter(id=task_instance.id).update(trigger_method=TaskTriggerMethod.subprocess.name)
+ task_instance.refresh_from_db()
+
+ assert not TaskFlowRelation.objects.filter(task_id=task_instance.id).exists()
+ task_instance.change_parent_task_node_state_to_running()
+
+ def test_node_id_set_triggers_calculate_tree_info(self):
+ """Cover TaskInstance.node_id_set branch that triggers calculate_tree_info"""
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ TaskInstance.objects.filter(id=task_instance.id).update(tree_info_id=None)
+ task_instance.refresh_from_db()
+
+ node_ids = task_instance.node_id_set
+ assert isinstance(node_ids, set)
+ task_instance.refresh_from_db()
+ assert task_instance.tree_info_id is not None
+
+ def test_get_notify_info_with_more_receivers(self):
+ """Cover TaskInstance.get_notify_info more_receivers branch and executor filtering"""
+ task_instance = TaskInstance.objects.create_instance(
+ space_id=1,
+ pipeline_tree=build_default_pipeline_tree(),
+ executor="user_a",
+ )
+ TaskInstance.objects.filter(id=task_instance.id).update(
+ extra_info={
+ "notify_config": {
+ "notify_receivers": {"more_receiver": "user_a,user_b,user_c"},
+ "notify_type": {"success": ["weixin"], "fail": ["weixin"]},
+ "notify_format": {"title": "t", "content": "c"},
+ }
+ }
+ )
+ task_instance.refresh_from_db()
+
+ notify_info = task_instance.get_notify_info()
+ assert notify_info["receivers"].count("user_a") == 1
+ assert set(notify_info["receivers"]) == {"user_a", "user_b", "user_c"}
+
+
+@pytest.mark.django_db(transaction=True)
+class TestTaskInstanceMoreCoverage:
+ def test_taskinstance_properties(self):
+ """
+ Cover TaskInstance snapshot/data/pipeline_tree/tree_info/
+ execution_snapshot/execution_data/node_id_set/elapsed_time
+ """
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+
+ assert task_instance.snapshot is not None
+ assert isinstance(task_instance.data, dict)
+
+ assert task_instance.execution_snapshot is not None
+ assert isinstance(task_instance.execution_data, dict)
+ assert task_instance.pipeline_tree == task_instance.execution_data
+
+ task_instance.calculate_tree_info()
+ assert task_instance.tree_info is not None
+ assert isinstance(task_instance.node_id_set, set)
+
+ TaskInstance.objects.filter(id=task_instance.id).update(
+ start_time=timezone.now(),
+ finish_time=timezone.now(),
+ )
+ task_instance.refresh_from_db()
+ assert task_instance.elapsed_time is not None
+
+ def test_replace_id_with_subprocess_node(self, monkeypatch):
+ """Cover TaskInstance._replace_id subprocess recursion branch"""
+
+ def _noop_replace_all_id(_data):
+ return {}
+
+ monkeypatch.setattr(task_models, "replace_all_id", _noop_replace_all_id)
+
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ exec_data = {
+ PE.start_event: {"id": "s0"},
+ PE.end_event: {"id": "e0"},
+ PE.gateways: [],
+ PE.activities: {
+ "sub1": {
+ PE.type: PE.SubProcess,
+ "pipeline": {
+ PE.start_event: {"id": "s1"},
+ PE.end_event: {"id": "e1"},
+ PE.gateways: [],
+ PE.activities: {},
+ },
+ },
+ "act2": {PE.type: "ServiceActivity"},
+ },
+ }
+
+ task_instance._replace_id(exec_data)
+ assert exec_data[PE.activities]["sub1"]["pipeline"]["id"] == "sub1"
+
+ def test_get_node_id_set_with_subprocess_node(self):
+ """Cover TaskInstance._get_node_id_set subprocess recursion branch"""
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+
+ data = {
+ PE.start_event: {"id": "s0"},
+ PE.end_event: {"id": "e0"},
+ PE.gateways: ["g1"],
+ PE.activities: {
+ "sub1": {
+ PE.type: PE.SubProcess,
+ "pipeline": {
+ PE.start_event: {"id": "s1"},
+ PE.end_event: {"id": "e1"},
+ PE.gateways: [],
+ PE.activities: {"a1": {PE.type: "ServiceActivity"}},
+ },
+ }
+ },
+ }
+
+ node_ids = set()
+ task_instance._get_node_id_set(node_ids, data)
+ assert {"s0", "e0", "g1", "sub1", "s1", "e1", "a1"}.issubset(node_ids)
+
+ def test_change_parent_task_node_state_to_running_not_subprocess(self):
+ """Cover change_parent_task_node_state_to_running early return when not child task"""
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ task_instance.change_parent_task_node_state_to_running()
+
+ def test_change_parent_task_node_state_to_running_success_path(self, monkeypatch):
+ """Cover deeper branches in change_parent_task_node_state_to_running with runtime mocks"""
+ parent_task = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ child_task = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ TaskInstance.objects.filter(id=child_task.id).update(trigger_method=TaskTriggerMethod.subprocess.name)
+ child_task.refresh_from_db()
+
+ TaskFlowRelation.objects.create(
+ task_id=child_task.id,
+ parent_task_id=parent_task.id,
+ root_task_id=parent_task.id,
+ extra_info={"node_id": "node_1", "node_version": 1},
+ )
+
+ class DummySchedule:
+ id = 123
+
+ class DummyQS:
+ def update(self, **kwargs):
+ return 1
+
+ class DummyRuntime:
+ def get_state(self, node_id):
+ return type("S", (), {"name": states.FAILED, "version": 1})()
+
+ def get_schedule_with_node_and_version(self, node_id, version):
+ return DummySchedule()
+
+ def set_state(self, **kwargs):
+ return None
+
+ def get_execution_data_outputs(self, node_id):
+ return {"ex_data": "x", "k": "v"}
+
+ def set_execution_data_outputs(self, node_id, data_outputs):
+ assert "ex_data" not in data_outputs
+ return None
+
+ monkeypatch.setattr(task_models, "BambooDjangoRuntime", lambda: DummyRuntime())
+ monkeypatch.setattr(task_models.DBSchedule.objects, "filter", lambda **kwargs: DummyQS())
+
+ child_task.change_parent_task_node_state_to_running()
+
+ def test_change_parent_task_node_state_to_running_state_mismatch_returns(self, monkeypatch):
+ """Cover change_parent_task_node_state_to_running early return when node state/version mismatch"""
+ parent_task = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ child_task = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ TaskInstance.objects.filter(id=child_task.id).update(trigger_method=TaskTriggerMethod.subprocess.name)
+ child_task.refresh_from_db()
+
+ TaskFlowRelation.objects.create(
+ task_id=child_task.id,
+ parent_task_id=parent_task.id,
+ root_task_id=parent_task.id,
+ extra_info={"node_id": "node_1", "node_version": 1},
+ )
+
+ class DummyRuntime:
+ def get_state(self, node_id):
+ return type("S", (), {"name": states.RUNNING, "version": 1})()
+
+ def get_schedule_with_node_and_version(self, node_id, version):
+ raise AssertionError("should not reach schedule branch")
+
+ monkeypatch.setattr(task_models, "BambooDjangoRuntime", lambda: DummyRuntime())
+
+ child_task.change_parent_task_node_state_to_running()
+
@pytest.mark.django_db(transaction=True)
class TestTaskMockData:
@@ -159,6 +446,177 @@ def test_create_timeout_config(self):
assert config.timeout == 300
+@pytest.mark.django_db(transaction=True)
+class TestTimeoutNodeConfigManager:
+ def test_batch_create_node_timeout_config_parse_fail(self, monkeypatch):
+ """Cover TimeoutNodeConfigManager.batch_create_node_timeout_config parse fail branch"""
+
+ def _fake_parse(_pipeline_tree):
+ return {"result": False, "data": []}
+
+ monkeypatch.setattr(task_models, "parse_node_timeout_configs", _fake_parse)
+
+ TimeoutNodeConfig.objects.batch_create_node_timeout_config(
+ taskflow_id=1,
+ root_pipeline_id="root_123",
+ pipeline_tree={},
+ )
+ assert TimeoutNodeConfig.objects.count() == 0
+
+
+@pytest.mark.django_db(transaction=True)
+class TestPeriodicTask:
+ def test_create_task_and_enabled_property(self):
+ """Test PeriodicTaskManager.create_task and PeriodicTask.enabled"""
+ cron = {"minute": "0", "hour": "*", "day_of_week": "*", "day_of_month": "*", "month_of_year": "*"}
+ periodic_task = PeriodicTask.objects.create_task(
+ name="pt",
+ template_id=1,
+ trigger_id=1,
+ cron=cron,
+ config={"template_id": 1},
+ creator="tester",
+ extra_info={"k": "v"},
+ is_enabled=False,
+ )
+
+ assert periodic_task.celery_task_id is not None
+ assert periodic_task.enabled is False
+
+ def test_modify_cron_must_disabled_branch(self):
+ """Cover PeriodicTask.modify_cron must_disabled and enabled branch"""
+ schedule = DjangoCeleryBeatCrontabSchedule.objects.create(
+ minute="0",
+ hour="*",
+ day_of_week="*",
+ day_of_month="*",
+ month_of_year="*",
+ )
+ celery_task = DjangoCeleryBeatPeriodicTask.objects.create(
+ crontab=schedule,
+ name="celery_pt_1",
+ task="bkflow.task.celery.tasks.bkflow_periodic_task_start",
+ enabled=True,
+ kwargs=json.dumps({"periodic_task_id": 1}),
+ )
+ periodic_task = PeriodicTask.objects.create(
+ name="pt",
+ template_id=1,
+ trigger_id=1,
+ cron={"minute": "0"},
+ celery_task=celery_task,
+ config={},
+ creator="tester",
+ )
+
+ new_cron = {"minute": "5", "hour": "*", "day_of_week": "*", "day_of_month": "*", "month_of_year": "*"}
+ periodic_task.modify_cron(new_cron, must_disabled=True)
+
+ periodic_task.celery_task.refresh_from_db()
+ assert periodic_task.celery_task.crontab.minute == "5"
+ assert periodic_task.celery_task.enabled is True
+
+ def test_modify_cron_else_branch(self):
+ """Cover PeriodicTask.modify_cron else branch"""
+ schedule = DjangoCeleryBeatCrontabSchedule.objects.create(
+ minute="0",
+ hour="*",
+ day_of_week="*",
+ day_of_month="*",
+ month_of_year="*",
+ )
+ celery_task = DjangoCeleryBeatPeriodicTask.objects.create(
+ crontab=schedule,
+ name="celery_pt_2",
+ task="bkflow.task.celery.tasks.bkflow_periodic_task_start",
+ enabled=False,
+ kwargs=json.dumps({"periodic_task_id": 1}),
+ )
+ periodic_task = PeriodicTask.objects.create(
+ name="pt",
+ template_id=1,
+ trigger_id=1,
+ cron={"minute": "0"},
+ celery_task=celery_task,
+ config={},
+ creator="tester",
+ )
+
+ new_cron = {"minute": "10", "hour": "*", "day_of_week": "*", "day_of_month": "*", "month_of_year": "*"}
+ periodic_task.modify_cron(new_cron, must_disabled=True)
+
+ periodic_task.celery_task.refresh_from_db()
+ assert periodic_task.celery_task.crontab.minute == "10"
+
+ def test_delete_periodic_task_deletes_celery_task(self):
+ """Cover PeriodicTask.delete"""
+ cron = {"minute": "0", "hour": "*", "day_of_week": "*", "day_of_month": "*", "month_of_year": "*"}
+ periodic_task = PeriodicTask.objects.create_task(
+ name="pt_delete",
+ template_id=1,
+ trigger_id=1,
+ cron=cron,
+ config={},
+ creator="tester",
+ )
+ celery_task_id = periodic_task.celery_task_id
+ periodic_task_id = periodic_task.id
+
+ periodic_task.delete()
+ assert not PeriodicTask.objects.filter(id=periodic_task_id).exists()
+ assert not DjangoCeleryBeatPeriodicTask.objects.filter(id=celery_task_id).exists()
+
+
+@pytest.mark.django_db(transaction=True)
+class TestPeriodicTaskMoreCoverage:
+ def test_default_cron_and_unicode(self):
+ """Cover default_cron and PeriodicTask.__unicode__"""
+ cron = task_models.default_cron()
+ assert cron["minute"] == "0"
+ assert cron["hour"] == "*"
+
+ periodic_task = PeriodicTask.objects.create(
+ name="pt_unicode",
+ template_id=1,
+ trigger_id=1,
+ cron=cron,
+ config={},
+ creator="tester",
+ )
+ assert "pt_unicode" in periodic_task.__unicode__()
+ assert str(periodic_task.id) in periodic_task.__unicode__()
+
+
+@pytest.mark.django_db(transaction=True)
+class TestTaskLabelRelationManager:
+ def test_set_labels_add_and_remove(self):
+ """Cover BaseLabelRelationManager.set_labels add/remove branches"""
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ TaskLabelRelation.objects.create(task_id=task_instance.id, label_id=1)
+ TaskLabelRelation.objects.create(task_id=task_instance.id, label_id=2)
+
+ TaskLabelRelation.objects.set_labels(task_instance.id, [2, 3])
+
+ label_ids = list(TaskLabelRelation.objects.filter(task_id=task_instance.id).values_list("label_id", flat=True))
+ assert sorted(label_ids) == [2, 3]
+
+ def test_fetch_tasks_labels(self):
+ """Cover BaseLabelRelationManager.fetch_tasks_labels branches"""
+ task1 = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ task2 = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+
+ # empty
+ assert TaskLabelRelation.objects.fetch_tasks_labels([task1.id, task2.id]) == {}
+
+ TaskLabelRelation.objects.create(task_id=task1.id, label_id=1)
+ TaskLabelRelation.objects.create(task_id=task1.id, label_id=2)
+ TaskLabelRelation.objects.create(task_id=task2.id, label_id=3)
+
+ result = TaskLabelRelation.objects.fetch_tasks_labels([task1.id, task2.id])
+ assert sorted(result[task1.id]) == [1, 2]
+ assert result[task2.id] == [3]
+
+
@pytest.mark.django_db(transaction=True)
class TestEngineSpaceConfig:
"""测试 EngineSpaceConfig 模型"""
@@ -203,3 +661,24 @@ def test_get_space_var(self):
# Not exist
space_var = EngineSpaceConfig.get_space_var(space_id=999)
assert space_var == {}
+
+
+@pytest.mark.django_db(transaction=True)
+class TestEngineSpaceConfigMoreCoverage:
+ def test_to_json(self):
+ """Cover EngineSpaceConfig.to_json"""
+ config = EngineSpaceConfig.objects.create(
+ interface_config_id=100,
+ space_id=1,
+ name="cfg",
+ value_type=EngineSpaceConfigValueType.TEXT.value,
+ text_value="v",
+ json_value={"k": "v"},
+ )
+ data = config.to_json()
+ assert data["id"] == config.id
+ assert data["name"] == "cfg"
+ assert data["value_type"] == EngineSpaceConfigValueType.TEXT.value
+ assert data["value"] == "v"
+ assert data["json_value"] == {"k": "v"}
+ assert data["interface_config_id"] == 100
diff --git a/tests/engine/task/test_task_views.py b/tests/engine/task/test_task_views.py
index 457e670c24..20713fc028 100644
--- a/tests/engine/task/test_task_views.py
+++ b/tests/engine/task/test_task_views.py
@@ -28,10 +28,15 @@
EngineSpaceConfigValueType,
PeriodicTask,
TaskInstance,
+ TaskLabelRelation,
TaskMockData,
TaskOperationRecord,
)
-from bkflow.task.views import PeriodicTaskViewSet, TaskInstanceViewSet
+from bkflow.task.views import (
+ PeriodicTaskViewSet,
+ TaskInstanceFilterSet,
+ TaskInstanceViewSet,
+)
from bkflow.utils.pipeline import build_default_pipeline_tree
@@ -993,6 +998,358 @@ def test_validate_task_info_invalid_node_id_in_data(self):
assert response.data["result"] is False
assert "node_id should be in task" in response.data["message"]
+ def test_list_injects_labels(self):
+ """测试 list 会补充 labels 字段"""
+ task1 = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ task2 = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+
+ TaskLabelRelation.objects.create(task_id=task1.id, label_id=1)
+ TaskLabelRelation.objects.create(task_id=task1.id, label_id=2)
+ TaskLabelRelation.objects.create(task_id=task2.id, label_id=3)
+
+ view = TaskInstanceViewSet.as_view({"get": "list"})
+ request = self._create_request_with_auth("get", "/task/", {"limit": 10, "offset": 0})
+ response = view(request)
+
+ assert response.status_code == status.HTTP_200_OK
+ results = response.data["data"]["results"]
+ assert len(results) >= 2
+ for item in results:
+ assert "labels" in item
+
+ task1_item = next(i for i in results if i["id"] == task1.id)
+ task2_item = next(i for i in results if i["id"] == task2.id)
+ assert sorted(task1_item["labels"]) == [1, 2]
+ assert sorted(task2_item["labels"]) == [3]
+
+ def test_list_filter_by_labels_invalid_value(self):
+ """测试 label 过滤参数非法时返回空结果"""
+ TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+
+ view = TaskInstanceViewSet.as_view({"get": "list"})
+ request = self._create_request_with_auth(
+ "get",
+ "/task/",
+ {"label": "not_int", "limit": 10, "offset": 0},
+ )
+ response = view(request)
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["data"]["count"] == 0
+ assert response.data["data"]["results"] == []
+
+ def test_list_filter_by_labels_success(self):
+ """测试 label 过滤参数合法时能正确筛选任务"""
+ task1 = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ task2 = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ TaskLabelRelation.objects.create(task_id=task1.id, label_id=1)
+ TaskLabelRelation.objects.create(task_id=task2.id, label_id=2)
+
+ view = TaskInstanceViewSet.as_view({"get": "list"})
+ request = self._create_request_with_auth(
+ "get",
+ "/task/",
+ {"label": "1", "limit": 10, "offset": 0},
+ )
+ response = view(request)
+
+ assert response.status_code == status.HTTP_200_OK
+ results = response.data["data"]["results"]
+ assert len(results) == 1
+ assert results[0]["id"] == task1.id
+
+ def test_update_labels_action(self):
+ """测试 update_labels action 会更新 TaskLabelRelation"""
+ task = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+
+ view = TaskInstanceViewSet.as_view({"post": "update_labels"})
+ request = self._create_request_with_auth(
+ "post",
+ f"/task/{task.id}/update_labels/",
+ {"label_ids": [11, 12]},
+ )
+ response = view(request, pk=task.id)
+
+ assert response.status_code == status.HTTP_200_OK
+ assert sorted(response.data["data"]) == [11, 12]
+ assert set(TaskLabelRelation.objects.filter(task_id=task.id).values_list("label_id", flat=True)) == {11, 12}
+
+ def test_get_task_label_ref_count(self):
+ """测试 get_task_label_ref_count action"""
+ task1 = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ task2 = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+
+ TaskLabelRelation.objects.create(task_id=task1.id, label_id=1)
+ TaskLabelRelation.objects.create(task_id=task2.id, label_id=1)
+ TaskLabelRelation.objects.create(task_id=task2.id, label_id=2)
+
+ view = TaskInstanceViewSet.as_view({"get": "get_task_label_ref_count"})
+ request = self._create_request_with_auth(
+ "get",
+ "/task/get_task_label_ref_count/",
+ {"label_ids": "1,2,3", "space_id": 1},
+ )
+ response = view(request)
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["result"] is True
+ assert response.data["data"]["1"] == 2
+ assert response.data["data"]["2"] == 1
+ assert response.data["data"]["3"] == 0
+
+ def test_delete_task_label_relation(self):
+ """测试 delete_task_label_relation action"""
+ task = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ TaskLabelRelation.objects.create(task_id=task.id, label_id=1)
+ TaskLabelRelation.objects.create(task_id=task.id, label_id=2)
+
+ view = TaskInstanceViewSet.as_view({"post": "delete_task_label_relation"})
+ request = self._create_request_with_auth(
+ "post",
+ "/task/delete_task_label_relation/",
+ {"label_ids": [1]},
+ )
+ response = view(request)
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["data"]["label_ids"] == [1]
+ assert set(TaskLabelRelation.objects.filter(task_id=task.id).values_list("label_id", flat=True)) == {2}
+
+ @patch("bkflow.task.views.start_trace")
+ @patch("bkflow.task.views.TaskOperation")
+ def test_operate_task_operation_method_not_found(self, mock_task_operation, mock_start_trace):
+ """测试任务操作 - operation 存在但方法缺失"""
+
+ class _NoOp:
+ pass
+
+ mock_start_trace.return_value.__enter__ = MagicMock()
+ mock_start_trace.return_value.__exit__ = MagicMock()
+ mock_task_operation.return_value = _NoOp()
+
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ view = TaskInstanceViewSet.as_view({"post": "operate"})
+ request = self._create_request_with_auth("post", f"/task/{task_instance.id}/operate/start/", {})
+
+ with pytest.raises(Exception):
+ view(request, pk=task_instance.id, operation="start")
+
+ @patch("bkflow.task.views.start_trace")
+ @patch("bkflow.task.views.TaskNodeOperation")
+ def test_node_operate_method_not_found(self, mock_node_operation, mock_start_trace):
+ """测试节点操作 - operation 存在但方法缺失"""
+
+ class _NoOp:
+ pass
+
+ mock_start_trace.return_value.__enter__ = MagicMock()
+ mock_start_trace.return_value.__exit__ = MagicMock()
+ mock_node_operation.return_value = _NoOp()
+
+ pipeline_tree = build_default_pipeline_tree()
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=pipeline_tree)
+ self._start_task_instance(task_instance)
+ node_id = list(pipeline_tree["activities"].keys())[0]
+
+ view = TaskInstanceViewSet.as_view({"post": "node_operate"})
+ request = self._create_request_with_auth("post", f"/task/{task_instance.id}/node_operate/{node_id}/retry/", {})
+
+ with pytest.raises(Exception):
+ view(request, pk=task_instance.id, node_id=node_id, operation="retry")
+
+ def test_get_node_detail_include_data_true_success(self):
+ """测试 get_node_detail include_data=True 且获取数据成功"""
+
+ class _Result(dict):
+ def __init__(self, result=True, data=None, message="success"):
+ super().__init__({"result": result, "data": data, "message": message})
+ self.result = result
+ self.data = data
+ self.message = message
+
+ pipeline_tree = build_default_pipeline_tree()
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=pipeline_tree)
+ node_id = list(pipeline_tree["activities"].keys())[0]
+
+ node_data = {"x": 1}
+ node_detail = {"y": 2}
+
+ with patch("bkflow.task.views.TaskNodeOperation") as mock_node_operation:
+ mock_node_op = MagicMock()
+ mock_node_op.get_node_data.return_value = _Result(result=True, data=node_data)
+ mock_node_op.get_node_detail.return_value = _Result(result=True, data=node_detail)
+ mock_node_operation.return_value = mock_node_op
+
+ view = TaskInstanceViewSet.as_view({"get": "get_node_detail"})
+ request = self._create_request_with_auth(
+ "get",
+ f"/task/{task_instance.id}/get_task_node_detail/{node_id}/",
+ {"include_data": True},
+ )
+
+ response = view(request, pk=task_instance.id, node_id=node_id)
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["result"] is True
+ assert response.data["data"]["x"] == 1
+ assert response.data["data"]["y"] == 2
+
+ def test_get_node_detail_get_node_data_fail(self):
+ """测试 get_node_detail include_data=True 但 get_node_data 失败会提前返回"""
+
+ class _Result(dict):
+ def __init__(self, result=True, data=None, message="success"):
+ super().__init__({"result": result, "data": data, "message": message})
+ self.result = result
+ self.data = data
+ self.message = message
+
+ pipeline_tree = build_default_pipeline_tree()
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=pipeline_tree)
+ node_id = list(pipeline_tree["activities"].keys())[0]
+
+ with patch("bkflow.task.views.TaskNodeOperation") as mock_node_operation:
+ mock_node_op = MagicMock()
+ mock_node_op.get_node_data.return_value = _Result(result=False, data=None, message="failed")
+ mock_node_operation.return_value = mock_node_op
+
+ view = TaskInstanceViewSet.as_view({"get": "get_node_detail"})
+ request = self._create_request_with_auth(
+ "get",
+ f"/task/{task_instance.id}/get_task_node_detail/{node_id}/",
+ {"include_data": True},
+ )
+ response = view(request, pk=task_instance.id, node_id=node_id)
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["result"] is False
+ assert response.data["message"] == "failed"
+
+ def test_get_node_detail_get_node_detail_fail(self):
+ """测试 get_node_detail get_node_detail 失败会提前返回"""
+
+ class _Result(dict):
+ def __init__(self, result=True, data=None, message="success"):
+ super().__init__({"result": result, "data": data, "message": message})
+ self.result = result
+ self.data = data
+ self.message = message
+
+ pipeline_tree = build_default_pipeline_tree()
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=pipeline_tree)
+ node_id = list(pipeline_tree["activities"].keys())[0]
+
+ with patch("bkflow.task.views.TaskNodeOperation") as mock_node_operation:
+ mock_node_op = MagicMock()
+ mock_node_op.get_node_data.return_value = _Result(result=True, data={"x": 1})
+ mock_node_op.get_node_detail.return_value = _Result(result=False, data=None, message="detail_failed")
+ mock_node_operation.return_value = mock_node_op
+
+ view = TaskInstanceViewSet.as_view({"get": "get_node_detail"})
+ request = self._create_request_with_auth(
+ "get",
+ f"/task/{task_instance.id}/get_task_node_detail/{node_id}/",
+ {"include_data": True},
+ )
+ response = view(request, pk=task_instance.id, node_id=node_id)
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["result"] is False
+ assert response.data["message"] == "detail_failed"
+
+ def test_get_node_snapshot_config_template_node_id_not_found(self):
+ """测试 get_node_snapshot_config - template_node_id 未找到"""
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+
+ # create_instance may replace node ids, so take node_id from persisted execution_data.
+ pipeline_tree = task_instance.execution_data
+ node_id = list(pipeline_tree["activities"].keys())[0]
+ pipeline_tree["activities"][node_id].pop("template_node_id", None)
+
+ # set_execution_data won't overwrite existing snapshot data, so update snapshot directly.
+ from bkflow.task.models import TaskExecutionSnapshot
+
+ TaskExecutionSnapshot.objects.filter(id=task_instance.execution_snapshot_id).update(data=pipeline_tree)
+ task_instance.calculate_tree_info()
+
+ view = TaskInstanceViewSet.as_view({"get": "get_node_snapshot_config"})
+ request = self._create_request_with_auth(
+ "get",
+ f"/task/{task_instance.id}/get_node_snapshot_config/",
+ {"node_id": node_id},
+ )
+ response = view(request, pk=task_instance.id)
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["result"] is False
+ assert response.data["message"] == "template_node_id 未找到"
+
+ def test_get_engine_config_raises_does_not_exist(self):
+ """测试 get_engine_config - 触发 DoesNotExist 异常分支"""
+ view = TaskInstanceViewSet.as_view({"get": "get_engine_config"})
+ request = self._create_request_with_auth(
+ "get",
+ "/task/get_engine_config/",
+ {"interface_config_ids": [999], "simplified": False},
+ )
+
+ with patch("bkflow.task.views.EngineSpaceConfig.objects.filter") as mock_filter:
+ mock_filter.side_effect = EngineSpaceConfig.DoesNotExist("not found")
+ response = view(request)
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["result"] is False
+
+ def test_delete_engine_config_raises_does_not_exist(self):
+ """测试 delete_engine_config - 触发 DoesNotExist 异常分支"""
+ view = TaskInstanceViewSet.as_view({"delete": "delete_engine_config"})
+ request = self._create_request_with_auth(
+ "delete",
+ "/task/delete_engine_config/",
+ {"interface_config_ids": [999], "simplified": False},
+ )
+
+ with patch("bkflow.task.views.EngineSpaceConfig.objects.filter") as mock_filter:
+ mock_filter.side_effect = EngineSpaceConfig.DoesNotExist()
+ response = view(request)
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["result"] is False
+
+ def test_filter_by_labels_with_empty_label_ids_returns_queryset(self):
+ """覆盖 TaskInstanceFilterSet.filter_by_labels 的 label_ids 为空分支"""
+
+ class _Value:
+ def split(self, _sep):
+ return []
+
+ TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ qs = TaskInstance.objects.filter(space_id=1)
+ f = TaskInstanceFilterSet()
+ new_qs = f.filter_by_labels(qs, "label", _Value())
+
+ assert list(new_qs) == list(qs)
+
+ def test_get_node_detail_raises_when_node_not_found(self):
+ """覆盖 get_node_detail 内部的 node not found 分支(绕过 validate_task_info)"""
+ from rest_framework.request import Request
+
+ from bkflow.exceptions import ValidationError
+
+ task_instance = TaskInstance.objects.create_instance(space_id=1, pipeline_tree=build_default_pipeline_tree())
+ invalid_node_id = "invalid_node_id_999"
+
+ view = TaskInstanceViewSet()
+ view.action = "get_node_detail"
+ view.kwargs = {"pk": task_instance.id}
+
+ # DRF Request has query_params attribute (WSGIRequest doesn't).
+ raw_request = self._create_request_with_auth("get", "/", {"include_data": True})
+ request = Request(raw_request)
+ view.request = request
+
+ with pytest.raises(ValidationError):
+ TaskInstanceViewSet.get_node_detail.__wrapped__(view, request, node_id=invalid_node_id)
+
@pytest.mark.django_db(transaction=True)
class TestPeriodicTaskViewSet:
@@ -1084,6 +1441,28 @@ def test_update_periodic_task_success(self):
assert response.status_code == status.HTTP_200_OK
assert response.data["data"]["name"] == "updated_name"
+ def test_update_periodic_task_not_found(self):
+ """测试更新周期任务 - trigger_id 不存在"""
+ update_view = PeriodicTaskViewSet.as_view({"post": "update_task"})
+ update_data = {
+ "trigger_id": 999999,
+ "name": "updated_name",
+ "cron": {
+ "minute": "30",
+ "hour": "12",
+ "day_of_week": "*",
+ "day_of_month": "*",
+ "month_of_year": "*",
+ },
+ "config": {},
+ }
+ request = self._create_request("post", "/periodic_task/update/", update_data)
+ response = update_view(request)
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ assert response.data["data"]["result"] is False
+ assert "not exist" in response.data["data"]["message"]
+
def test_batch_delete_periodic_tasks(self):
"""测试批量删除周期任务"""
# 创建两个周期任务
diff --git a/tests/engine/utils/test_client.py b/tests/engine/utils/test_client.py
index e8625fe993..6a6f6042bc 100644
--- a/tests/engine/utils/test_client.py
+++ b/tests/engine/utils/test_client.py
@@ -1,9 +1,31 @@
"""Test API client"""
+import importlib
+import sys
+import types
from unittest import mock
from bkflow.contrib.api.client import BaseComponentClient, BKComponentClient
+def _import_task_component_client(monkeypatch, module_info_cls):
+ """Import TaskComponentClient with a stubbed bkflow.admin.models.ModuleInfo.
+
+ Engine test settings may not include `bkflow.admin` in INSTALLED_APPS, so importing
+ the real Django model will raise at collection time.
+ """
+
+ if "bkflow.admin" not in sys.modules:
+ monkeypatch.setitem(sys.modules, "bkflow.admin", types.ModuleType("bkflow.admin"))
+
+ models_mod = types.ModuleType("bkflow.admin.models")
+ models_mod.ModuleInfo = module_info_cls
+ monkeypatch.setitem(sys.modules, "bkflow.admin.models", models_mod)
+
+ sys.modules.pop("bkflow.contrib.api.collections.task", None)
+ task_mod = importlib.import_module("bkflow.contrib.api.collections.task")
+ return task_mod.TaskComponentClient, task_mod
+
+
class TestBKComponentClient:
"""Test BKComponentClient"""
@@ -126,3 +148,226 @@ def test_base_request(self, mock_http_post):
assert result["result"] is True
mock_http_post.assert_called_once()
+
+
+class TestTaskComponentClient:
+ def test_get_module_info_fallback(self, monkeypatch):
+ class FakeModuleInfo:
+ class DoesNotExist(Exception):
+ pass
+
+ default_module = types.SimpleNamespace(token="default_token", url="http://task.service", space_id=0)
+
+ def _get_side_effect(**kwargs):
+ if kwargs.get("space_id") == 999:
+ raise FakeModuleInfo.DoesNotExist
+ if kwargs.get("space_id") == 0:
+ return default_module
+ raise AssertionError(f"unexpected kwargs: {kwargs}")
+
+ FakeModuleInfo.objects = mock.Mock()
+ FakeModuleInfo.objects.get.side_effect = _get_side_effect
+
+ TaskComponentClient, _ = _import_task_component_client(monkeypatch, FakeModuleInfo)
+ client = TaskComponentClient(space_id=999)
+
+ assert client.module_info is default_module
+ assert FakeModuleInfo.objects.get.call_args_list == [
+ mock.call(type="TASK", space_id=999),
+ mock.call(type="TASK", space_id=0),
+ ]
+
+ def test_get_module_info_space_specific(self, monkeypatch):
+ class FakeModuleInfo:
+ class DoesNotExist(Exception):
+ pass
+
+ space_module = types.SimpleNamespace(token="space_token", url="http://task.space1", space_id=1)
+ FakeModuleInfo.objects = mock.Mock()
+ FakeModuleInfo.objects.get.return_value = space_module
+
+ TaskComponentClient, _ = _import_task_component_client(monkeypatch, FakeModuleInfo)
+ client = TaskComponentClient(space_id=1)
+
+ assert client.module_info is space_module
+ FakeModuleInfo.objects.get.assert_called_once_with(type="TASK", space_id=1)
+
+ def test_pre_process_headers(self, monkeypatch):
+ class FakeModuleInfo:
+ class DoesNotExist(Exception):
+ pass
+
+ space_module = types.SimpleNamespace(token="space_token", url="http://task.space1", space_id=1)
+ FakeModuleInfo.objects = mock.Mock()
+ FakeModuleInfo.objects.get.return_value = space_module
+
+ TaskComponentClient, task_mod = _import_task_component_client(monkeypatch, FakeModuleInfo)
+
+ monkeypatch.setattr(task_mod.settings, "APP_INTERNAL_TOKEN_HEADER_KEY", "X-Internal-Token", raising=False)
+ monkeypatch.setattr(task_mod.settings, "APP_INTERNAL_SPACE_ID_HEADER_KEY", "X-Space-Id", raising=False)
+ monkeypatch.setattr(
+ task_mod.settings, "APP_INTERNAL_FROM_SUPERUSER_HEADER_KEY", "X-From-Superuser", raising=False
+ )
+
+ client = TaskComponentClient(space_id=1, from_superuser=True)
+
+ headers = client._pre_process_headers(None)
+ assert headers["Content-Type"] == "application/json"
+ assert headers["X-Internal-Token"] == "space_token"
+ assert headers["X-Space-Id"] == "1"
+ assert headers["X-From-Superuser"] == "1"
+
+ existing = {"Authorization": "Bearer token"}
+ result = client._pre_process_headers(existing)
+ assert result["Authorization"] == "Bearer token"
+ assert result["X-Internal-Token"] == "space_token"
+ assert result["X-Space-Id"] == "1"
+ assert result["X-From-Superuser"] == "1"
+
+ # cover: space_id is None branch
+ client.space_id = None
+ headers = client._pre_process_headers(None)
+ assert headers["X-From-Superuser"] == "1"
+ assert "X-Space-Id" not in headers
+
+ # cover: from_superuser False branch
+ client.from_superuser = False
+ headers = client._pre_process_headers(None)
+ assert headers["X-From-Superuser"] == "0"
+
+ def test_request_wrappers_build_url_and_method(self, monkeypatch):
+ class FakeModuleInfo:
+ class DoesNotExist(Exception):
+ pass
+
+ space_module = types.SimpleNamespace(token="space_token", url="http://task.space1", space_id=1)
+ FakeModuleInfo.objects = mock.Mock()
+ FakeModuleInfo.objects.get.return_value = space_module
+
+ TaskComponentClient, _ = _import_task_component_client(monkeypatch, FakeModuleInfo)
+ client = TaskComponentClient(space_id=1)
+ client._request = mock.Mock(return_value={"result": True})
+
+ client.task_list({"a": 1})
+ client._request.assert_called_with(method="get", url="http://task.space1/task/", data={"a": 1})
+
+ client.update_labels(2, {"label_ids": [1]})
+ client._request.assert_called_with(
+ method="post", url="http://task.space1/task/2/update_labels/", data={"label_ids": [1]}
+ )
+
+ client.get_task_label_ref_count(1, "1,2")
+ client._request.assert_called_with(
+ method="get",
+ url="http://task.space1/task/get_task_label_ref_count/?space_id=1&label_ids=1,2",
+ data=None,
+ )
+
+ client.delete_task_label_relation({"task_id": 1, "label_ids": [1]})
+ client._request.assert_called_with(
+ method="post",
+ url="http://task.space1/task/delete_task_label_relation/",
+ data={"task_id": 1, "label_ids": [1]},
+ )
+
+ client.create_task({"name": "t"})
+ client._request.assert_called_with(method="post", url="http://task.space1/task/", data={"name": "t"})
+
+ client.get_task_detail(3)
+ client._request.assert_called_with(method="get", url="http://task.space1/task/3/", data=None)
+
+ client.delete_task(3)
+ client._request.assert_called_with(method="delete", url="http://task.space1/task/3/", data=None)
+
+ client.get_task_states(3, {"x": 1})
+ client._request.assert_called_with(method="get", url="http://task.space1/task/3/get_states/", data={"x": 1})
+
+ client.get_task_mock_data(3)
+ client._request.assert_called_with(method="get", url="http://task.space1/task/3/get_task_mock_data/", data=None)
+
+ client.operate_task(3, "pause", {"y": 2})
+ client._request.assert_called_with(method="post", url="http://task.space1/task/3/operate/pause/", data={"y": 2})
+
+ client.get_task_node_detail(3, "node", username="admin")
+ client._request.assert_called_with(
+ method="get",
+ url="http://task.space1/task/3/get_task_node_detail/node/?username=admin",
+ data=None,
+ )
+
+ client.node_operate(3, "node", "retry", {"z": 3})
+ client._request.assert_called_with(
+ method="post", url="http://task.space1/task/3/node_operate/node/retry/", data={"z": 3}
+ )
+
+ client.get_task_node_log(3, "node", 1)
+ client._request.assert_called_with(
+ method="get", url="http://task.space1/task/3/get_task_node_log/node/1/", data=None
+ )
+
+ client.render_current_constants(3)
+ client._request.assert_called_with(
+ method="get", url="http://task.space1/task/3/render_current_constants/", data=None
+ )
+
+ client.render_context_with_node_outputs(3, {"a": 1})
+ client._request.assert_called_with(
+ method="post", url="http://task.space1/task/3/render_context_with_node_outputs/", data={"a": 1}
+ )
+
+ client.get_task_operation_record(3)
+ client._request.assert_called_with(
+ method="get", url="http://task.space1/task/3/get_task_operation_record/", data=None
+ )
+
+ client.get_node_snapshot_config(3)
+ client._request.assert_called_with(
+ method="get", url="http://task.space1/task/3/get_node_snapshot_config/", data=None
+ )
+
+ client.get_tasks_states({"task_ids": [1]})
+ client._request.assert_called_with(
+ method="post", url="http://task.space1/task/get_tasks_states/", data={"task_ids": [1]}
+ )
+
+ client.trigger_engine_admin_action("inst", "pause")
+ client._request.assert_called_with(
+ method="post",
+ url="http://task.space1/task_engine_admin/api/v1/bamboo_engine/pause/inst/",
+ data=None,
+ )
+
+ client.batch_delete_tasks({"task_ids": [1]})
+ client._request.assert_called_with(
+ method="post", url="http://task.space1/task/batch_delete_tasks/", data={"task_ids": [1]}
+ )
+
+ client.create_periodic_task({"name": "p"})
+ client._request.assert_called_with(
+ method="post", url="http://task.space1/task/periodic_task/", data={"name": "p"}
+ )
+
+ client.update_periodic_task({"id": 1})
+ client._request.assert_called_with(
+ method="post", url="http://task.space1/task/periodic_task/update/", data={"id": 1}
+ )
+
+ client.batch_delete_periodic_task({"ids": [1]})
+ client._request.assert_called_with(
+ method="post", url="http://task.space1/task/periodic_task/batch_delete/", data={"ids": [1]}
+ )
+
+ client.get_engine_config({"a": 1})
+ client._request.assert_called_with(
+ method="get", url="http://task.space1/task/get_engine_config/", data={"a": 1}
+ )
+
+ client.upsert_engine_config({"a": 1})
+ client._request.assert_called_with(
+ method="post", url="http://task.space1/task/upsert_engine_config/", data={"a": 1}
+ )
+
+ client.delete_engine_config({"a": 1})
+ client._request.assert_called_with(
+ method="delete", url="http://task.space1/task/delete_engine_config/", data={"a": 1}
+ )
diff --git a/tests/interface/apigw/test_update_template.py b/tests/interface/apigw/test_update_template.py
index f587b8d49c..83f2f52ab7 100644
--- a/tests/interface/apigw/test_update_template.py
+++ b/tests/interface/apigw/test_update_template.py
@@ -164,3 +164,232 @@ def test_create_trigger_success(self, mock_create, mock_update):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["result"], True)
self.assertEqual(response.data["data"]["triggers"][0]["name"], "old trigger new name")
+
+
+class TestTemplateSerializerLabelSync(TestCase):
+ def test_sync_template_labels_invalid_ids_raises_validation_error(self):
+ from rest_framework.exceptions import ValidationError
+
+ from bkflow.template.serializers.template import TemplateSerializer
+
+ serializer = TemplateSerializer()
+
+ with patch("bkflow.template.serializers.template.Label.objects.check_label_ids") as mock_check:
+ mock_check.return_value = False
+
+ with self.assertRaises(ValidationError) as cm:
+ serializer._sync_template_lables(template_id=1, label_ids=[1, 2])
+
+ self.assertIn("标签不存在", str(cm.exception))
+
+ def test_sync_template_labels_set_labels_exception_raises_validation_error(self):
+ from rest_framework.exceptions import ValidationError
+
+ from bkflow.template.serializers.template import TemplateSerializer
+
+ serializer = TemplateSerializer()
+
+ with patch("bkflow.template.serializers.template.Label.objects.check_label_ids") as mock_check:
+ mock_check.return_value = True
+
+ with patch("bkflow.template.serializers.template.TemplateLabelRelation.objects.set_labels") as mock_set:
+ mock_set.side_effect = Exception("db error")
+
+ with self.assertRaises(ValidationError) as cm:
+ serializer._sync_template_lables(template_id=1, label_ids=[1, 2])
+
+ self.assertIn("标签设置失败", str(cm.exception))
+
+
+class TestApigwTemplateSerializers(TestCase):
+ def setUp(self):
+ self.factory = APIRequestFactory()
+ self.user = User.objects.create_user(username="test_user", password="password")
+
+ def build_pipeline_tree(self):
+ start = EmptyStartEvent()
+ act_1 = ServiceActivity(component_code="example_component")
+ end = EmptyEndEvent()
+
+ start.extend(act_1).extend(end)
+ pipeline = build_tree(start, data={"test": "test"})
+ return pipeline
+
+ def create_space(self):
+ return Space.objects.create(app_code="test", platform_url="http://test.com", name="space")
+
+ def make_request(self, username="test_user"):
+ request = self.factory.post("/apigw/")
+ if username:
+ request.user = self.user
+ request.user.username = username
+ else:
+ # serializer only relies on request.user.username
+ request.user = type("DummyUser", (), {"username": ""})()
+ return request
+
+ def test_create_template_serializer_maps_bind_app_code_to_bk_app_code(self):
+ from bkflow.apigw.serializers.template import CreateTemplateSerializer
+
+ space = self.create_space()
+ request = self.make_request(username="test_user")
+
+ serializer = CreateTemplateSerializer(
+ data={"name": "test", "bind_app_code": "app_code"},
+ context={"request": request, "space_id": space.id},
+ )
+ serializer.is_valid(raise_exception=True)
+ self.assertEqual(serializer.validated_data["bk_app_code"], "app_code")
+ self.assertNotIn("bind_app_code", serializer.validated_data)
+
+ def test_create_template_serializer_scope_type_and_value_must_both_present(self):
+ from rest_framework.exceptions import ValidationError
+
+ from bkflow.apigw.serializers.template import CreateTemplateSerializer
+
+ space = self.create_space()
+ request = self.make_request(username="test_user")
+
+ serializer = CreateTemplateSerializer(
+ data={"name": "test", "scope_type": "biz"},
+ context={"request": request, "space_id": space.id},
+ )
+
+ with self.assertRaises(ValidationError):
+ serializer.is_valid(raise_exception=True)
+
+ def test_create_template_serializer_source_template_not_exists_raises(self):
+ from rest_framework.exceptions import ValidationError
+
+ from bkflow.apigw.serializers.template import CreateTemplateSerializer
+
+ space = self.create_space()
+ request = self.make_request(username="test_user")
+
+ serializer = CreateTemplateSerializer(
+ data={"name": "test", "source_template_id": 99999999},
+ context={"request": request, "space_id": space.id},
+ )
+
+ with self.assertRaises(ValidationError):
+ serializer.is_valid(raise_exception=True)
+
+ def test_create_template_serializer_only_copy_template_in_same_space(self):
+ from rest_framework.exceptions import ValidationError
+
+ from bkflow.apigw.serializers.template import CreateTemplateSerializer
+ from bkflow.template.models import TemplateSnapshot
+
+ space_1 = self.create_space()
+ space_2 = Space.objects.create(app_code="test2", platform_url="http://test.com", name="space2")
+ pipeline_tree = self.build_pipeline_tree()
+ snapshot = TemplateSnapshot.create_snapshot(pipeline_tree=pipeline_tree, username="test_user", version="1.0.0")
+ template = Template.objects.create(name="src", space_id=space_1.id, snapshot_id=snapshot.id)
+
+ request = self.make_request(username="test_user")
+ serializer = CreateTemplateSerializer(
+ data={"name": "test", "source_template_id": template.id},
+ context={"request": request, "space_id": space_2.id},
+ )
+
+ with self.assertRaises(ValidationError):
+ serializer.is_valid(raise_exception=True)
+
+ @patch("bkflow.apigw.serializers.template.validate_pipeline_tree")
+ def test_create_template_serializer_pipeline_validate_error_raises(self, mock_validate):
+ from rest_framework.exceptions import ValidationError
+
+ from bkflow.apigw.serializers.template import CreateTemplateSerializer
+
+ mock_validate.side_effect = Exception("invalid pipeline")
+
+ space = self.create_space()
+ request = self.make_request(username="test_user")
+ serializer = CreateTemplateSerializer(
+ data={"name": "test", "pipeline_tree": {"invalid": True}},
+ context={"request": request, "space_id": space.id},
+ )
+
+ with self.assertRaises(ValidationError) as cm:
+ serializer.is_valid(raise_exception=True)
+ self.assertIn("pipeline校验不通过", str(cm.exception))
+
+ def test_create_template_serializer_creator_and_apigw_user_both_empty_raises(self):
+ from rest_framework.exceptions import ValidationError
+
+ from bkflow.apigw.serializers.template import CreateTemplateSerializer
+
+ space = self.create_space()
+ request = self.make_request(username="")
+
+ serializer = CreateTemplateSerializer(
+ data={"name": "test"},
+ context={"request": request, "space_id": space.id},
+ )
+
+ with self.assertRaises(ValidationError):
+ serializer.is_valid(raise_exception=True)
+
+ def test_delete_template_serializer_validate_space_id_invalid_raises(self):
+ from rest_framework.exceptions import ValidationError
+
+ from bkflow.apigw.serializers.template import DeleteTemplateSerializer
+
+ serializer = DeleteTemplateSerializer(data={"template_id": 1, "space_id": 99999999})
+
+ with self.assertRaises(ValidationError):
+ serializer.is_valid(raise_exception=True)
+
+ def test_delete_template_serializer_validate_space_id_valid(self):
+ from bkflow.apigw.serializers.template import DeleteTemplateSerializer
+
+ space = self.create_space()
+ serializer = DeleteTemplateSerializer(data={"template_id": 1, "space_id": space.id})
+ serializer.is_valid(raise_exception=True)
+ self.assertEqual(serializer.validated_data["space_id"], space.id)
+
+ def test_update_template_serializer_scope_type_and_value_must_both_present(self):
+ from rest_framework.exceptions import ValidationError
+
+ from bkflow.apigw.serializers.template import UpdateTemplateSerializer
+
+ request = self.make_request(username="test_user")
+ serializer = UpdateTemplateSerializer(
+ data={"scope_type": "biz"},
+ context={"request": request},
+ )
+
+ with self.assertRaises(ValidationError):
+ serializer.is_valid(raise_exception=True)
+
+ def test_update_template_serializer_operator_and_apigw_user_both_empty_raises(self):
+ from rest_framework.exceptions import ValidationError
+
+ from bkflow.apigw.serializers.template import UpdateTemplateSerializer
+
+ request = self.make_request(username="")
+ serializer = UpdateTemplateSerializer(
+ data={"name": "test"},
+ context={"request": request},
+ )
+
+ with self.assertRaises(ValidationError):
+ serializer.is_valid(raise_exception=True)
+
+ @patch("bkflow.apigw.serializers.template.validate_pipeline_tree")
+ def test_update_template_serializer_pipeline_validate_error_raises(self, mock_validate):
+ from rest_framework.exceptions import ValidationError
+
+ from bkflow.apigw.serializers.template import UpdateTemplateSerializer
+
+ mock_validate.side_effect = Exception("invalid pipeline")
+
+ request = self.make_request(username="test_user")
+ serializer = UpdateTemplateSerializer(
+ data={"pipeline_tree": {"invalid": True}},
+ context={"request": request},
+ )
+
+ with self.assertRaises(ValidationError) as cm:
+ serializer.is_valid(raise_exception=True)
+ self.assertIn("pipeline校验不通过", str(cm.exception))
diff --git a/tests/interface/task/test_task_views.py b/tests/interface/task/test_task_views.py
index 7b1a7ca79e..ca3ca20443 100644
--- a/tests/interface/task/test_task_views.py
+++ b/tests/interface/task/test_task_views.py
@@ -70,6 +70,56 @@ def test_get_task_list(self, mock_client_class):
mock_client.task_list.assert_called_once()
mock_client_class.assert_called_once_with(space_id=self.space.id)
+ @mock.patch("bkflow.interface.task.view.Label.objects.get_labels_map")
+ @mock.patch("bkflow.interface.task.view.Label.get_label_ids_by_names")
+ @mock.patch("bkflow.interface.task.view.TaskComponentClient")
+ def test_get_task_list_with_labels(self, mock_client_class, mock_get_label_ids, mock_get_labels_map):
+ """Test get_task_list method with label query param, should convert name to ids and then map ids to names"""
+ mock_get_label_ids.return_value = [1, 2]
+ mock_get_labels_map.return_value = {1: "label_1", 2: "label_2"}
+
+ mock_client = mock.Mock()
+ mock_client.task_list.return_value = {
+ "result": True,
+ "data": {"results": [{"id": 1, "name": "Task 1", "labels": [1, 2]}]},
+ }
+ mock_client_class.return_value = mock_client
+
+ view = TaskInterfaceAdminViewSet.as_view({"get": "get_task_list"})
+ request = self.factory.get(f"/admin/tasks/get_task_list/{self.space.id}/?label=label_1,label_2")
+ force_authenticate(request, user=self.admin_user)
+
+ response = view(request, space_id=self.space.id)
+
+ assert response.status_code == 200
+ # Should convert label names to ids for query
+ call_data = mock_client.task_list.call_args[1]["data"]
+ assert call_data["label"] == ["1,2"]
+ # Should map ids back to label names in response
+ assert response.data["data"]["results"][0]["labels"] == ["label_1", "label_2"]
+
+ @mock.patch("bkflow.interface.task.view.Label.objects.get_labels_map")
+ @mock.patch("bkflow.interface.task.view.TaskComponentClient")
+ def test_update_labels(self, mock_client_class, mock_get_labels_map):
+ """Test update_labels method, should map returned label ids to label names"""
+ mock_get_labels_map.return_value = {1: "label_1", 2: "label_2"}
+
+ mock_client = mock.Mock()
+ mock_client.update_labels.return_value = {"result": True, "data": [1, 2]}
+ mock_client_class.return_value = mock_client
+
+ view = TaskInterfaceAdminViewSet.as_view({"post": "update_labels"})
+ request = self.factory.post(
+ f"/admin/tasks/update_labels/{self.space.id}/1/", {"label_ids": [1, 2]}, format="json"
+ )
+ force_authenticate(request, user=self.admin_user)
+
+ response = view(request, space_id=self.space.id, pk=1)
+
+ assert response.status_code == 200
+ mock_client.update_labels.assert_called_once()
+ assert response.data["data"] == ["label_1", "label_2"]
+
@mock.patch("bkflow.interface.task.view.TaskComponentClient")
def test_get_tasks_states(self, mock_client_class):
"""Test get_tasks_states method"""
@@ -280,6 +330,39 @@ def test_inject_user_task_auth_with_scope_permissions(self):
assert "auth" in data["data"]
assert PermissionType.VIEW.value in data["data"]["auth"]
+ def test_inject_user_task_auth_mock_task_with_template_permission(self):
+ """Test _inject_user_task_auth for MOCK task, should include TEMPLATE permission query"""
+ Token.objects.create(
+ token="token_template_mock",
+ space_id=self.space.id,
+ user="normaluser",
+ resource_type=ResourceType.TEMPLATE.value,
+ resource_id="456",
+ permission_type=PermissionType.MOCK.value,
+ expired_time=timezone.now() + timezone.timedelta(hours=1),
+ )
+
+ request = MagicMock()
+ request.user.is_superuser = False
+ request.user.username = "normaluser"
+ request.is_space_superuser = False
+
+ data = {
+ "result": True,
+ "data": {
+ "id": "123",
+ "space_id": self.space.id,
+ "scope_type": "project",
+ "scope_value": "456",
+ "create_method": "MOCK",
+ "template_id": "456",
+ },
+ }
+
+ TaskInterfaceViewSet._inject_user_task_auth(request, data)
+
+ assert PermissionType.MOCK.value in data["data"]["auth"]
+
def test_inject_user_task_auth_result_false(self):
"""Test _inject_user_task_auth when result is False"""
request = MagicMock()
diff --git a/tests/interface/template/test_template_views.py b/tests/interface/template/test_template_views.py
index 6e6a83b8fa..fd425bbb5b 100644
--- a/tests/interface/template/test_template_views.py
+++ b/tests/interface/template/test_template_views.py
@@ -24,6 +24,7 @@
from rest_framework.test import APIRequestFactory, force_authenticate
from bkflow.decision_table.models import DecisionTable
+from bkflow.label.models import Label, TemplateLabelRelation
from bkflow.space.configs import FlowVersioning
from bkflow.space.models import Space, SpaceConfig
from bkflow.template.models import (
@@ -1581,3 +1582,670 @@ def test_create_mock_scheme_duplicate(self):
assert response.status_code in [400, 200]
except Exception:
pass
+
+
+@pytest.mark.django_db
+class TestTemplateFilterAndBranches:
+ def setup_method(self):
+ self.factory = APIRequestFactory()
+ self.user = User.objects.create_superuser(username="test_user", password="password")
+ self.admin_user, _ = User.objects.get_or_create(
+ username="admin", defaults={"is_superuser": True, "is_staff": True}
+ )
+ self.space = Space.objects.create(name="Test Space", app_code="test_app")
+ self.pipeline_tree = build_pipeline_tree()
+
+ def _create_template(self, name="t1"):
+ snapshot = TemplateSnapshot.create_snapshot(self.pipeline_tree, "test_user", "1.0.0")
+ template = Template.objects.create(
+ name=name,
+ space_id=self.space.id,
+ snapshot_id=snapshot.id,
+ creator="test_user",
+ updated_by="test_user",
+ )
+ snapshot.template_id = template.id
+ snapshot.save()
+ return template
+
+ def test_filter_by_labels_returns_queryset_when_no_match(self):
+ """Cover TemplateFilterSet.filter_by_labels empty label_ids branch (137-139)."""
+ template = self._create_template("t_no_label")
+
+ view = TemplateViewSet.as_view({"get": "list_template"})
+ request = self.factory.get(f"/templates/list_template/?space_id={self.space.id}&label=__not_exists__")
+ force_authenticate(request, user=self.user)
+ response = view(request)
+
+ assert response.status_code == 200
+ results = response.data.get("data", {}).get("results", [])
+ # should not error and should include created template
+ assert any(item.get("id") == template.id for item in results)
+
+ def test_filter_by_labels_filters_queryset(self):
+ """Cover TemplateFilterSet.filter_by_labels subquery filter branch (141-143)."""
+ label = Label.objects.create(
+ name="tag_1",
+ creator="test_user",
+ updated_by="test_user",
+ space_id=self.space.id,
+ label_scope=["template"],
+ )
+ template_hit = self._create_template("t_hit")
+ template_miss = self._create_template("t_miss")
+ TemplateLabelRelation.objects.create(template_id=template_hit.id, label_id=label.id)
+
+ view = TemplateViewSet.as_view({"get": "list_template"})
+ request = self.factory.get(f"/templates/list_template/?space_id={self.space.id}&label=tag_1")
+ force_authenticate(request, user=self.user)
+ response = view(request)
+
+ assert response.status_code == 200
+ results = response.data.get("data", {}).get("results", [])
+ ids = {item.get("id") for item in results}
+ assert template_hit.id in ids
+ assert template_miss.id not in ids
+
+ def test_admin_list_injects_labels_and_trigger_flag_false(self):
+ """Cover AdminTemplateViewSet.list label injection and trigger flag false branch."""
+ template = self._create_template("t_admin")
+ label = Label.objects.create(
+ name="tag_admin",
+ creator="test_user",
+ updated_by="test_user",
+ space_id=self.space.id,
+ label_scope=["template"],
+ )
+ TemplateLabelRelation.objects.create(template_id=template.id, label_id=label.id)
+
+ view = AdminTemplateViewSet.as_view({"get": "list"})
+ request = self.factory.get(f"/admin/templates/?space_id={self.space.id}")
+ force_authenticate(request, user=self.admin_user)
+ response = view(request)
+
+ assert response.status_code == 200
+ results = response.data.get("data", {}).get("results", [])
+ item = next((r for r in results if r.get("id") == template.id), None)
+ assert item is not None
+ assert item.get("has_interval_trigger") is False
+ assert any(_label.get("id") == label.id for _label in item.get("labels", []))
+
+ @mock.patch("bkflow.template.views.template.SpaceConfig.get_config")
+ @mock.patch("bkflow.template.views.template.build_default_pipeline_tree_with_space_id")
+ def test_create_template_flow_versioning_true_creates_draft_snapshot(self, mock_build_tree, mock_get_config):
+ """Cover create_template flow-versioning true branch (create_draft_snapshot)."""
+ mock_build_tree.return_value = self.pipeline_tree
+ mock_get_config.return_value = "true"
+
+ view = AdminTemplateViewSet.as_view({"post": "create_template"})
+ data = {"name": "New Template", "desc": "d", "label_ids": []}
+ request = self.factory.post(f"/admin/templates/create_default_template/{self.space.id}/", data, format="json")
+ force_authenticate(request, user=self.admin_user)
+ response = view(request, space_id=self.space.id)
+
+ assert response.status_code == 200
+ # NOTE: response payload is wrapped by SimpleGenericViewSet.finalize_response
+ template_id = response.data["data"]["data"]["id"]
+ template = Template.objects.get(id=template_id)
+ snapshot = TemplateSnapshot.objects.get(id=template.snapshot_id)
+ assert snapshot.draft is True
+
+
+@pytest.mark.django_db
+class TestTemplateViewSetMoreExceptionBranches:
+ def setup_method(self):
+ self.factory = APIRequestFactory()
+ self.user = User.objects.create_superuser(username="test_user", password="password")
+ self.space = Space.objects.create(name="Test Space", app_code="test_app")
+ self.pipeline_tree = build_pipeline_tree()
+ SpaceConfig.objects.create(
+ space_id=self.space.id, name=FlowVersioning.name, value_type="TEXT", text_value="true"
+ )
+
+ snapshot = TemplateSnapshot.create_snapshot(self.pipeline_tree, "test_user", "1.0.0")
+ self.template = Template.objects.create(
+ name="Test Template",
+ space_id=self.space.id,
+ snapshot_id=snapshot.id,
+ creator="test_user",
+ updated_by="test_user",
+ )
+ snapshot.template_id = self.template.id
+ snapshot.save()
+
+ def test_preview_task_tree_get_pipeline_tree_error_wrapped(self, monkeypatch):
+ """Cover preview_task_tree internal exception catch (579-604)."""
+
+ def _boom(_version):
+ raise Exception("boom")
+
+ monkeypatch.setattr(Template, "get_pipeline_tree_by_version", lambda _self, _version: _boom(_version))
+
+ view = TemplateViewSet.as_view({"post": "preview_task_tree"})
+ data = {"appoint_node_ids": [], "is_all_nodes": True, "version": "1.0.0", "is_draft": False}
+ request = self.factory.post(f"/templates/{self.template.id}/preview_task_tree/", data, format="json")
+ force_authenticate(request, user=self.user)
+ response = view(request, pk=self.template.id)
+
+ assert response.status_code == 200
+ assert response.data.get("result") is False
+
+ @mock.patch("bkflow.template.views.template.TaskComponentClient")
+ def test_create_mock_task_raises_api_response_error(self, mock_client_class):
+ """Cover create_mock_task API failure branch (619-622)."""
+ mock_client = mock.Mock()
+ mock_client.create_task.return_value = {"result": False, "message": "api error"}
+ mock_client_class.return_value = mock_client
+
+ view = TemplateViewSet.as_view({"post": "create_mock_task"})
+ data = {
+ "name": "Mock Task",
+ "creator": "test_user",
+ "pipeline_tree": self.pipeline_tree,
+ "mock_data": {"nodes": [], "outputs": {}, "mock_data_ids": {}},
+ "include_node_ids": [],
+ }
+ request = self.factory.post(f"/templates/{self.template.id}/create_mock_task/", data, format="json")
+ force_authenticate(request, user=self.user)
+
+ with pytest.raises(Exception):
+ view(request, pk=self.template.id)
+
+ def test_get_draft_template_versioning_disabled(self):
+ """Cover get_draft_template versioning disabled branch (657)."""
+ space2 = Space.objects.create(name="No Version Space", app_code="test_app2")
+ # explicit disable versioning
+ SpaceConfig.objects.create(space_id=space2.id, name=FlowVersioning.name, value_type="TEXT", text_value="false")
+
+ snapshot = TemplateSnapshot.create_snapshot(self.pipeline_tree, "test_user", "1.0.0")
+ template = Template.objects.create(
+ name="t2",
+ space_id=space2.id,
+ snapshot_id=snapshot.id,
+ creator="test_user",
+ updated_by="test_user",
+ )
+ snapshot.template_id = template.id
+ snapshot.save()
+
+ view = TemplateViewSet.as_view({"get": "get_draft_template"})
+ request = self.factory.get(f"/templates/{template.id}/get_draft_template/")
+ force_authenticate(request, user=self.user)
+ response = view(request, pk=template.id)
+
+ assert response.status_code == 200
+ assert response.data.get("result") is False
+
+ def test_calculate_version_invalid_version_returns_exception_response(self):
+ """Cover calculate_version ValueError branch (675-680)."""
+ # Template.version is a property derived from TemplateSnapshot.version when versioning is enabled,
+ # so only updating snapshot version is enough.
+ TemplateSnapshot.objects.filter(id=self.template.snapshot_id).update(version="invalid")
+
+ view = TemplateViewSet.as_view({"get": "calculate_version"})
+ request = self.factory.get(f"/templates/{self.template.id}/calculate_version/")
+ force_authenticate(request, user=self.user)
+ response = view(request, pk=self.template.id)
+
+ assert response.status_code == 200
+ assert response.data.get("result") is False
+
+ def test_rollback_template_version_missing(self):
+ """Cover rollback_template missing version branch (723)."""
+ view = TemplateViewSet.as_view({"post": "rollback_template"})
+ request = self.factory.post(f"/templates/{self.template.id}/rollback_template/", {}, format="json")
+ force_authenticate(request, user=self.user)
+ response = view(request, pk=self.template.id)
+
+ assert response.status_code == 200
+ assert "version" in str(response.data)
+
+
+@pytest.mark.django_db
+class TestTemplateViewsMoreCoverage:
+ def setup_method(self):
+ self.factory = APIRequestFactory()
+ self.admin_user, _ = User.objects.get_or_create(
+ username="admin", defaults={"is_superuser": True, "is_staff": True}
+ )
+ self.user = User.objects.create_superuser(username="test_user", password="password")
+ self.space = Space.objects.create(name="Test Space", app_code="test_app")
+ self.pipeline_tree = build_pipeline_tree()
+
+ # enable flow versioning by default in this class
+ SpaceConfig.objects.create(
+ space_id=self.space.id, name=FlowVersioning.name, value_type="TEXT", text_value="true"
+ )
+
+ snapshot = TemplateSnapshot.create_snapshot(self.pipeline_tree, "test_user", "1.0.0")
+ self.template = Template.objects.create(
+ name="Test Template",
+ space_id=self.space.id,
+ snapshot_id=snapshot.id,
+ creator="test_user",
+ updated_by="test_user",
+ )
+ snapshot.template_id = self.template.id
+ snapshot.save()
+
+ def test_admin_list_without_pagination_hits_return_response_183(self, monkeypatch):
+ """Cover AdminTemplateViewSet.list non-paginated return (183)."""
+ monkeypatch.setattr(AdminTemplateViewSet, "pagination_class", None)
+
+ view = AdminTemplateViewSet.as_view({"get": "list"})
+ request = self.factory.get(f"/admin/templates/?space_id={self.space.id}")
+ force_authenticate(request, user=self.admin_user)
+ response = view(request)
+
+ assert response.status_code == 200
+ assert response.data.get("result") is True
+ assert isinstance(response.data.get("data"), list)
+
+ def test_admin_update_proxy_to_super_update_211(self):
+ """Cover AdminTemplateViewSet.update -> super().update (211) without touching serializer validation."""
+ from rest_framework.response import Response
+
+ with mock.patch("rest_framework.viewsets.ModelViewSet.update", return_value=Response({"ok": True})):
+ view = AdminTemplateViewSet.as_view({"put": "update"})
+ request = self.factory.put(f"/admin/templates/{self.template.id}/", {}, format="json")
+ force_authenticate(request, user=self.admin_user)
+ response = view(request, pk=self.template.id)
+
+ assert response.status_code == 200
+ assert response.data.get("result") is True
+
+ @mock.patch("bkflow.template.views.template.event_broadcast_signal")
+ @mock.patch("bkflow.template.views.template.PipelineTemplateWebPreviewer.preview_pipeline_tree_exclude_task_nodes")
+ @mock.patch("bkflow.template.views.template.TaskComponentClient")
+ def test_admin_create_task_success_hits_event_broadcast_250_263(self, mock_client_cls, _mock_preview, mock_signal):
+ """Cover AdminTemplateViewSet.create_task success branch and event broadcast (250-263)."""
+ mock_client = mock.Mock()
+ mock_client.create_task.return_value = {
+ "result": True,
+ "data": {"id": 1, "name": "task", "template_id": self.template.id, "parameters": {}},
+ }
+ mock_client_cls.return_value = mock_client
+
+ view = AdminTemplateViewSet.as_view({"post": "create_task"})
+ data = {"template_id": self.template.id, "name": "task", "creator": "test_user", "constants": {}}
+ request = self.factory.post(f"/admin/templates/create_task/{self.space.id}/", data, format="json")
+ force_authenticate(request, user=self.admin_user)
+ response = view(request, space_id=self.space.id)
+
+ assert response.status_code == 200
+ assert response.data.get("result") is True
+ mock_signal.send.assert_called_once()
+
+ def test_admin_batch_delete_continue_branch_311(self):
+ """Cover batch_delete 'continue' branch when root template is also in delete list (311)."""
+ snapshot2 = TemplateSnapshot.create_snapshot(self.pipeline_tree, "test_user", "1.0.1")
+ root = Template.objects.create(
+ name="Root",
+ space_id=self.space.id,
+ snapshot_id=snapshot2.id,
+ creator="test_user",
+ updated_by="test_user",
+ )
+ snapshot2.template_id = root.id
+ snapshot2.save()
+
+ # Mock TemplateReference queryset to make sure the loop runs and hits the `continue` line (311)
+ mocked_ref_qs = mock.Mock()
+ mocked_ref_qs.values_list.return_value = [str(root.id)]
+ mocked_ref_qs.values.return_value = [
+ {"subprocess_template_id": str(self.template.id), "root_template_id": str(root.id)}
+ ]
+
+ view = AdminTemplateViewSet.as_view({"post": "batch_delete"})
+ data = {"space_id": self.space.id, "is_full": False, "template_ids": [root.id, self.template.id]}
+ request = self.factory.post("/admin/templates/batch_delete/", data, format="json")
+ force_authenticate(request, user=self.admin_user)
+
+ original_filter = Template.objects.filter
+
+ def _template_filter_side_effect(*args, **kwargs):
+ # Only affect the query that builds `templates_map` so that `root_id not in templates_map` is True
+ if "id__in" in kwargs and kwargs.get("is_deleted") is False and "space_id" not in kwargs:
+ return original_filter(id__in=[self.template.id], is_deleted=False)
+ return original_filter(*args, **kwargs)
+
+ with mock.patch(
+ "bkflow.template.views.template.TemplateReference.objects.filter", return_value=mocked_ref_qs
+ ), mock.patch(
+ "bkflow.template.views.template.Template.objects.filter", side_effect=_template_filter_side_effect
+ ), mock.patch(
+ "bkflow.template.views.template.Trigger.objects.batch_delete_by_ids"
+ ):
+ response = view(request)
+
+ assert response.status_code == 200
+ assert response.data.get("result") is True
+
+ def test_admin_batch_delete_is_full_hits_326(self):
+ """Cover batch_delete is_full=True update-all branch (326)."""
+ view = AdminTemplateViewSet.as_view({"post": "batch_delete"})
+ data = {"space_id": self.space.id, "is_full": True, "template_ids": [self.template.id]}
+ request = self.factory.post("/admin/templates/batch_delete/", data, format="json")
+ force_authenticate(request, user=self.admin_user)
+ with mock.patch("bkflow.template.views.template.Trigger.objects.batch_delete_by_ids"):
+ response = view(request)
+
+ assert response.status_code == 200
+ assert response.data.get("result") is True
+ assert Template.objects.filter(space_id=self.space.id, is_deleted=True).exists()
+
+ def test_template_version_list_without_pagination_hits_390(self, monkeypatch):
+ """Cover TemplateVersionViewSet.list non-paginated return (390)."""
+ monkeypatch.setattr(TemplateVersionViewSet, "pagination_class", None)
+
+ view = TemplateVersionViewSet.as_view({"get": "list"})
+ request = self.factory.get(f"/template_versions/?template_id={self.template.id}")
+ force_authenticate(request, user=self.user)
+ response = view(request)
+
+ assert response.status_code == 200
+ assert response.data.get("result") is True
+ assert isinstance(response.data.get("data"), list)
+
+ def test_template_version_delete_snapshot_template_missing_397_398(self):
+ """Cover delete_snapshot when snapshot's template does not exist (397-398)."""
+ snap = TemplateSnapshot.create_snapshot(self.pipeline_tree, "test_user", "9.9.9")
+ snap.template_id = 999999
+ snap.save(update_fields=["template_id"])
+
+ view = TemplateVersionViewSet.as_view({"post": "delete_snapshot"})
+ request = self.factory.post(f"/template_versions/{snap.id}/delete_snapshot/", {}, format="json")
+ force_authenticate(request, user=self.user)
+ response = view(request, pk=snap.id)
+
+ assert response.status_code == 400
+
+ def test_list_template_versioning_filters_draft_and_hits_456(self, monkeypatch):
+ """Cover list_template versioning filter branch (443-448) and non-pagination return (456)."""
+ monkeypatch.setattr(TemplateViewSet, "pagination_class", None)
+
+ # draft snapshot template should be filtered out
+ draft_snapshot = TemplateSnapshot.create_draft_snapshot(self.pipeline_tree, "test_user")
+ t_draft = Template.objects.create(
+ name="Draft",
+ space_id=self.space.id,
+ snapshot_id=draft_snapshot.id,
+ creator="test_user",
+ updated_by="test_user",
+ )
+ draft_snapshot.template_id = t_draft.id
+ draft_snapshot.save(update_fields=["template_id"])
+
+ view = TemplateViewSet.as_view({"get": "list_template"})
+ request = self.factory.get(f"/templates/list_template/?space_id={self.space.id}")
+ force_authenticate(request, user=self.user)
+ response = view(request)
+
+ assert response.status_code == 200
+ results = response.data.get("data", [])
+ ids = {item.get("id") for item in results}
+ assert t_draft.id not in ids
+ assert self.template.id in ids
+
+ def test_get_space_related_configs_raises_when_default_not_exists_546_548(self):
+ """Cover get_space_related_configs catch and re-raise (546-548)."""
+ from bkflow.space.exceptions import SpaceConfigDefaultValueNotExists
+
+ def _boom(*args, **kwargs):
+ raise SpaceConfigDefaultValueNotExists("no default")
+
+ with mock.patch("bkflow.template.views.template.SpaceConfig.get_config", side_effect=_boom):
+ view = TemplateViewSet.as_view({"get": "get_space_related_configs"})
+ request = self.factory.get(f"/templates/{self.template.id}/get_space_related_configs/")
+ force_authenticate(request, user=self.user)
+ with pytest.raises(SpaceConfigDefaultValueNotExists):
+ view(request, pk=self.template.id)
+
+ def test_get_space_related_configs_uniform_api_branch_552(self):
+ """Cover uniform api config handler branch (552)."""
+ from bkflow.space.configs import GatewayExpressionConfig, UniformApiConfig
+
+ def _get_config(space_id=None, config_name=None, *args, **kwargs):
+ if config_name == GatewayExpressionConfig.name:
+ return "{}"
+ if config_name == UniformApiConfig.name:
+ return '{"token": "x"}'
+ return "true"
+
+ handler_ret = mock.Mock()
+ handler_ret.dict.return_value = {"handled": True}
+ with mock.patch("bkflow.template.views.template.SpaceConfig.get_config", side_effect=_get_config), mock.patch(
+ "bkflow.template.views.template.UniformAPIConfigHandler"
+ ) as handler_cls:
+ handler_cls.return_value.handle.return_value = handler_ret
+
+ view = TemplateViewSet.as_view({"get": "get_space_related_configs"})
+ request = self.factory.get(f"/templates/{self.template.id}/get_space_related_configs/")
+ force_authenticate(request, user=self.user)
+ response = view(request, pk=self.template.id)
+
+ assert response.status_code == 200
+ data = response.data.get("data", {})
+ assert UniformApiConfig.name in data
+ assert data[UniformApiConfig.name] == {"handled": True}
+
+ def test_template_update_injects_labels_460_468(self):
+ """Cover TemplateViewSet.update label injection (460-468) by stubbing serializer and perform_update."""
+ label = Label.objects.create(
+ name="tag_upd",
+ creator="test_user",
+ updated_by="test_user",
+ space_id=self.space.id,
+ label_scope=["template"],
+ )
+ TemplateLabelRelation.objects.set_labels(self.template.id, [label.id])
+
+ template_id = self.template.id
+
+ class _StubSerializer:
+ def __init__(self):
+ self.data = {"id": template_id, "name": "n"}
+
+ def is_valid(self, raise_exception=False):
+ return True
+
+ def _get_serializer(_self, *args, **kwargs):
+ return _StubSerializer()
+
+ with mock.patch.object(TemplateViewSet, "get_serializer", _get_serializer), mock.patch.object(
+ TemplateViewSet, "perform_update", lambda *_args, **_kwargs: None
+ ):
+ view = TemplateViewSet.as_view({"put": "update"})
+ request = self.factory.put(f"/templates/{self.template.id}/", {}, format="json")
+ force_authenticate(request, user=self.user)
+ response = view(request, pk=self.template.id)
+
+ assert response.status_code == 200
+ assert response.data.get("result") is True
+ assert any(_label.get("id") == label.id for _label in response.data.get("data", {}).get("labels", []))
+
+ def test_update_labels_action_473_477(self):
+ """Cover update_labels transaction block and return (473-477)."""
+ label = Label.objects.create(
+ name="tag2",
+ creator="test_user",
+ updated_by="test_user",
+ space_id=self.space.id,
+ label_scope=["template"],
+ )
+ view = TemplateViewSet.as_view({"post": "update_labels"})
+ request = self.factory.post(
+ f"/templates/{self.template.id}/update_labels/", {"label_ids": [label.id]}, format="json"
+ )
+ force_authenticate(request, user=self.user)
+ response = view(request, pk=self.template.id)
+
+ assert response.status_code == 200
+ assert response.data.get("data") == [label.id]
+
+ def test_analysis_constants_ref_exception_489_491(self):
+ """Cover analysis_constants_ref exception branch (489-491)."""
+ from bkflow.template.exceptions import AnalysisConstantsRefException
+
+ with mock.patch(
+ "bkflow.template.views.template.analysis_pipeline_constants_ref", side_effect=Exception("boom")
+ ):
+ view = TemplateViewSet.as_view({"post": "analysis_constants_ref"})
+ request = self.factory.post("/templates/analysis_constants_ref/", {"constants": {}}, format="json")
+ force_authenticate(request, user=self.user)
+ with pytest.raises(AnalysisConstantsRefException):
+ view(request)
+
+ def test_analysis_constants_ref_nodefined_branch_500(self):
+ """Cover analysis_constants_ref nodefined assignment (500)."""
+ with mock.patch("bkflow.template.views.template.analysis_pipeline_constants_ref", return_value={"x": "y"}):
+ view = TemplateViewSet.as_view({"post": "analysis_constants_ref"})
+ request = self.factory.post("/templates/analysis_constants_ref/", {"constants": {}}, format="json")
+ force_authenticate(request, user=self.user)
+ response = view(request)
+
+ assert response.status_code == 200
+ assert response.data.get("data", {}).get("nodefined", {}).get("x") == "y"
+
+ def test_draw_pipeline_includes_position_kwargs_515(self):
+ """Cover draw_pipeline POSITION kwargs branch (515)."""
+ with mock.patch("bkflow.template.views.template.draw_pipeline_tree") as m:
+ view = TemplateViewSet.as_view({"post": "draw_pipeline"})
+ request = self.factory.post(
+ "/templates/draw_pipeline/",
+ {"pipeline_tree": self.pipeline_tree, "canvas_width": 1300, "activity_size": [150, 54]},
+ format="json",
+ )
+ force_authenticate(request, user=self.user)
+ response = view(request)
+
+ assert response.status_code == 200
+ m.assert_called_once()
+
+ @mock.patch("bkflow.template.views.template.preview_template_tree", return_value={"ok": True})
+ def test_preview_task_tree_draft_success_covers_569_604(self, _mock_preview):
+ """Cover preview_task_tree is_draft branch and success path (569, 579-604)."""
+ draft = TemplateSnapshot.create_draft_snapshot(self.pipeline_tree, "test_user")
+ draft.template_id = self.template.id
+ draft.save(update_fields=["template_id"])
+
+ with mock.patch.object(Template, "outputs", lambda *_args, **_kwargs: {}):
+ view = TemplateViewSet.as_view({"post": "preview_task_tree"})
+ data = {"appoint_node_ids": [], "is_all_nodes": True, "version": "1.0.0", "is_draft": True}
+ request = self.factory.post(f"/templates/{self.template.id}/preview_task_tree/", data, format="json")
+ force_authenticate(request, user=self.user)
+ response = view(request, pk=self.template.id)
+
+ assert response.status_code == 200
+ assert response.data.get("result") is True
+ assert response.data.get("data", {}).get("name") == self.template.name
+
+ def test_preview_task_tree_not_all_nodes_hits_exclude_task_nodes_585(self):
+ """Cover preview_task_tree not-all-nodes branch (585)."""
+ with mock.patch.object(
+ Template, "get_pipeline_tree_by_version", return_value=self.pipeline_tree
+ ), mock.patch.object(Template, "outputs", lambda *_args, **_kwargs: {}), mock.patch(
+ "bkflow.template.views.template.PipelineTemplateWebPreviewer."
+ "get_template_exclude_task_nodes_with_appoint_nodes",
+ return_value=["x"],
+ ) as m_get_exclude, mock.patch(
+ "bkflow.template.views.template.preview_template_tree", return_value={"ok": True}
+ ):
+ view = TemplateViewSet.as_view({"post": "preview_task_tree"})
+ data = {"appoint_node_ids": ["n1"], "is_all_nodes": False, "version": "1.0.0", "is_draft": False}
+ request = self.factory.post(f"/templates/{self.template.id}/preview_task_tree/", data, format="json")
+ force_authenticate(request, user=self.user)
+ response = view(request, pk=self.template.id)
+
+ assert response.status_code == 200
+ assert response.data.get("result") is True
+ m_get_exclude.assert_called_once()
+
+ def test_preview_task_tree_outer_exception_logged_591_594(self):
+ """Cover preview_task_tree outer exception handler logging (591-594)."""
+ with mock.patch.object(Template, "get_pipeline_tree_by_version", return_value=self.pipeline_tree), mock.patch(
+ "bkflow.template.views.template.preview_template_tree", side_effect=Exception("boom")
+ ):
+ view = TemplateViewSet.as_view({"post": "preview_task_tree"})
+ data = {"appoint_node_ids": [], "is_all_nodes": True, "version": "1.0.0", "is_draft": False}
+ request = self.factory.post(f"/templates/{self.template.id}/preview_task_tree/", data, format="json")
+ force_authenticate(request, user=self.user)
+ with pytest.raises(Exception):
+ view(request, pk=self.template.id)
+
+ @mock.patch("bkflow.template.views.template.PipelineTemplateWebPreviewer.preview_pipeline_tree_exclude_task_nodes")
+ @mock.patch(
+ "bkflow.template.views.template.PipelineTemplateWebPreviewer.get_template_exclude_task_nodes_with_appoint_nodes"
+ )
+ @mock.patch("bkflow.template.views.template.TaskComponentClient")
+ def test_create_mock_task_include_node_ids_branch_619_622(self, mock_client_cls, mock_get_exclude, _mock_preview):
+ """Cover create_mock_task include_node_ids branch (619-622)."""
+ mock_get_exclude.return_value = []
+ mock_client = mock.Mock()
+ mock_client.create_task.return_value = {"result": True, "data": {"id": 11}}
+ mock_client_cls.return_value = mock_client
+
+ view = TemplateViewSet.as_view({"post": "create_mock_task"})
+ data = {
+ "name": "Mock Task",
+ "creator": "test_user",
+ "pipeline_tree": self.pipeline_tree,
+ "mock_data": {"nodes": [], "outputs": {}, "mock_data_ids": {}},
+ "include_node_ids": ["node1"],
+ }
+ request = self.factory.post(f"/templates/{self.template.id}/create_mock_task/", data, format="json")
+ force_authenticate(request, user=self.user)
+ response = view(request, pk=self.template.id)
+
+ assert response.status_code == 200
+ assert response.data.get("result") is True
+
+ def test_release_template_duplicate_version_693(self):
+ """Cover release_template duplicate version early return (693)."""
+ view = TemplateViewSet.as_view({"post": "release_template"})
+ request = self.factory.post(
+ f"/templates/{self.template.id}/release_template/", {"version": "1.0.0", "desc": "d"}, format="json"
+ )
+ force_authenticate(request, user=self.user)
+ response = view(request, pk=self.template.id)
+
+ assert response.status_code == 200
+ assert response.data.get("result") is False
+
+ def test_release_template_invalid_version_bump_custom_696_698(self):
+ """Cover release_template bump_custom ValueError branch (696-698)."""
+ with mock.patch("bkflow.template.views.template.bump_custom", side_effect=ValueError("bad")):
+ view = TemplateViewSet.as_view({"post": "release_template"})
+ request = self.factory.post(
+ f"/templates/{self.template.id}/release_template/", {"version": "bad", "desc": "d"}, format="json"
+ )
+ force_authenticate(request, user=self.user)
+ response = view(request, pk=self.template.id)
+
+ assert response.status_code == 200
+ assert response.data.get("result") is False
+ assert "版本号不符合规范" in str(response.data)
+
+ def test_rollback_template_versioning_disabled_720(self):
+ """Cover rollback_template when versioning disabled (720)."""
+ space2 = Space.objects.create(name="No Version", app_code="app2")
+ SpaceConfig.objects.create(space_id=space2.id, name=FlowVersioning.name, value_type="TEXT", text_value="false")
+
+ snap = TemplateSnapshot.create_snapshot(self.pipeline_tree, "test_user", "1.0.0")
+ tpl = Template.objects.create(
+ name="t",
+ space_id=space2.id,
+ snapshot_id=snap.id,
+ creator="test_user",
+ updated_by="test_user",
+ )
+ snap.template_id = tpl.id
+ snap.save(update_fields=["template_id"])
+
+ view = TemplateViewSet.as_view({"post": "rollback_template"})
+ request = self.factory.post(f"/templates/{tpl.id}/rollback_template/", {"version": "1.0.0"}, format="json")
+ force_authenticate(request, user=self.user)
+ response = view(request, pk=tpl.id)
+
+ assert response.status_code == 200
+ assert response.data.get("result") is False
diff --git a/tests/label/test_label.py b/tests/label/test_label.py
index c3bc9fc25e..12936f23d7 100644
--- a/tests/label/test_label.py
+++ b/tests/label/test_label.py
@@ -166,6 +166,15 @@ def test_full_path_builds_hierarchy(self):
assert child.full_path == "root/child"
assert grandchild.full_path == "root/child/grandchild"
+ def test_str_contains_parent_name_or_default(self):
+ """__str__ should include parent name when available, otherwise use default text."""
+ root = make_label("root_for_str")
+ child = make_label("child_for_str", parent_id=root.id)
+
+ assert "root_for_str" in str(child)
+ assert "child_for_str" in str(child)
+ assert "父标签:无" in str(root)
+
def test_label_clean_validates_parent_space(self):
"""
save should fail when child's space_id does not match parent space_id.
@@ -222,6 +231,24 @@ def test_label_clean_prevents_cycle_reference(self):
msg = str(exc.value)
assert "禁止循环引用" in msg
+ def test_label_clean_breaks_when_ancestor_missing(self):
+ """Cycle check should break gracefully when an ancestor record is missing."""
+ parent = make_label("parent_inconsistent")
+ # Create an inconsistent chain: parent.parent_id points to a missing label.
+ # Use queryset.update to bypass model clean/save.
+ Label.objects.filter(id=parent.id).update(parent_id=999999)
+
+ child = Label(
+ name="child_ok",
+ space_id=parent.space_id,
+ parent_id=parent.id,
+ label_scope=["task"],
+ creator="tester",
+ updated_by="tester",
+ )
+ # Should not raise, even though parent's parent is missing.
+ child.save()
+
def test_get_all_children_delegates_to_manager(self):
"""
get_all_children should delegate to LabelManager.get_sub_labels.
@@ -346,6 +373,10 @@ def test_fetch_objects_labels(self):
for item in labels:
assert set(item.keys()) >= {"id", "name", "color", "full_path"}
+ def test_fetch_objects_labels_returns_empty_when_no_relations(self):
+ """fetch_objects_labels should return {} when the provided objects have no relations."""
+ assert TemplateLabelRelation.objects.fetch_objects_labels([999999], label_fields=("name", "color")) == {}
+
class TestLabelSerializer:
"""Tests for LabelSerializer validation logic."""
@@ -519,3 +550,133 @@ def test_validate_name_empty_and_whitespace(self):
assert serializer.is_valid() is True
assert serializer.validated_data["name"] == "valid_name"
+
+ def test_validate_name_custom_error_when_blank_after_strip(self):
+ """Direct validate_name should raise the custom error when value becomes blank after strip."""
+ from bkflow.label.serializers import LabelSerializer
+
+ serializer = LabelSerializer()
+ with pytest.raises(DRFValidationError) as exc:
+ serializer.validate_name(" ")
+ assert "标签名称不能为空" == str(exc.value.detail[0])
+
+
+class TestLabelPermission:
+ """Tests for bkflow.label.permissions.LabelPermission."""
+
+ def test_get_resource_type(self):
+ from bkflow.label.permissions import LabelPermission
+
+ assert LabelPermission().get_resource_type() == "LABEL"
+
+ def test_has_object_permission_edit_actions_use_edit_permission_only(self, monkeypatch):
+ """When action is in EDIT_ABOVE_ACTIONS, only edit permission matters."""
+ from types import SimpleNamespace
+
+ from bkflow.label.permissions import LabelPermission
+
+ perm = LabelPermission()
+
+ monkeypatch.setattr(perm, "has_edit_permission", lambda *args, **kwargs: True)
+ monkeypatch.setattr(perm, "has_view_permission", lambda *args, **kwargs: False)
+
+ request = SimpleNamespace(user=SimpleNamespace(username="tester"), token="t")
+ view = SimpleNamespace(action="create", EDIT_ABOVE_ACTIONS=["create", "update", "partial_update", "destroy"])
+ obj = SimpleNamespace(space_id=1, id=123)
+
+ assert perm.has_object_permission(request, view, obj) is True
+
+ monkeypatch.setattr(perm, "has_edit_permission", lambda *args, **kwargs: False)
+ assert perm.has_object_permission(request, view, obj) is False
+
+ def test_has_object_permission_view_actions_allow_view_or_edit(self, monkeypatch):
+ """When action is not edit-type, view permission OR edit permission passes."""
+ from types import SimpleNamespace
+
+ from bkflow.label.permissions import LabelPermission
+
+ perm = LabelPermission()
+ request = SimpleNamespace(user=SimpleNamespace(username="tester"), token="t")
+ view = SimpleNamespace(action="list", EDIT_ABOVE_ACTIONS=["create", "update", "partial_update", "destroy"])
+ obj = SimpleNamespace(space_id=1, id=123)
+
+ # view=True, edit=False -> True
+ monkeypatch.setattr(perm, "has_edit_permission", lambda *args, **kwargs: False)
+ monkeypatch.setattr(perm, "has_view_permission", lambda *args, **kwargs: True)
+ assert perm.has_object_permission(request, view, obj) is True
+
+ # view=False, edit=True -> True
+ monkeypatch.setattr(perm, "has_edit_permission", lambda *args, **kwargs: True)
+ monkeypatch.setattr(perm, "has_view_permission", lambda *args, **kwargs: False)
+ assert perm.has_object_permission(request, view, obj) is True
+
+ # view=False, edit=False -> False
+ monkeypatch.setattr(perm, "has_edit_permission", lambda *args, **kwargs: False)
+ monkeypatch.setattr(perm, "has_view_permission", lambda *args, **kwargs: False)
+ assert perm.has_object_permission(request, view, obj) is False
+
+
+class TestLabelAdminHelpers:
+ """Cover admin helper methods in bkflow.label.admin."""
+
+ def test_label_admin_parent_label(self):
+ from django.contrib import admin
+
+ from bkflow.label.admin import LabelAdmin
+ from bkflow.label.models import Label
+
+ root = make_label("root_admin")
+ child = make_label("child_admin", parent_id=root.id)
+
+ admin_obj = LabelAdmin(Label, admin.site)
+
+ assert admin_obj.parent_label(root) == "-"
+ assert admin_obj.parent_label(child) == "root_admin"
+
+ def test_template_label_relation_admin_label_name(self):
+ from django.contrib import admin
+
+ from bkflow.label.admin import TemplateLabelRelationAdmin
+ from bkflow.label.models import TemplateLabelRelation
+
+ label = make_label("label_for_relation")
+ rel_ok = TemplateLabelRelation.objects.create(template_id=1, label_id=label.id)
+ rel_missing = TemplateLabelRelation.objects.create(template_id=1, label_id=999999999)
+
+ admin_obj = TemplateLabelRelationAdmin(TemplateLabelRelation, admin.site)
+
+ assert admin_obj.label_name(rel_ok) == "label_for_relation"
+ assert admin_obj.label_name(rel_missing) == "-"
+
+
+class TestLabelRefSerializer:
+ def test_validate_label_ids_accepts_list_and_sorts_dedup(self):
+ from bkflow.label.serializers import LabelRefSerializer
+
+ ser = LabelRefSerializer()
+ assert ser.validate_label_ids([3, "2", 2]) == "2,3"
+
+ def test_validate_label_ids_rejects_invalid_list_item(self):
+ from rest_framework.exceptions import ValidationError
+
+ from bkflow.label.serializers import LabelRefSerializer
+
+ ser = LabelRefSerializer()
+ with pytest.raises(ValidationError):
+ ser.validate_label_ids(["x"])
+
+ def test_validate_label_ids_rejects_empty_and_bad_format(self):
+ from rest_framework.exceptions import ValidationError
+
+ from bkflow.label.serializers import LabelRefSerializer
+
+ ser = LabelRefSerializer()
+
+ # empty
+ with pytest.raises(ValidationError):
+ ser.validate_label_ids("")
+
+ # leading/trailing commas / illegal chars
+ for v in [",1,2", "1,2,", "1,a"]:
+ with pytest.raises(ValidationError):
+ ser.validate_label_ids(v)
diff --git a/tests/label/test_label_viewset.py b/tests/label/test_label_viewset.py
index 6425855e5b..9b018e5af8 100644
--- a/tests/label/test_label_viewset.py
+++ b/tests/label/test_label_viewset.py
@@ -403,3 +403,88 @@ def test_filter_is_default_boolean_filter(self):
filtered_ids = list(filtered.values_list("id", flat=True))
assert non_default_label.id in filtered_ids
assert default_label.id not in filtered_ids
+
+
+class TestLabelViewSetExtraCoverage:
+ """Extra tests to cover remaining branches in bkflow.label.views."""
+
+ pytestmark = pytest.mark.django_db
+
+ def setup_method(self):
+ self.factory = APIRequestFactory()
+ self.admin_user, created = User.objects.get_or_create(
+ username="label_admin_extra",
+ defaults={
+ "is_superuser": True,
+ "is_staff": True,
+ },
+ )
+
+ ModuleInfo.objects.get_or_create(
+ space_id=1,
+ defaults={
+ "code": "test_task",
+ "url": "http://test.example.com",
+ "token": "test_token",
+ "type": ModuleType.TASK.value,
+ "isolation_level": IsolationLevel.ONLY_CALCULATION.value,
+ },
+ )
+
+ self.list_view = LabelViewSet.as_view({"get": "list"})
+ self.destroy_view = LabelViewSet.as_view({"delete": "destroy"})
+
+ def test_list_without_required_space_id_returns_empty(self):
+ """Missing required filter should make filterset invalid and return empty queryset."""
+ make_label("root", space_id=1)
+
+ request = self.factory.get("/api/label/")
+ request.user = self.admin_user
+
+ response = self.list_view(request)
+ assert response.status_code == status.HTTP_200_OK
+
+ # For SimpleGenericViewSet response format
+ data = response.data.get("data", {})
+ results = data.get("results", [])
+ assert results == []
+
+ def test_destroy_when_task_component_client_returns_error(self):
+ """destroy should return 400 and not delete objects when task component rejects."""
+ root = make_label("root_fail_delete", space_id=1)
+ child = make_label("child_fail_delete", space_id=1, parent_id=root.id)
+
+ request = self.factory.delete(f"/api/label/{root.id}/")
+ request.user = self.admin_user
+
+ with patch("bkflow.label.views.TaskComponentClient") as mock_client:
+ mock_instance = mock_client.return_value
+ mock_instance.delete_task_label_relation.return_value = {"result": False, "message": "fail"}
+
+ response = self.destroy_view(request, pk=root.id)
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ assert Label.objects.filter(id=root.id).exists() is True
+ assert Label.objects.filter(id=child.id).exists() is True
+
+
+class TestLabelFilterQuerysetBranches:
+ """Cover LabelFilter.filter_queryset has_parent_id=True branch."""
+
+ pytestmark = pytest.mark.django_db
+
+ def test_filter_queryset_with_parent_id_keeps_queryset(self):
+ from bkflow.label.views import LabelFilter
+
+ parent = make_label("parent_for_filter", space_id=1)
+ child = make_label("child_for_filter", space_id=1, parent_id=parent.id)
+
+ # provide required space_id + parent_id so has_parent_id=True
+ data = {"space_id": 1, "parent_id": parent.id}
+ f = LabelFilter(data=data, queryset=Label.objects.all())
+
+ assert f.is_valid() is True
+ qs = f.qs
+
+ returned_ids = set(qs.values_list("id", flat=True))
+ assert returned_ids == {child.id}