From 60d9b3529ecc74839f7d1d490e9443d3f16b715a Mon Sep 17 00:00:00 2001 From: iSecloud <869820505@qq.com> Date: Fri, 6 Sep 2024 20:18:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E5=8D=95=E6=8D=AE=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=BB=86=E5=8C=96=20#6755?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db_services/bigdata/resources/query.py | 4 +- .../db_services/mysql/dumper/handlers.py | 4 +- .../dbm_init/json_files/itsm/itsm_dbm.json | 10 +- dbm-ui/backend/dbm_init/services.py | 10 +- .../components/collections/common/pause.py | 25 +- .../tests/mock_data/ticket/ticket_flow.py | 13 + .../ticket/{ => doris}/test_doris_flow.py | 6 +- .../ticket/{ => mongo}/test_mongodb_flow.py | 2 +- .../test_mysql_flow.py} | 0 .../{ => sqlserver}/test_sqlserver_flow.py | 0 .../tests/ticket/test_ticket_revoke.py | 75 ++++ .../builders/mysql/mysql_ha_full_backup.py | 3 +- dbm-ui/backend/ticket/constants.py | 93 +++-- dbm-ui/backend/ticket/contexts.py | 6 +- dbm-ui/backend/ticket/filters.py | 62 ++- dbm-ui/backend/ticket/flow_manager/base.py | 28 +- .../backend/ticket/flow_manager/delivery.py | 9 +- dbm-ui/backend/ticket/flow_manager/inner.py | 71 +++- dbm-ui/backend/ticket/flow_manager/itsm.py | 47 ++- dbm-ui/backend/ticket/flow_manager/manager.py | 17 +- dbm-ui/backend/ticket/flow_manager/pause.py | 11 +- .../backend/ticket/flow_manager/resource.py | 16 +- dbm-ui/backend/ticket/flow_manager/timer.py | 20 +- dbm-ui/backend/ticket/handler.py | 136 +++++-- dbm-ui/backend/ticket/models/ticket.py | 23 +- dbm-ui/backend/ticket/models/todo.py | 14 +- dbm-ui/backend/ticket/serializers.py | 75 +++- dbm-ui/backend/ticket/tasks/ticket_tasks.py | 3 +- dbm-ui/backend/ticket/todos/__init__.py | 31 +- dbm-ui/backend/ticket/todos/itsm_todo.py | 70 ++++ dbm-ui/backend/ticket/todos/pause_todo.py | 14 +- dbm-ui/backend/ticket/todos/pipeline_todo.py | 24 +- dbm-ui/backend/ticket/views.py | 362 +++++++++--------- 33 files changed, 870 insertions(+), 414 deletions(-) rename dbm-ui/backend/tests/ticket/{ => doris}/test_doris_flow.py (96%) rename dbm-ui/backend/tests/ticket/{ => mongo}/test_mongodb_flow.py (99%) rename dbm-ui/backend/tests/ticket/{test_ticket_flow.py => mysql/test_mysql_flow.py} (100%) rename dbm-ui/backend/tests/ticket/{ => sqlserver}/test_sqlserver_flow.py (100%) create mode 100644 dbm-ui/backend/tests/ticket/test_ticket_revoke.py create mode 100644 dbm-ui/backend/ticket/todos/itsm_todo.py diff --git a/dbm-ui/backend/db_services/bigdata/resources/query.py b/dbm-ui/backend/db_services/bigdata/resources/query.py index 552eb9a5d5..f6cbbcea13 100644 --- a/dbm-ui/backend/db_services/bigdata/resources/query.py +++ b/dbm-ui/backend/db_services/bigdata/resources/query.py @@ -21,7 +21,7 @@ from backend.db_proxy.models import ClusterExtension from backend.db_services.dbbase.resources import query from backend.db_services.ipchooser.query.resource import ResourceQueryHelper -from backend.ticket.constants import TicketFlowStatus +from backend.ticket.constants import TICKET_RUNNING_STATUS from backend.ticket.models import InstanceOperateRecord from backend.utils.time import datetime2str @@ -65,7 +65,7 @@ def _filter_instance_hook(cls, bk_biz_id, query_params, instances, **kwargs): # 获取实例的操作与实例记录 records = InstanceOperateRecord.objects.filter( - instance_id__in=instance_ids, ticket__status=TicketFlowStatus.RUNNING + instance_id__in=instance_ids, ticket__status__in=TICKET_RUNNING_STATUS ) instance_operate_records_map: Dict[int, List] = defaultdict(list) for record in records: diff --git a/dbm-ui/backend/db_services/mysql/dumper/handlers.py b/dbm-ui/backend/db_services/mysql/dumper/handlers.py index 53b1cf9b17..06db016c68 100644 --- a/dbm-ui/backend/db_services/mysql/dumper/handlers.py +++ b/dbm-ui/backend/db_services/mysql/dumper/handlers.py @@ -15,7 +15,7 @@ from backend.db_meta.enums import InstanceInnerRole from backend.db_meta.models import Cluster from backend.db_services.mysql.dumper.models import DumperSubscribeConfig -from backend.ticket.constants import FlowType, TicketFlowStatus, TicketStatus, TicketType +from backend.ticket.constants import TICKET_RUNNING_STATUS, FlowType, TicketFlowStatus, TicketStatus, TicketType from backend.ticket.models import Flow, Ticket @@ -66,7 +66,7 @@ def patch_dumper_list_info(cls, dumper_results: List[Dict], bk_biz_id: int = 0, dumper_ticket_types.remove(TicketType.TBINLOGDUMPER_INSTALL) dumper_ticket_types.extend([TicketType.MYSQL_MASTER_SLAVE_SWITCH, TicketType.MYSQL_MASTER_FAIL_OVER]) active_tickets = Ticket.objects.filter( - bk_biz_id=bk_biz_id, status=TicketStatus.RUNNING, ticket_type__in=dumper_ticket_types + bk_biz_id=bk_biz_id, status__in=TICKET_RUNNING_STATUS, ticket_type__in=dumper_ticket_types ) # 获取每个dumper单据状态与id的映射 dumper_inst_id__ticket: Dict[int, str] = {} diff --git a/dbm-ui/backend/dbm_init/json_files/itsm/itsm_dbm.json b/dbm-ui/backend/dbm_init/json_files/itsm/itsm_dbm.json index c2b0a2a2b2..8420c236d1 100644 --- a/dbm-ui/backend/dbm_init/json_files/itsm/itsm_dbm.json +++ b/dbm-ui/backend/dbm_init/json_files/itsm/itsm_dbm.json @@ -1398,5 +1398,13 @@ "display_role": "", "source": "custom", "project_key": "bk_dbm_dev", - "for_update": false + "for_update": true, + "remark_key": { + "0": "2b188068fc0864e15307933a953ed0b3", + "1": "d33b7919a6805e3e6f9162600b451657" + }, + "approve_key": { + "0": "b58ca8d060692fe1fa91a4e9418d545a", + "1": "be937ddce3ec8435c96a8c313bae4836" + } } \ No newline at end of file diff --git a/dbm-ui/backend/dbm_init/services.py b/dbm-ui/backend/dbm_init/services.py index 850f963b74..4cf4a84d9d 100644 --- a/dbm-ui/backend/dbm_init/services.py +++ b/dbm-ui/backend/dbm_init/services.py @@ -81,7 +81,11 @@ def auto_create_itsm_service() -> int: dbm_service_json["project_key"] = project_key dbm_service_json["catalog_id"] = dbm_catalog_id dbm_service_name = dbm_service_json["name"] + for_update = dbm_service_json.pop("for_update", False) + approve_key = dbm_service_json.pop("approve_key") + remark_key = dbm_service_json.pop("remark_key") + dbm_service_id = 0 try: @@ -100,9 +104,9 @@ def auto_create_itsm_service() -> int: # 更新到系统配置中 if dbm_service_id: - SystemSettings.insert_setting_value( - key=SystemSettingsEnum.BK_ITSM_SERVICE_ID.value, value=str(dbm_service_id) - ) + SystemSettings.insert_setting_value(key=SystemSettingsEnum.BK_ITSM_SERVICE_ID, value=str(dbm_service_id)) + SystemSettings.insert_setting_value(key=SystemSettingsEnum.ITSM_APPROVAL_KEY, value=approve_key) + SystemSettings.insert_setting_value(key=SystemSettingsEnum.ITSM_REMARK_KEY, value=remark_key) logger.info("服务创建/更新成功") else: logger.info("本次更新跳过...") diff --git a/dbm-ui/backend/flow/plugins/components/collections/common/pause.py b/dbm-ui/backend/flow/plugins/components/collections/common/pause.py index 55e157e496..82f99d92e1 100644 --- a/dbm-ui/backend/flow/plugins/components/collections/common/pause.py +++ b/dbm-ui/backend/flow/plugins/components/collections/common/pause.py @@ -15,9 +15,8 @@ from pipeline.core.flow.io import ObjectItemSchema, StringItemSchema from backend.flow.plugins.components.collections.common.base_service import BaseService -from backend.ticket.constants import TodoType -from backend.ticket.models import Ticket, Todo -from backend.ticket.todos.pipeline_todo import PipelineTodoContext +from backend.ticket.models import Ticket +from backend.ticket.todos.pipeline_todo import PipelineTodo logger = logging.getLogger("root") @@ -34,26 +33,14 @@ def _execute(self, data, parent_data): self.log_info("execute PauseService") kwargs = data.get_one_of_inputs("kwargs") global_data = data.get_one_of_inputs("global_data") + + # 获取单据和flow信息 ticket_id = global_data["uid"] ticket = Ticket.objects.get(id=ticket_id) - - # todo:这里假设ticket中不会出现并行的flow flow = ticket.current_flow() - Todo.objects.create( - name=_("【{}】流程待确认,是否继续?").format(ticket.get_ticket_type_display()), - flow=flow, - ticket=ticket, - type=TodoType.INNER_APPROVE, - # todo: 待办人暂定为提单人 - operators=[ticket.creator], - context=PipelineTodoContext( - flow.id, - ticket_id, - self.runtime_attrs.get("root_pipeline_id"), - self.runtime_attrs.get("id"), - ).to_dict(), - ) + # 创建一条代办 + PipelineTodo.create(ticket, flow, self.runtime_attrs.get("root_pipeline_id"), self.runtime_attrs.get("id")) self.log_info("pause kwargs: {}".format(kwargs)) return True diff --git a/dbm-ui/backend/tests/mock_data/ticket/ticket_flow.py b/dbm-ui/backend/tests/mock_data/ticket/ticket_flow.py index 81cdc9d8e9..6806df954a 100644 --- a/dbm-ui/backend/tests/mock_data/ticket/ticket_flow.py +++ b/dbm-ui/backend/tests/mock_data/ticket/ticket_flow.py @@ -40,6 +40,19 @@ "ticket_type": "MYSQL_AUTHORIZE_RULES", } +MYSQL_FULL_BACKUP_TICKET_DATA = { + "bk_biz_id": constant.BK_BIZ_ID, + "details": { + "infos": { + "backup_type": "logical", + "file_tag": "DBFILE1M", + "clusters": [{"cluster_id": 1, "backup_local": "master"}], + } + }, + "remark": "", + "ticket_type": "MYSQL_HA_FULL_BACKUP", +} + MYSQL_PERMISSION_ACCOUNT = { "items": [ { diff --git a/dbm-ui/backend/tests/ticket/test_doris_flow.py b/dbm-ui/backend/tests/ticket/doris/test_doris_flow.py similarity index 96% rename from dbm-ui/backend/tests/ticket/test_doris_flow.py rename to dbm-ui/backend/tests/ticket/doris/test_doris_flow.py index 2f5f4a7a09..affbae034d 100644 --- a/dbm-ui/backend/tests/ticket/test_doris_flow.py +++ b/dbm-ui/backend/tests/ticket/doris/test_doris_flow.py @@ -38,14 +38,14 @@ SCALEUP_POOL_TICKET_DATA, ) from backend.tests.ticket.server_base import TestFlowBase -from backend.ticket.constants import TicketFlowStatus, TicketStatus +from backend.ticket.constants import TicketFlowStatus logger = logging.getLogger("test") pytestmark = pytest.mark.django_db client = APIClient() -INITIAL_FLOW_FINISHED_STATUS = [TicketFlowStatus.SKIPPED, TicketStatus.SUCCEEDED] -CHANGED_MOCK_STATUS = [TicketFlowStatus.SKIPPED, TicketStatus.SUCCEEDED, TicketFlowStatus.RUNNING] +INITIAL_FLOW_FINISHED_STATUS = [TicketFlowStatus.SKIPPED, TicketFlowStatus.SUCCEEDED] +CHANGED_MOCK_STATUS = [TicketFlowStatus.SKIPPED, TicketFlowStatus.SUCCEEDED, TicketFlowStatus.RUNNING] @pytest.fixture(autouse=True) # autouse=True 会自动应用这个fixture到所有的测试中 diff --git a/dbm-ui/backend/tests/ticket/test_mongodb_flow.py b/dbm-ui/backend/tests/ticket/mongo/test_mongodb_flow.py similarity index 99% rename from dbm-ui/backend/tests/ticket/test_mongodb_flow.py rename to dbm-ui/backend/tests/ticket/mongo/test_mongodb_flow.py index ea933a0562..6ecb142fec 100644 --- a/dbm-ui/backend/tests/ticket/test_mongodb_flow.py +++ b/dbm-ui/backend/tests/ticket/mongo/test_mongodb_flow.py @@ -53,7 +53,7 @@ pytestmark = pytest.mark.django_db client = APIClient() -INITIAL_FLOW_FINISHED_STATUS = [TicketFlowStatus.SKIPPED, TicketStatus.SUCCEEDED] +INITIAL_FLOW_FINISHED_STATUS = [TicketFlowStatus.SKIPPED, TicketFlowStatus.SUCCEEDED] CHANGED_MOCK_STATUS = [TicketFlowStatus.SKIPPED, TicketStatus.SUCCEEDED, TicketFlowStatus.RUNNING] diff --git a/dbm-ui/backend/tests/ticket/test_ticket_flow.py b/dbm-ui/backend/tests/ticket/mysql/test_mysql_flow.py similarity index 100% rename from dbm-ui/backend/tests/ticket/test_ticket_flow.py rename to dbm-ui/backend/tests/ticket/mysql/test_mysql_flow.py diff --git a/dbm-ui/backend/tests/ticket/test_sqlserver_flow.py b/dbm-ui/backend/tests/ticket/sqlserver/test_sqlserver_flow.py similarity index 100% rename from dbm-ui/backend/tests/ticket/test_sqlserver_flow.py rename to dbm-ui/backend/tests/ticket/sqlserver/test_sqlserver_flow.py diff --git a/dbm-ui/backend/tests/ticket/test_ticket_revoke.py b/dbm-ui/backend/tests/ticket/test_ticket_revoke.py new file mode 100644 index 0000000000..ee10680937 --- /dev/null +++ b/dbm-ui/backend/tests/ticket/test_ticket_revoke.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-DB管理系统(BlueKing-BK-DBM) available. +Copyright (C) 2017-2023 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 https://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +import copy +import logging +from unittest.mock import PropertyMock, patch + +import pytest +from django.conf import settings +from rest_framework.permissions import AllowAny +from rest_framework.test import APIClient + +from backend.constants import DEFAULT_SYSTEM_USER +from backend.tests.mock_data.components.cc import CCApiMock +from backend.tests.mock_data.components.itsm import ItsmApiMock +from backend.tests.mock_data.iam_app.permission import PermissionMock +from backend.tests.mock_data.ticket.ticket_flow import MYSQL_FULL_BACKUP_TICKET_DATA, SN +from backend.ticket.builders.mysql.mysql_ha_full_backup import MySQLHaFullBackupDetailSerializer +from backend.ticket.constants import TicketStatus, TodoStatus, TodoType +from backend.ticket.flow_manager.inner import InnerFlow +from backend.ticket.handler import TicketHandler +from backend.ticket.models import Flow, Ticket +from backend.ticket.views import TicketViewSet + +logger = logging.getLogger("test") +pytestmark = pytest.mark.django_db +client = APIClient() + + +@pytest.fixture(autouse=True) # autouse=True 会自动应用这个fixture到所有的测试中 +def set_empty_middleware(): + with patch.object(settings, "MIDDLEWARE", []): + yield + + +class TestTicketRevoke: + """ + 测试单据终止 + """ + + @patch.object(TicketViewSet, "permission_classes") + @patch.object(MySQLHaFullBackupDetailSerializer, "validate") + @patch.object(InnerFlow, "status", new_callable=PropertyMock) + @patch.object(TicketViewSet, "get_permissions", lambda x: []) + @patch("backend.ticket.flow_manager.itsm.ItsmApi", ItsmApiMock()) + @patch("backend.db_services.cmdb.biz.CCApi", CCApiMock()) + @patch("backend.db_services.cmdb.biz.Permission", PermissionMock) + def test_ticket_revoke( + self, mocked_status, mocked_validate, mocked_permission_classes, query_fixture, db, init_app + ): + # 以全库备份为例,测试流程:start --> itsm --> inner --> end + mocked_status.return_value = TicketStatus.SUCCEEDED + mocked_permission_classes.return_value = [AllowAny] + mocked_validate.return_value = MYSQL_FULL_BACKUP_TICKET_DATA + + client.login(username="admin") + # 创建单据 + sql_import_data = copy.deepcopy(MYSQL_FULL_BACKUP_TICKET_DATA) + ticket = client.post("/apis/tickets/", data=sql_import_data).data + + # 在todo流程终止 + current_flow = Flow.objects.filter(flow_obj_id=SN).first() + client.post(f"/apis/tickets/{current_flow.ticket_id}/callback/") + TicketHandler.revoke_ticket(ticket_ids=[ticket["id"]], operator=DEFAULT_SYSTEM_USER) + # 验证单据和todo已经终止 + revoke_ticket = Ticket.objects.get(id=ticket["id"]) + assert revoke_ticket.status == TicketStatus.TERMINATED + assert revoke_ticket.todo_of_ticket.filter(type=TodoType.APPROVE)[0].status == TodoStatus.DONE_FAILED diff --git a/dbm-ui/backend/ticket/builders/mysql/mysql_ha_full_backup.py b/dbm-ui/backend/ticket/builders/mysql/mysql_ha_full_backup.py index 8776c658a6..ae152a99dc 100644 --- a/dbm-ui/backend/ticket/builders/mysql/mysql_ha_full_backup.py +++ b/dbm-ui/backend/ticket/builders/mysql/mysql_ha_full_backup.py @@ -64,5 +64,4 @@ class MySQLHaFullBackupFlowParamBuilder(builders.FlowParamBuilder): class MySQLHaFullBackupFlowBuilder(BaseMySQLHATicketFlowBuilder): serializer = MySQLHaFullBackupDetailSerializer inner_flow_builder = MySQLHaFullBackupFlowParamBuilder - inner_flow_name = _("全库备份执行") - retry_type = FlowRetryType.MANUAL_RETRY + retry_type = FlowRetryType.AUTO_RETRY diff --git a/dbm-ui/backend/ticket/constants.py b/dbm-ui/backend/ticket/constants.py index e72dad6775..7f934e7178 100644 --- a/dbm-ui/backend/ticket/constants.py +++ b/dbm-ui/backend/ticket/constants.py @@ -12,7 +12,7 @@ from django.utils.translation import ugettext_lazy as _ -from backend.configuration.constants import DBType, SystemSettingsEnum +from backend.configuration.constants import DBType from backend.db_meta.exceptions import ClusterExclusiveOperateException from backend.flow.consts import StateType from backend.ticket.exceptions import TicketBaseException @@ -29,7 +29,9 @@ class TodoType(str, StructuredEnum): 待办类型 """ + ITSM = EnumField("ITSM", _("主流程-单据审批")) APPROVE = EnumField("APPROVE", _("主流程-人工确认")) + INNER_FAILED = EnumField("INNER_FAILED", _("主流程-失败后待确认")) INNER_APPROVE = EnumField("INNER_APPROVE", _("自动化流程-人工确认")) RESOURCE_REPLENISH = EnumField("RESOURCE_REPLENISH", _("资源补货")) @@ -39,19 +41,24 @@ class CountType(str, StructuredEnum): 单据计数类型 """ - MY_TODO = EnumField("MY_TODO", _("我的待办")) MY_APPROVE = EnumField("MY_APPROVE", _("我的申请")) + APPROVE = EnumField("APPROVE", _("待我审批")) + TODO = EnumField("TODO", _("待我确认执行")) + INNER_TODO = EnumField("INNER_TODO", _("待我继续")) + RESOURCE_REPLENISH = EnumField("RESOURCE_REPLENISH", _("待我补货")) + FAILED = EnumField("FAILED", _("失败待处理")) + DONE = EnumField("DONE", _("我的已办")) + SELF_MANAGE = EnumField("SELF_MANAGE", _("我负责的业务")) class TodoStatus(str, StructuredEnum): """ 待办状态枚举 - TODO -> (RUNNING,可选) -> DONE_SUCCESS - | -> DONE_FAILED + TODO -> (RUNNING,可选) -> DONE_SUCCESS + | -> DONE_FAILED """ TODO = EnumField("TODO", _("待处理")) - RUNNING = EnumField("RUNNING", _("处理中")) DONE_SUCCESS = EnumField("DONE_SUCCESS", _("已处理")) DONE_FAILED = EnumField("DONE_FAILED", _("已终止")) @@ -68,18 +75,44 @@ class ResourceApplyErrCode(int, StructuredEnum): TODO_DONE_STATUS = [TodoStatus.DONE_SUCCESS, TodoStatus.DONE_FAILED] -TODO_RUNNING_STATUS = [TodoStatus.TODO, TodoStatus.RUNNING] +TODO_RUNNING_STATUS = [TodoStatus.TODO] class TicketStatus(str, StructuredEnum): """单据状态枚举""" PENDING = EnumField("PENDING", _("等待中")) + APPROVE = EnumField("APPROVE", _("待审批")) + RESOURCE_REPLENISH = EnumField("RESOURCE_REPLENISH", _("待补货")) + TODO = EnumField("TODO", _("待执行")) + TIMER = EnumField("TIMER", _("定时中")) RUNNING = EnumField("RUNNING", _("执行中")) - SUCCEEDED = EnumField("SUCCEEDED", _("成功")) + SUCCEEDED = EnumField("SUCCEEDED", _("已完成")) FAILED = EnumField("FAILED", _("失败")) - REVOKED = EnumField("REVOKED", _("撤销")) - TERMINATED = EnumField("TERMINATED", _("终止")) + REVOKED = EnumField("REVOKED", _("已撤销")) + TERMINATED = EnumField("TERMINATED", _("已终止")) + # 仅展示,不参与状态流转,不落地db + INNER_TODO = EnumField("INNER_TODO", _("待继续")) + + +# 单据[正在进行]的状态合集 +TICKET_RUNNING_STATUS = [ + TicketStatus.APPROVE, + TicketStatus.TODO, + TicketStatus.RESOURCE_REPLENISH, + TicketStatus.RUNNING, + TicketStatus.TIMER, +] +# 单据[包含TODO]的状态合集 +TICKET_TODO_STATUS = [ + TicketStatus.APPROVE, + TicketStatus.TODO, + TicketStatus.RESOURCE_REPLENISH, + TicketStatus.FAILED, + TicketStatus.RUNNING, +] +# 单据[失败]的状态合集 +TICKET_FAILED_STATUS = [TicketStatus.REVOKED, TicketStatus.TERMINATED, TicketStatus.FAILED] class TicketFlowStatus(str, StructuredEnum): @@ -94,8 +127,8 @@ class TicketFlowStatus(str, StructuredEnum): SKIPPED = EnumField("SKIPPED", _("跳过")) -FLOW_FINISHED_STATUS = [TicketFlowStatus.SKIPPED, TicketStatus.SUCCEEDED] -FLOW_NOT_EXECUTE_STATUS = [TicketFlowStatus.SKIPPED, TicketStatus.PENDING] +FLOW_FINISHED_STATUS = [TicketFlowStatus.SKIPPED, TicketFlowStatus.SUCCEEDED] +FLOW_NOT_EXECUTE_STATUS = [TicketFlowStatus.SKIPPED, TicketFlowStatus.PENDING] BAMBOO_STATE__TICKET_STATE_MAP = { StateType.FINISHED.value: TicketFlowStatus.SUCCEEDED.value, @@ -167,6 +200,12 @@ def get_cluster_type_by_ticket(cls, ticket_type): raise TicketBaseException(_("无法找到{}关联的集群类型").format(ticket_type)) return builder.cluster_types + @classmethod + def get_approve_mode_by_ticket(cls, ticket_type): + if ticket_type in [cls.MYSQL_ACCOUNT_RULE_CHANGE, cls.TENDBCLUSTER_ACCOUNT_RULE_CHANGE]: + return ItsmApproveMode.CounterSign.value + return ItsmApproveMode.OrSign.value + # fmt: off # MYSQL MYSQL_SINGLE_APPLY = TicketEnumField("MYSQL_SINGLE_APPLY", _("MySQL 单节点部署"), register_iam=False) @@ -214,7 +253,7 @@ def get_cluster_type_by_ticket(cls, ticket_type): MYSQL_SLAVE_MIGRATE_UPGRADE = TicketEnumField("MYSQL_SLAVE_MIGRATE_UPGRADE", _("MySQL Slave 迁移升级"), _("版本升级")) MYSQL_RO_SLAVE_UNINSTALL = TicketEnumField("MYSQL_RO_SLAVE_UNINSTALL", _("MySQL非stanby slave下架"), _("集群维护")) MYSQL_PROXY_UPGRADE = TicketEnumField("MYSQL_PROXY_UPGRADE", _("MySQL Proxy升级"), _("版本升级")) - MYSQL_HA_TRANSFER_TO_OTHER_BIZ = TicketEnumField("MYSQL_HA_TRANSFER_TO_OTHER_BIZ", _("TendbHA集群迁移至其他业务"), register_iam=False) # noqa + MYSQL_HA_TRANSFER_TO_OTHER_BIZ = TicketEnumField("MYSQL_HA_TRANSFER_TO_OTHER_BIZ", _("TendbHA集群迁移至其他业务"), register_iam=False) # noqa MYSQL_PUSH_PERIPHERAL_CONFIG = TicketEnumField("MYSQL_PUSH_PERIPHERAL_CONFIG", _("推送周边配置"), register_iam=False) MYSQL_ACCOUNT_RULE_CHANGE = TicketEnumField("MYSQL_ACCOUNT_RULE_CHANGE", _("MySQL 授权规则变更"), register_iam=False) @@ -413,10 +452,8 @@ def get_cluster_type_by_ticket(cls, ticket_type): RIAK_CLUSTER_MIGRATE = TicketEnumField("RIAK_CLUSTER_MIGRATE", _("Riak 集群迁移"), _("集群管理")) # MONGODB - MONGODB_REPLICASET_APPLY = TicketEnumField("MONGODB_REPLICASET_APPLY", _("MongoDB 副本集集群部署"), - register_iam=False) # noqa - MONGODB_SHARD_APPLY = TicketEnumField("MONGODB_SHARD_APPLY", _("MongoDB 分片集群部署"), _("集群管理"), - register_iam=False) # noqa + MONGODB_REPLICASET_APPLY = TicketEnumField("MONGODB_REPLICASET_APPLY", _("MongoDB 副本集集群部署"), register_iam=False) # noqa + MONGODB_SHARD_APPLY = TicketEnumField("MONGODB_SHARD_APPLY", _("MongoDB 分片集群部署"), _("集群管理"), register_iam=False) # noqa MONGODB_EXEC_SCRIPT_APPLY = TicketEnumField("MONGODB_EXEC_SCRIPT_APPLY", _("MongoDB 变更脚本执行"), _("脚本任务")) MONGODB_REMOVE_NS = TicketEnumField("MONGODB_REMOVE_NS", _("MongoDB 清档"), _("数据处理")) MONGODB_FULL_BACKUP = TicketEnumField("MONGODB_FULL_BACKUP", _("MongoDB 全库备份"), _("备份")) @@ -432,7 +469,7 @@ def get_cluster_type_by_ticket(cls, ticket_type): MONGODB_DESTROY = TicketEnumField("MONGODB_DESTROY", _("MongoDB 集群删除"), _("集群管理")) MONGODB_CUTOFF = TicketEnumField("MONGODB_CUTOFF", _("MongoDB 整机替换"), _("集群维护")) MONGODB_AUTHORIZE_RULES = TicketEnumField("MONGODB_AUTHORIZE_RULES", _("MongoDB 授权"), _("权限管理")) - MONGODB_EXCEL_AUTHORIZE_RULES = TicketEnumField("MONGODB_EXCEL_AUTHORIZE_RULES", _("MongoDB Excel授权"), _("权限管理")) # noqa + MONGODB_EXCEL_AUTHORIZE_RULES = TicketEnumField("MONGODB_EXCEL_AUTHORIZE_RULES", _("MongoDB Excel授权"), _("权限管理")) # noqa MONGODB_IMPORT = TicketEnumField("MONGODB_IMPORT", _("MongoDB 数据导入"), _("集群维护")) MONGODB_RESTORE = TicketEnumField("MONGODB_RESTORE", _("MongoDB 定点回档"), _("集群维护")) MONGODB_TEMPORARY_DESTROY = TicketEnumField("MONGODB_TEMPORARY_DESTROY", _("MongoDB 临时集群销毁"), _("集群维护")) @@ -642,11 +679,7 @@ class OperateNodeActionType(str, StructuredEnum): DISTRIBUTE = EnumField("DISTRIBUTE", _("派单")) DELIVER = EnumField("DELIVER", _("转单")) TERMINATE = EnumField("TERMINATE", _("终止节点和单据")) - - -class ItsmTicketNodeEnum(str, StructuredEnum): - ApprovalOption = EnumField("审批意见", "审批意见") - Remark = EnumField("备注", "备注") + WITHDRAW = EnumField("WITHDRAW", _("撤销单据")) class ItsmApproveMode(int, StructuredEnum): @@ -654,12 +687,6 @@ class ItsmApproveMode(int, StructuredEnum): CounterSign = EnumField(1, _("会签模式")) -ITSM_FIELD_NAME__ITSM_KEY = { - ItsmTicketNodeEnum.ApprovalOption.value: SystemSettingsEnum.ITSM_APPROVAL_KEY, - ItsmTicketNodeEnum.Remark.value: SystemSettingsEnum.ITSM_REMARK_KEY, -} - - class FlowMsgType(str, StructuredEnum): DONE = EnumField(_("完成"), _("完成")) TODO = EnumField(_("待办"), _("待办")) @@ -693,3 +720,13 @@ class TicketExpireType(str, StructuredEnum): FlowType.RESOURCE_APPLY: TicketExpireType.FLOW_TODO, FlowType.RESOURCE_BATCH_APPLY: TicketExpireType.FLOW_TODO, } + +# 根据流程类型来映射单据状态 +RUNNING_FLOW__TICKET_STATUS = { + FlowType.BK_ITSM: TicketStatus.APPROVE, + FlowType.RESOURCE_APPLY: TicketStatus.RESOURCE_REPLENISH, + FlowType.RESOURCE_BATCH_APPLY: TicketStatus.RESOURCE_REPLENISH, + FlowType.PAUSE: TicketStatus.TODO, + FlowType.INNER_FLOW: TicketStatus.RUNNING, + FlowType.TIMER: TicketStatus.TIMER, +} diff --git a/dbm-ui/backend/ticket/contexts.py b/dbm-ui/backend/ticket/contexts.py index 391170d660..d0f78b3f70 100644 --- a/dbm-ui/backend/ticket/contexts.py +++ b/dbm-ui/backend/ticket/contexts.py @@ -22,9 +22,9 @@ def __init__( self.spec_map = get_spec_display_map() self.db_config = {} - bizs = list(AppCache.objects.all()) - self.biz_name_map = {biz.bk_biz_id: biz.bk_biz_name for biz in bizs} - self.app_abbr_map = {biz.bk_biz_id: biz.db_app_abbr for biz in bizs} + bizs = AppCache.get_appcache(key="appcache_dict") + self.biz_name_map = {int(bk_biz_id): biz["bk_biz_name"] for bk_biz_id, biz in bizs.items()} + self.app_abbr_map = {int(bk_biz_id): biz["db_app_abbr"] for bk_biz_id, biz in bizs.items()} db_modules = list(DBModule.objects.all()) self.db_module_map = {module.db_module_id: module.alias_name for module in db_modules} diff --git a/dbm-ui/backend/ticket/filters.py b/dbm-ui/backend/ticket/filters.py index b0961795de..5e2e9f5699 100644 --- a/dbm-ui/backend/ticket/filters.py +++ b/dbm-ui/backend/ticket/filters.py @@ -8,18 +8,21 @@ 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. """ - +from django.db.models import Q from django.utils.translation import ugettext_lazy as _ from django_filters import rest_framework as filters from backend.db_meta.models import Cluster -from backend.ticket.models import ClusterOperateRecord, Ticket +from backend.ticket.constants import TODO_RUNNING_STATUS, TicketStatus +from backend.ticket.models import ClusterOperateRecord, InstanceOperateRecord, Ticket class TicketListFilter(filters.FilterSet): + ids = filters.CharFilter(field_name="ids", method="filter_ids", label=_("单据ID列表")) remark = filters.CharFilter(field_name="remark", lookup_expr="icontains", label=_("备注")) + status = filters.CharFilter(field_name="status", method="filter_status", label=_("单据状态")) cluster = filters.CharFilter(field_name="cluster", method="filter_cluster", label=_("集群域名")) - ids = filters.CharFilter(field_name="ids", method="filter_ids", label=_("单据ID列表")) + todo = filters.CharFilter(field_name="todo", method="filter_todo", label=_("代办状态")) class Meta: model = Ticket @@ -27,7 +30,6 @@ class Meta: "id": ["exact", "in"], "bk_biz_id": ["exact"], "ticket_type": ["exact", "in"], - "status": ["exact", "in"], "create_at": ["gte", "lte"], "creator": ["exact"], } @@ -40,3 +42,55 @@ def filter_cluster(self, queryset, name, value): def filter_ids(self, queryset, name, value): ids = list(map(int, value.split(","))) return queryset.filter(id__in=ids) + + def filter_todo(self, queryset, name, value): + user = self.request.user.username + if value == "running": + todo_filter = Q(todo_of_ticket__operators__contains=user, todo_of_ticket__status__in=TODO_RUNNING_STATUS) + else: + todo_filter = Q(todo_of_ticket__done_by=user) + return queryset.filter(todo_filter).distinct() + + def filter_status(self, queryset, name, value): + status = value.split(",") + status_filter = Q() + # 如果有待确认,则解析为:running + 包含正在运行的todo + if TicketStatus.INNER_TODO in status: + status_filter |= Q(status=TicketStatus.RUNNING, todo_of_ticket__status__in=TODO_RUNNING_STATUS) + status.remove(TicketStatus.INNER_TODO.value) + # 如果有待执行,则解析为:running + 不包含正在运行的todo + if TicketStatus.RUNNING in status: + status_filter |= Q(status=TicketStatus.RUNNING) & ~Q(todo_of_ticket__status__in=TODO_RUNNING_STATUS) + status.remove(TicketStatus.RUNNING.value) + # 其他状态,直接in即可 + status_filter |= Q(status__in=status) + return queryset.filter(status_filter).distinct() + + +class OpRecordListFilter(filters.FilterSet): + start_time = filters.DateTimeFilter(field_name="create_at", lookup_expr="gte", label=_("开始时间")) + end_time = filters.DateTimeFilter(field_name="create_at", lookup_expr="lte", label=_("开始时间")) + op_type = filters.CharFilter(field_name="op_type", method="filter_op_type", label=_("操作类型")) + op_status = filters.CharFilter(field_name="op_status", method="filter_op_status", label=_("操作状态")) + + def filter_op_type(self, queryset, name, value): + return queryset.filter(ticket__ticket_type=value) + + def filter_op_status(self, queryset, name, value): + return queryset.filter(ticket__status=value) + + +class ClusterOpRecordListFilter(OpRecordListFilter): + cluster_id = filters.NumberFilter(field_name="cluster_id", lookup_expr="exact", label=_("集群ID")) + + class Meta: + model = ClusterOperateRecord + fields = ["start_time", "end_time", "op_type", "op_status"] + + +class InstanceOpRecordListFilter(OpRecordListFilter): + instance_id = filters.NumberFilter(field_name="instance_id", lookup_expr="exact", label=_("实例ID")) + + class Meta: + model = InstanceOperateRecord + fields = ["start_time", "end_time", "op_type", "op_status"] diff --git a/dbm-ui/backend/ticket/flow_manager/base.py b/dbm-ui/backend/ticket/flow_manager/base.py index 15ce0f58ff..1dc06f2e1f 100644 --- a/dbm-ui/backend/ticket/flow_manager/base.py +++ b/dbm-ui/backend/ticket/flow_manager/base.py @@ -25,12 +25,14 @@ FLOW_FINISHED_STATUS, FLOW_NOT_EXECUTE_STATUS, FLOW_TYPE__EXPIRE_TYPE_CONFIG, + TICKET_EXPIRE_DEFAULT_CONFIG, FlowContext, FlowErrCode, + FlowType, FlowTypeConfig, TicketFlowStatus, ) -from backend.ticket.models import ClusterOperateRecord, Flow, InstanceOperateRecord, TicketFlowsConfig +from backend.ticket.models import ClusterOperateRecord, Flow, InstanceOperateRecord, TicketFlowsConfig, Todo logger = logging.getLogger("root") @@ -55,10 +57,9 @@ def status(self) -> str: if self.flow_obj.err_msg: # 如果flow的状态包含错误信息,则是saas侧出错,当前的flow流程直接返回失败 # 注意:这里不能直接根据flow的状态为失败就进行返回,有可能是pipeline跳过失败的操作 - - # 如果是自动重试互斥错误,则返回RUNNING状态 - if self.flow_obj.err_code == FlowErrCode.AUTO_EXCLUSIVE_ERROR: - return TicketFlowStatus.RUNNING + # 如果是inner_flow,则进入子status处理 + if self.flow_obj.flow_type == FlowType.INNER_FLOW: + return self._status return TicketFlowStatus.FAILED @@ -70,7 +71,7 @@ def status(self) -> str: if not self.flow_obj.flow_obj_id: # 任务流程未创建时未PENDING状态 - return constants.TicketStatus.PENDING + return constants.TicketFlowStatus.PENDING # 其他情况暂时认为在PENDING状态 return TicketFlowStatus.PENDING @@ -144,10 +145,11 @@ def flush_error_status_handler(self): def flush_revoke_status_handler(self, operator): """终止节点,更新相关状态和错误信息""" self.flow_obj.status = TicketFlowStatus.TERMINATED - self.flow_obj.err_code = FlowErrCode.GENERAL_ERROR if operator == DEFAULT_SYSTEM_USER: self.flow_obj.err_code = FlowErrCode.SYSTEM_TERMINATED_ERROR self.flow_obj.context = {FlowContext.EXPIRE_TIME: self.get_current_config_expire_time()} + else: + self.flow_obj.err_code = FlowErrCode.GENERAL_ERROR self.flow_obj.save(update_fields=["status", "err_code", "context", "update_at"]) # 更新操作者 self.ticket.updater = operator @@ -157,8 +159,9 @@ def get_current_config_expire_time(self): """获取当前配置的flow过期时间""" if self.flow_obj.flow_type not in FLOW_TYPE__EXPIRE_TYPE_CONFIG: return -1 - config = TicketFlowsConfig.get_config(ticket_type=self.ticket.ticket_type) - expire_time = config[FlowTypeConfig.EXPIRE_CONFIG][FLOW_TYPE__EXPIRE_TYPE_CONFIG[self.flow_obj.flow_type]] + config = TicketFlowsConfig.get_config(ticket_type=self.ticket.ticket_type).configs + expire_config = config.get(FlowTypeConfig.EXPIRE_CONFIG, TICKET_EXPIRE_DEFAULT_CONFIG) + expire_time = expire_config[FLOW_TYPE__EXPIRE_TYPE_CONFIG[self.flow_obj.flow_type]] return expire_time def create_operate_records(self, object_key, record_model, object_ids): @@ -259,4 +262,11 @@ def _retry(self) -> Any: self.run() def _revoke(self, operator) -> Any: + # 停止相关联的todo + from backend.ticket.todos import ActionType, TodoActorFactory + + todos = Todo.objects.filter(ticket=self.ticket, flow=self.flow_obj) + for todo in todos: + TodoActorFactory.actor(todo).process(operator, ActionType.TERMINATE, params={}) + # 刷新flow和单据状态 --> 终止 self.flush_revoke_status_handler(operator) diff --git a/dbm-ui/backend/ticket/flow_manager/delivery.py b/dbm-ui/backend/ticket/flow_manager/delivery.py index f5ac54089e..c07a453210 100644 --- a/dbm-ui/backend/ticket/flow_manager/delivery.py +++ b/dbm-ui/backend/ticket/flow_manager/delivery.py @@ -84,13 +84,14 @@ def _end_time(self) -> Optional[str]: return datetime2str(self.pre_flow_tree.updated_at) @property - def _summary(self) -> str: + def _summary(self) -> dict: if self.pre_flow_tree.status in FAILED_STATES: - return _("失败后继续提交") + return {"status": TicketFlowStatus.FAILED, "message": _("失败后继续提交")} elif self.pre_flow_tree.status in SUCCEED_STATES: - return _("执行成功") + return {"status": TicketFlowStatus.SUCCEEDED, "message": _("执行成功")} else: - return _("执行{}".format(StateType.get_choice_label(self.pre_flow_tree.status))) + state = StateType.get_choice_label(self.pre_flow_tree.status) + return {"status": TicketFlowStatus.RUNNING, "message": _("执行{}".format(state))} @property def _url(self) -> str: diff --git a/dbm-ui/backend/ticket/flow_manager/inner.py b/dbm-ui/backend/ticket/flow_manager/inner.py index c4d70ad206..f3624923a9 100644 --- a/dbm-ui/backend/ticket/flow_manager/inner.py +++ b/dbm-ui/backend/ticket/flow_manager/inner.py @@ -24,9 +24,18 @@ from backend.flow.models import FlowTree from backend.ticket import constants from backend.ticket.builders.common.base import fetch_cluster_ids -from backend.ticket.constants import BAMBOO_STATE__TICKET_STATE_MAP, FlowCallbackType, TicketType +from backend.ticket.constants import ( + BAMBOO_STATE__TICKET_STATE_MAP, + FlowCallbackType, + FlowErrCode, + TicketFlowStatus, + TicketType, + TodoStatus, + TodoType, +) from backend.ticket.flow_manager.base import BaseTicketFlow -from backend.ticket.models import Flow +from backend.ticket.models import Flow, Todo +from backend.ticket.todos import BaseTodoContext from backend.utils.basic import generate_root_id from backend.utils.time import datetime2str @@ -82,22 +91,47 @@ def _end_time(self) -> Union[str, datetime]: @property def _summary(self) -> str: # TODO 可以给出具体失败的节点和原因 - return _("任务{status_display}").format(status_display=constants.TicketStatus.get_choice_label(self.status)) + return _("任务{status_display}").format(status_display=constants.TicketFlowStatus.get_choice_label(self.status)) @property def _status(self) -> str: - # 如果未找到流程树,则直接取flow_obj的status + # 如果是自动重试互斥错误,则返回RUNNING状态 + if self.flow_obj.err_msg and self.flow_obj.err_code == FlowErrCode.AUTO_EXCLUSIVE_ERROR: + return TicketFlowStatus.RUNNING + + # 查询流程树状态,如果未找到则直接取flow_obj的status if not self.flow_tree: - return self.flow_obj.status + status = self.flow_obj.status + else: + status = BAMBOO_STATE__TICKET_STATE_MAP.get(self.flow_tree.status, constants.TicketFlowStatus.RUNNING) - status = BAMBOO_STATE__TICKET_STATE_MAP.get(self.flow_tree.status, constants.TicketStatus.RUNNING) - self.flow_obj.update_status(status) - return status + todo_status = TodoStatus.TODO if status == TicketFlowStatus.FAILED else TodoStatus.DONE_SUCCESS + fail_todo = self.flow_obj.todo_of_flow.filter(type=TodoType.INNER_FAILED).first() + # 如果任务失败,且不存在todo,则创建一条 + if not fail_todo and todo_status == TodoStatus.TODO: + self.create_failed_todo() + # 变更todo状态 + if fail_todo and fail_todo.status != todo_status: + fail_todo.set_status(self.ticket.creator, todo_status) + + return self.flow_obj.update_status(status) @property def _url(self) -> str: return f"{env.BK_SAAS_HOST}/{self.ticket.bk_biz_id}/task-history/detail/{self.root_id}" + def create_failed_todo(self): + # 创建一条todo失败记录,在失败时变更为TODO状态 + Todo.objects.create( + name=_("【{}】单据任务执行失败,待处理").format(self.ticket.get_ticket_type_display()), + flow=self.flow_obj, + ticket=self.ticket, + type=TodoType.INNER_FAILED, + operators=[self.ticket.creator], + context=BaseTodoContext(self.flow_obj.id, self.ticket.id).to_dict(), + status=TodoStatus.DONE_SUCCESS, + ) + def check_exclusive_operations(self): """判断执行互斥""" # TODO: 目前来说,执行互斥对于同时提单或者同时重试的操作是防不住的。 @@ -122,10 +156,6 @@ def check_exclusive_operations(self): cluster_ids=cluster_ids, ticket_type=ticket_type, exclude_ticket_ids=[self.ticket.id] ) - def handle_exclusive_error(self): - """处理执行互斥后重试的逻辑""" - pass - def callback(self, callback_type: FlowCallbackType) -> None: """ inner节点独有的钩子函数,执行前置/后继流程节点动作 @@ -184,6 +214,15 @@ def _retry(self) -> Any: ) super()._retry() + def _revoke(self, operator) -> Any: + # 终止运行的pipeline + from backend.db_services.taskflow.handlers import TaskFlowHandler + + if FlowTree.objects.filter(root_id=self.flow_obj.flow_obj_id).exists(): + TaskFlowHandler(self.flow_obj.flow_obj_id).revoke_pipeline() + # 流转flow的终止状态 + super()._revoke(operator) + class QuickInnerFlow(InnerFlow): """ @@ -193,7 +232,7 @@ class QuickInnerFlow(InnerFlow): @property def _status(self) -> str: - return constants.TicketStatus.SUCCEEDED + return constants.TicketFlowStatus.SUCCEEDED @property def _summary(self) -> str: @@ -218,7 +257,7 @@ class IgnoreResultInnerFlow(InnerFlow): @property def _summary(self) -> str: return _("(执行结果可忽略)任务状态: {status_display}").format( - status_display=constants.TicketStatus.get_choice_label(self._raw_status) + status_display=constants.TicketFlowStatus.get_choice_label(self._raw_status) ) @property @@ -228,7 +267,7 @@ def _raw_status(self) -> str: @property def _status(self) -> str: status = self._raw_status - if status in [constants.TicketStatus.SUCCEEDED, constants.TicketStatus.REVOKED, constants.TicketStatus.FAILED]: - return constants.TicketStatus.SUCCEEDED + if status in [constants.TicketFlowStatus.SUCCEEDED, *constants.TICKET_FAILED_STATUS]: + return constants.TicketFlowStatus.SUCCEEDED return status diff --git a/dbm-ui/backend/ticket/flow_manager/itsm.py b/dbm-ui/backend/ticket/flow_manager/itsm.py index ff7d0d397a..a62dadc3dc 100644 --- a/dbm-ui/backend/ticket/flow_manager/itsm.py +++ b/dbm-ui/backend/ticket/flow_manager/itsm.py @@ -16,10 +16,11 @@ from backend.components import ItsmApi from backend.components.itsm.constants import ItsmTicketStatus from backend.exceptions import ApiResultError -from backend.ticket.constants import FlowMsgStatus, FlowMsgType, TicketFlowStatus, TicketStatus +from backend.ticket.constants import FlowMsgStatus, FlowMsgType, TicketFlowStatus, TicketStatus, TodoStatus, TodoType from backend.ticket.flow_manager.base import BaseTicketFlow -from backend.ticket.models import Flow +from backend.ticket.models import Flow, Todo from backend.ticket.tasks.ticket_tasks import send_msg_for_flow +from backend.ticket.todos.itsm_todo import ItsmTodoContext from backend.utils.time import datetime2str, standardized_time_str @@ -56,24 +57,25 @@ def _end_time(self) -> Union[datetime, Any]: return self.flow_obj.update_at @property - def _summary(self) -> str: + def _summary(self) -> dict: try: logs = ItsmApi.get_ticket_logs({"sn": [self.flow_obj.flow_obj_id]}) except ApiResultError: return _("未知单据") + + # 获取单据审批状态 + current_status = self.ticket_approval_result["current_status"] + approve_result = self.ticket_approval_result["approve_result"] + summary = {"status": current_status, "approve_result": approve_result} + # 目前审批流程是固定的,取流程中第三个节点的日志作为概览即可 try: - return logs["logs"][2]["message"] + summary.update(operator=logs["logs"][2]["operator"], message=logs["logs"][2]["message"]) except (IndexError, KeyError): # 异常时根据状态取默认的概览 - status_summary_map = { - TicketStatus.RUNNING.value: _("审批中"), - TicketStatus.SUCCEEDED.value: _("已通过"), - TicketStatus.REVOKED.value: _("已撤销"), - TicketStatus.FAILED.value: _("被拒绝"), - TicketStatus.TERMINATED.value: _("已终止"), - } - return status_summary_map.get(self.status, "") + msg = TicketStatus.get_choice_label(self.status) + summary.update(operator=logs["logs"][-1]["operator"], status=self.status, message=msg) + return summary @property def _status(self) -> str: @@ -85,15 +87,19 @@ def _status(self) -> str: return self.flow_obj.update_status(TicketFlowStatus.RUNNING) # 撤单 elif current_status == ItsmTicketStatus.REVOKED: + self.flow_obj.todo_of_flow.update(status=TodoStatus.DONE_FAILED) return self.flow_obj.update_status(TicketFlowStatus.TERMINATED) # 审批通过 elif current_status == ItsmTicketStatus.FINISHED and approve_result: + self.flow_obj.todo_of_flow.update(status=TodoStatus.DONE_SUCCESS) return self.flow_obj.update_status(TicketFlowStatus.SUCCEEDED) # 审批拒绝 elif current_status == ItsmTicketStatus.FINISHED and not approve_result: + self.flow_obj.todo_of_flow.update(status=TodoStatus.DONE_FAILED) return self.flow_obj.update_status(TicketFlowStatus.TERMINATED) # 终止 elif current_status == ItsmTicketStatus.TERMINATED: + self.flow_obj.todo_of_flow.update(status=TodoStatus.DONE_FAILED) return self.flow_obj.update_status(TicketFlowStatus.TERMINATED) @property @@ -104,9 +110,20 @@ def _url(self) -> str: return "" def _run(self) -> str: + itsm_fields = {f["key"]: f["value"] for f in self.flow_obj.details["fields"]} + # 创建审批todo + operators = itsm_fields["approver"].split(",") + Todo.objects.create( + name=_("【{}】单据等待审批").format(self.ticket.get_ticket_type_display()), + flow=self.flow_obj, + ticket=self.ticket, + type=TodoType.ITSM, + operators=operators, + context=ItsmTodoContext(self.flow_obj.id, self.ticket.id).to_dict(), + ) + # 创建单据 data = ItsmApi.create_ticket(self.flow_obj.details) # 异步发送待审批消息 - itsm_fields = {f["key"]: f["value"] for f in self.flow_obj.details["fields"]} send_msg_for_flow.apply_async( kwargs={ "flow_id": self.flow_obj.id, @@ -117,3 +134,7 @@ def _run(self) -> str: } ) return data["sn"] + + def _revoke(self, operator) -> Any: + # 父类通过触发todo的终止可以终止itsm单据 + super()._revoke(operator) diff --git a/dbm-ui/backend/ticket/flow_manager/manager.py b/dbm-ui/backend/ticket/flow_manager/manager.py index c12118d2f5..8ac86ace95 100644 --- a/dbm-ui/backend/ticket/flow_manager/manager.py +++ b/dbm-ui/backend/ticket/flow_manager/manager.py @@ -89,23 +89,26 @@ def run_next_flow(self): def update_ticket_status(self): # 获取流程状态集合 - statuses = { - self.get_ticket_flow_cls(flow_type=flow.flow_type)(flow).status for flow in self.ticket.flows.all() + flow_status_map = { + self.get_ticket_flow_cls(flow_type=flow.flow_type)(flow).status: flow for flow in self.ticket.flows.all() } + statuses = set(flow_status_map.keys()) logger.info(f"update_ticket_status for ticket:{self.ticket.id}, statuses: {statuses}") + # 只要存在其中一个终止,则单据状态为已终止 if constants.TicketFlowStatus.TERMINATED in statuses: - # 只要存在其中一个终止,则单据状态为已终止 target_status = constants.TicketStatus.TERMINATED + # 只要存在其中一个失败,则单据状态为失败态 elif constants.TicketFlowStatus.FAILED in statuses: - # 只要存在其中一个失败,则单据状态为失败态 target_status = constants.TicketStatus.FAILED + # 只要存在其中一个撤销,则单据状态为撤销态 elif constants.TicketFlowStatus.REVOKED in statuses: - # 只要存在其中一个撤销,则单据状态为撤销态 target_status = constants.TicketStatus.REVOKED + # 只要有一个存在running,则需要根据flow的type决定单据的状态 elif constants.TicketFlowStatus.RUNNING in statuses: - target_status = constants.TicketStatus.RUNNING + flow = flow_status_map[constants.TicketFlowStatus.RUNNING] + target_status = constants.RUNNING_FLOW__TICKET_STATUS.get(flow.flow_type, constants.TicketStatus.RUNNING) + # 如果所有flow的状态处于完成态,则单据为成功 elif statuses.issubset(set(FLOW_FINISHED_STATUS)): - # 如果所有flow的状态处于完成态,则单据为成功 target_status = constants.TicketStatus.SUCCEEDED else: # 其他场景下状态未变更,无需更新DB diff --git a/dbm-ui/backend/ticket/flow_manager/pause.py b/dbm-ui/backend/ticket/flow_manager/pause.py index 1c1bb03a80..b643e87b9f 100644 --- a/dbm-ui/backend/ticket/flow_manager/pause.py +++ b/dbm-ui/backend/ticket/flow_manager/pause.py @@ -40,16 +40,15 @@ def _end_time(self) -> Optional[str]: @property def _summary(self) -> str: - return _("暂停状态{status_display}").format(status_display=constants.TicketStatus.get_choice_label(self.status)) + return _("暂停状态{status_display}").format( + status_display=constants.TicketFlowStatus.get_choice_label(self.status) + ) @property def _status(self) -> str: if self.ticket.todo_of_ticket.exist_unfinished(): - self.flow_obj.update_status(constants.TicketFlowStatus.RUNNING) - return constants.TicketStatus.RUNNING.value - - self.flow_obj.update_status(constants.TicketFlowStatus.SUCCEEDED) - return constants.TicketStatus.SUCCEEDED.value + return self.flow_obj.update_status(constants.TicketFlowStatus.RUNNING) + return self.flow_obj.update_status(constants.TicketFlowStatus.SUCCEEDED) @property def _url(self) -> str: diff --git a/dbm-ui/backend/ticket/flow_manager/resource.py b/dbm-ui/backend/ticket/flow_manager/resource.py index ee3b2f6012..bceb1d6651 100644 --- a/dbm-ui/backend/ticket/flow_manager/resource.py +++ b/dbm-ui/backend/ticket/flow_manager/resource.py @@ -58,7 +58,9 @@ def _end_time(self) -> Optional[str]: @property def _summary(self) -> str: - return _("资源申请状态{status_display}").format(status_display=constants.TicketStatus.get_choice_label(self.status)) + return _("资源申请状态{status_display}").format( + status_display=constants.TicketFlowStatus.get_choice_label(self.status) + ) @property def status(self) -> str: @@ -73,24 +75,24 @@ def update_flow_status(self, status): def _status(self) -> str: # 任务流程未创建时未PENDING状态 if not self.flow_obj.flow_obj_id: - return self.update_flow_status(constants.TicketStatus.PENDING.value) + return self.update_flow_status(constants.TicketFlowStatus.PENDING) # 如果资源申请成功,则直接返回success if self.resource_apply_status: - return self.update_flow_status(constants.TicketStatus.SUCCEEDED.value) + return self.update_flow_status(constants.TicketFlowStatus.SUCCEEDED) if self.flow_obj.err_msg: # 如果是其他情况引起的错误,则直接返回fail if not self.flow_obj.todo_of_flow.exists(): - return self.update_flow_status(constants.TicketStatus.FAILED.value) + return self.update_flow_status(constants.TicketFlowStatus.FAILED) # 如果是资源申请的todo状态,则判断todo是否完成 if self.ticket.todo_of_ticket.exist_unfinished(): - return self.update_flow_status(constants.TicketStatus.RUNNING.value) + return self.update_flow_status(constants.TicketFlowStatus.RUNNING) else: - return self.update_flow_status(constants.TicketStatus.SUCCEEDED.value) + return self.flow_obj.status # 其他情况认为还在RUNNING状态 - return self.update_flow_status(constants.TicketStatus.RUNNING.value) + return self.update_flow_status(constants.TicketFlowStatus.RUNNING) @property def _url(self) -> str: diff --git a/dbm-ui/backend/ticket/flow_manager/timer.py b/dbm-ui/backend/ticket/flow_manager/timer.py index 6031cd1f94..8dfdae4d8d 100644 --- a/dbm-ui/backend/ticket/flow_manager/timer.py +++ b/dbm-ui/backend/ticket/flow_manager/timer.py @@ -56,7 +56,7 @@ def _summary(self) -> str: return _("定时时间{},已超时{},需手动触发。暂停状态:{}").format( self.trigger_time, countdown2str(run_time - trigger_time), - constants.TicketStatus.get_choice_label(self.status), + constants.TicketFlowStatus.get_choice_label(self.status), ) now = datetime.now(timezone.utc) @@ -68,19 +68,17 @@ def _summary(self) -> str: @property def _status(self) -> str: trigger_time = str2datetime(self.trigger_time) + # 还未到定时节点,返回pending if self.expired_flag is None: - return constants.TicketStatus.PENDING.value - + return constants.TicketFlowStatus.PENDING.value + # 已过期,但是todo未处理,则返回running if self.expired_flag and self.ticket.todo_of_ticket.exist_unfinished(): - self.flow_obj.update_status(constants.TicketStatus.RUNNING.value) - return constants.TicketStatus.RUNNING.value - + return self.flow_obj.update_status(constants.TicketFlowStatus.RUNNING.value) + # 触发时间晚于当前时间,则返回running if trigger_time > datetime.now(timezone.utc): - self.flow_obj.update_status(constants.TicketStatus.RUNNING.value) - return constants.TicketStatus.RUNNING.value - - self.flow_obj.update_status(constants.TicketStatus.SUCCEEDED.value) - return constants.TicketStatus.SUCCEEDED.value + return self.flow_obj.update_status(constants.TicketFlowStatus.RUNNING.value) + # 其他情况说明已触发,返回succeed + return self.flow_obj.update_status(constants.TicketFlowStatus.SUCCEEDED.value) @property def _url(self) -> str: diff --git a/dbm-ui/backend/ticket/handler.py b/dbm-ui/backend/ticket/handler.py index 010f7f8a14..9800c5fb0a 100644 --- a/dbm-ui/backend/ticket/handler.py +++ b/dbm-ui/backend/ticket/handler.py @@ -11,6 +11,7 @@ import itertools import json import logging +import time from typing import Dict, List from django.db import transaction @@ -28,17 +29,22 @@ from backend.ticket.builders.common.base import fetch_cluster_ids, fetch_instance_ids from backend.ticket.constants import ( FLOW_FINISHED_STATUS, - ITSM_FIELD_NAME__ITSM_KEY, + RUNNING_FLOW__TICKET_STATUS, FlowType, FlowTypeConfig, OperateNodeActionType, TicketFlowStatus, + TicketStatus, TicketType, + TodoStatus, + TodoType, ) from backend.ticket.exceptions import TicketFlowsConfigException from backend.ticket.flow_manager.manager import TicketFlowManager from backend.ticket.models import Flow, Ticket, TicketFlowsConfig, Todo -from backend.ticket.todos import ActionType, TodoActorFactory +from backend.ticket.serializers import TodoSerializer +from backend.ticket.todos import BaseTodoContext, TodoActorFactory +from backend.ticket.todos.itsm_todo import ItsmTodoContext logger = logging.getLogger("root") @@ -200,24 +206,20 @@ def ticket_flow_config_init(cls): TicketFlowsConfig.objects.bulk_create(created_configs) @classmethod - def get_itsm_fields(cls, sample_sn=None): + def get_itsm_fields(cls, ticket_type): """获取单据审批需要的itsm字段""" + # 根据单据类型决定审批模式 + approve_mode = str(TicketType.get_approve_mode_by_ticket(ticket_type)) # 预先获取审批接口的field的审批意见和备注的key approval_key = SystemSettings.get_setting_value(key=SystemSettingsEnum.ITSM_APPROVAL_KEY) remark_key = SystemSettings.get_setting_value(key=SystemSettingsEnum.ITSM_REMARK_KEY) - - # 如果未入库,则获取任意一个ticket的信息来初始化key - if not approval_key or not remark_key: - ticket_info_response = ItsmApi.get_ticket_info(params={"sn": sample_sn}) - for field in ticket_info_response["fields"]: - SystemSettings.insert_setting_value(key=ITSM_FIELD_NAME__ITSM_KEY[field["name"]], value=field["key"]) - - return {SystemSettingsEnum.ITSM_APPROVAL_KEY: approval_key, SystemSettingsEnum.ITSM_REMARK_KEY: remark_key} + return approval_key[approve_mode], remark_key[approve_mode] @classmethod def approve_itsm_ticket(cls, ticket_id, action, operator, **kwargs): """审批 / 终止itsm中的单据""" - sn = Flow.objects.get(ticket_id=ticket_id, flow_type="BK_ITSM").flow_obj_id + flow = Flow.objects.get(ticket_id=ticket_id, flow_type="BK_ITSM") + sn = flow.flow_obj_id itsm_info = ItsmApi.get_ticket_info(params={"sn": sn}) # 当前没有正在进行的步骤,退出 @@ -225,16 +227,23 @@ def approve_itsm_ticket(cls, ticket_id, action, operator, **kwargs): return state_id = itsm_info["current_steps"][0]["state_id"] + act_msg_tpl = _("{}对单据{}操作: {}").format(operator, ticket_id, OperateNodeActionType.get_choice_label(action)) + act_msg = kwargs.get("action_message") or act_msg_tpl + # 审批单据 + params = {"action_message": act_msg} if action == OperateNodeActionType.TRANSITION: is_approved = kwargs["is_approved"] - fields = [{"key": field, "value": json.dumps(is_approved)} for field in cls.get_itsm_fields(sn).values()] - params = {"sn": sn, "state_id": state_id, "action_type": action, "operator": operator, "fields": fields} + itsm_fields = cls.get_itsm_fields(flow.ticket.ticket_type) + fields = [ + {"key": itsm_fields[0], "value": json.dumps(is_approved)}, + {"key": itsm_fields[1], "value": act_msg}, + ] + params.update(sn=sn, state_id=state_id, action_type=action, operator=operator, fields=fields) ItsmApi.operate_node(params, use_admin=True) - # 终止单据 - elif action == OperateNodeActionType.TERMINATE: - action_message = _("{} 终止了此单据").format(operator) - params = {"sn": sn, "action_type": action, "operator": operator, "action_message": action_message} + # 终止/撤销单据 + elif action in [OperateNodeActionType.TERMINATE, OperateNodeActionType.WITHDRAW]: + params.update(sn=sn, action_type=action, operator=operator) ItsmApi.operate_ticket(params, use_admin=True) return sn @@ -255,8 +264,8 @@ def revoke_ticket(cls, ticket_ids, operator): - 找到第一个非成功的flow 设置为终止 - 如果有关联正在运行的todos,也设置为终止 """ - # 查询ticket,关联正在运行的flows(这里定义的"运行"指的就是非成功) - finished_status = [*FLOW_FINISHED_STATUS, Flow, TicketFlowStatus.TERMINATED] + # 查询ticket,关联正在运行的flows(这里定义的"运行"指的就是非成功/终止/撤销) + finished_status = [*FLOW_FINISHED_STATUS, TicketFlowStatus.TERMINATED, TicketFlowStatus.REVOKED] running_flows = Flow.objects.filter(ticket__in=ticket_ids).exclude(status__in=finished_status) tickets = Ticket.objects.prefetch_related( Prefetch("flows", queryset=running_flows, to_attr="running_flows") @@ -265,22 +274,28 @@ def revoke_ticket(cls, ticket_ids, operator): # 对每个单据进行终止 for ticket in tickets: if not ticket.running_flows: - logger.info(_("单据[{}]没有需要终止的流程,跳过...").format(ticket.id)) continue - first_running_flow = ticket.running_flows[0] - # 如果有todo,则把所有todo终止 - todos = Todo.objects.filter(ticket=ticket, flow=first_running_flow) - for todo in todos: - TodoActorFactory.actor(todo).process(operator, ActionType.TERMINATE, params={}) + first_running_flow = ticket.running_flows[0] + cls.operate_flow(ticket.id, first_running_flow.id, func="revoke", operator=operator) + logger.info(_("操作人[{}]终止了单据[{}]").format(operator, ticket.id)) - # 如果是处于审批阶段,需要关闭itsm单据 - if first_running_flow.flow_type == FlowType.BK_ITSM: - cls.approve_itsm_ticket(ticket.id, OperateNodeActionType.TERMINATE, "admin", is_approved=False) + @classmethod + def batch_process_todo(cls, user, action, operations): + """ + 批量操作todo + @param user 用户 + @param action 动作 + @param operations: todo列表,每个item包含todo id和params + """ - # 用户终止 / 系统终止flow - logger.info(_("操作人[{}]终止了单据[{}]").format(operator, ticket.id)) - cls.operate_flow(ticket.id, first_running_flow.id, func="revoke", operator=operator) + results = [] + for operation in operations: + todo_id, params = operation["todo_id"], operation["params"] + todo = Todo.objects.get(id=todo_id) + TodoActorFactory.actor(todo).process(user, action, params) + results.append(todo) + return TodoSerializer(results, many=True).data @classmethod def create_ticket_flow_config(cls, bk_biz_id, cluster_ids, ticket_types, configs, operator): @@ -400,3 +415,60 @@ def query_ticket_flows_describe(cls, bk_biz_id, db_type, ticket_types=None): flow_desc_list.append(flow_config_info) return flow_desc_list + + @classmethod + def ticket_status_standardization(cls): + """ + 旧单据状态标准化。TODO: 迁移后此段代码可删除 + """ + batch = 50 + + # 标准化只针对running的单据,其他状态单据不影响 + running_tickets = Ticket.objects.filter(status=TicketStatus.RUNNING) + count = running_tickets.count() + for current in range(0, count, batch): + for ticket in running_tickets[current : current + batch]: + raw_status = ticket.status + ticket.status = RUNNING_FLOW__TICKET_STATUS[ticket.current_flow().flow_type] + ticket.save() + print(f"ticket[{ticket.id}] status {raw_status} ---> {ticket.status}") + time.sleep(1) + + # 失败的单据要增加一条todo关联 + failed_tickets = Ticket.objects.prefetch_related("flows").filter(status=TicketStatus.FAILED) + for current in range(0, count, batch): + for ticket in failed_tickets[current : current + batch]: + inner_flow = ticket.flows.filter(flow_type=FlowType.INNER_FLOW, status=TicketFlowStatus.FAILED).first() + if not inner_flow or inner_flow.todo_of_flow.exists(): + continue + Todo.objects.create( + name=_("【{}】单据任务执行失败,待处理").format(ticket.get_ticket_type_display()), + flow=inner_flow, + ticket=ticket, + type=TodoType.INNER_FAILED, + operators=[ticket.creator], + context=BaseTodoContext(inner_flow.id, ticket.id).to_dict(), + status=TodoStatus.TODO, + ) + print(f"ticket[{ticket.id}] add a failed todo") + time.sleep(1) + + # 待审批的单据要增加一条todo关联 + itsm_tickets = Ticket.objects.prefetch_related("flows").filter(status=TicketStatus.FAILED) + for current in range(0, count, batch): + for ticket in itsm_tickets[current : current + batch]: + itsm_flow = ticket.flows.filter(flow_type=FlowType.BK_ITSM, status=TicketFlowStatus.RUNNING).first() + if not itsm_flow or itsm_flow.todo_of_flow.exists(): + continue + itsm_fields = {f["key"]: f["value"] for f in itsm_flow.details["fields"]} + operators = itsm_fields["approver"].split(",") + Todo.objects.create( + name=_("【{}】单据等待审批").format(ticket.get_ticket_type_display()), + flow=itsm_flow, + ticket=ticket, + type=TodoType.ITSM, + operators=operators, + context=ItsmTodoContext(itsm_flow.id, ticket.id).to_dict(), + ) + print(f"ticket[{ticket.id}] add a itsm todo") + time.sleep(1) diff --git a/dbm-ui/backend/ticket/models/ticket.py b/dbm-ui/backend/ticket/models/ticket.py index c9dac3d7e3..5e7b8d998b 100644 --- a/dbm-ui/backend/ticket/models/ticket.py +++ b/dbm-ui/backend/ticket/models/ticket.py @@ -24,6 +24,7 @@ from backend.db_monitor.exceptions import AutofixException from backend.ticket.constants import ( EXCLUSIVE_TICKET_EXCEL_PATH, + TICKET_RUNNING_STATUS, FlowRetryType, FlowType, TicketFlowStatus, @@ -116,13 +117,13 @@ class Meta: def url(self): return f"{env.BK_SAAS_HOST}/{self.bk_biz_id}/ticket-manage/index?id={self.id}" - def set_terminated(self): - self.status = TicketStatus.TERMINATED + def set_status(self, status): + self.status = status self.save() def get_cost_time(self): # 计算耗时 - if self.status in [TicketStatus.PENDING, TicketStatus.RUNNING]: + if self.status in [TicketStatus.PENDING, *TICKET_RUNNING_STATUS]: return calculate_cost_time(timezone.now(), self.create_at) return calculate_cost_time(self.update_at, self.create_at) @@ -265,11 +266,17 @@ def get_cluster_configs(cls, ticket_type, bk_biz_id, cluster_ids): ] return cluster_configs + @classmethod + def get_config(cls, ticket_type): + """获取平台配置""" + global_cfg = cls.objects.get(bk_biz_id=PLAT_BIZ_ID, ticket_type=ticket_type) + return global_cfg + class ClusterOperateRecordManager(models.Manager): def filter_actives(self, cluster_id, *args, **kwargs): """获得集群正在运行的单据记录""" - return self.filter(cluster_id=cluster_id, ticket__status=TicketFlowStatus.RUNNING, *args, **kwargs) + return self.filter(cluster_id=cluster_id, ticket__status=TicketStatus.RUNNING, *args, **kwargs) def filter_inner_actives(self, cluster_id, *args, **kwargs): """获取集群正在运行的inner flow的单据记录。此时认为集群会在互斥阶段""" @@ -349,7 +356,7 @@ def summary(self): def get_cluster_records_map(cls, cluster_ids: List[int]): """获取集群与操作记录之间的映射关系""" records = cls.objects.select_related("ticket", "flow").filter( - cluster_id__in=cluster_ids, ticket__status=TicketFlowStatus.RUNNING + cluster_id__in=cluster_ids, ticket__status__in=TICKET_RUNNING_STATUS ) cluster_operate_records_map: Dict[int, List] = defaultdict(list) for record in records: @@ -371,7 +378,7 @@ class InstanceOperateRecordManager(models.Manager): def filter_actives(self, instance_id, **kwargs): return self.filter( instance_id=instance_id, - ticket__status__in=[TicketStatus.RUNNING, TicketStatus.PENDING], + ticket__status=TicketStatus.RUNNING, **kwargs, ) @@ -413,9 +420,9 @@ def summary(self): @classmethod def get_instance_records_map(cls, instance_ids: List[Union[int, str]]): - """获取实例与操作记录之间的映射关系""" + """获取实例与操作记录之间的映射关系??????""" records = InstanceOperateRecord.objects.select_related("ticket").filter( - instance_id__in=instance_ids, ticket__status=TicketStatus.RUNNING + instance_id__in=instance_ids, ticket__status__in=TICKET_RUNNING_STATUS ) instance_operator_record_map: Dict[int, List] = defaultdict(list) for record in records: diff --git a/dbm-ui/backend/ticket/models/todo.py b/dbm-ui/backend/ticket/models/todo.py index 26f5e432dd..83bce7101e 100644 --- a/dbm-ui/backend/ticket/models/todo.py +++ b/dbm-ui/backend/ticket/models/todo.py @@ -19,7 +19,15 @@ from backend.bk_web.models import AuditedModel from backend.configuration.constants import BizSettingsEnum from backend.configuration.models import BizSettings -from backend.ticket.constants import FlowMsgStatus, FlowMsgType, TicketFlowStatus, TodoStatus, TodoType +from backend.ticket.constants import ( + TODO_RUNNING_STATUS, + FlowMsgStatus, + FlowMsgType, + TicketFlowStatus, + TicketStatus, + TodoStatus, + TodoType, +) from backend.ticket.tasks.ticket_tasks import send_msg_for_flow logger = logging.getLogger("root") @@ -27,7 +35,7 @@ class TodoManager(models.Manager): def exist_unfinished(self): - return self.filter(status__in=[TodoStatus.TODO, TodoStatus.RUNNING]).exists() + return self.filter(status__in=TODO_RUNNING_STATUS).exists() def create(self, **kwargs): assistance_flag = ( @@ -109,7 +117,7 @@ def set_success(self, username, action): def set_terminated(self, username, action): self.set_status(username, TodoStatus.DONE_FAILED) - self.ticket.set_terminated() + self.ticket.set_status(status=TicketStatus.TERMINATED) self.flow.update_status(TicketFlowStatus.TERMINATED) TodoHistory.objects.create(creator=username, todo=self, action=action) diff --git a/dbm-ui/backend/ticket/serializers.py b/dbm-ui/backend/ticket/serializers.py index 70a9686266..c958cee5d7 100644 --- a/dbm-ui/backend/ticket/serializers.py +++ b/dbm-ui/backend/ticket/serializers.py @@ -23,7 +23,15 @@ from backend.core.encrypt.handlers import AsymmetricHandler from backend.ticket import mock_data from backend.ticket.builders import BuilderFactory -from backend.ticket.constants import CountType, FlowType, TicketStatus, TicketType, TodoStatus +from backend.ticket.constants import ( + TICKET_RUNNING_STATUS, + TODO_RUNNING_STATUS, + FlowType, + TicketFlowStatus, + TicketStatus, + TicketType, + TodoStatus, +) from backend.ticket.flow_manager.manager import TicketFlowManager from backend.ticket.models import Flow, Ticket, Todo from backend.ticket.todos import ActionType @@ -82,14 +90,15 @@ class TicketSerializer(AuditedSerializer, serializers.ModelSerializer): ticket_type = serializers.ChoiceField( help_text=_("单据类型"), choices=TicketType.get_choices(), default=TicketType.MYSQL_SINGLE_APPLY ) - status = serializers.ChoiceField(help_text=_("状态"), choices=TicketStatus.get_choices(), read_only=True) remark = serializers.CharField(help_text=_("备注"), required=False, max_length=LEN_L_LONG, allow_blank=True) # 默认使用MySQL序列化器,不同单据类型不同字段序列化 group = serializers.CharField(help_text=_("单据分组类型"), required=False) details = TicketDetailsSerializer(help_text=_("单据详情")) # 额外补充展示字段 - ticket_type_display = serializers.SerializerMethodField(help_text=_("单据类型名称")) + todo_operators = serializers.SerializerMethodField(help_text=_("处理人列表")) + status = serializers.SerializerMethodField(help_text=_("状态"), read_only=True) status_display = serializers.SerializerMethodField(help_text=_("状态名称")) + ticket_type_display = serializers.SerializerMethodField(help_text=_("单据类型名称")) cost_time = serializers.SerializerMethodField(help_text=_("耗时")) bk_biz_name = serializers.SerializerMethodField(help_text=_("业务名")) db_app_abbr = serializers.SerializerMethodField(help_text=_("业务英文缩写")) @@ -111,14 +120,24 @@ def validate_ticket_type(self, value): raise serializers.ValidationError(_("不允许提交敏感单据类型{}").format(value)) return value + def get_todo_operators(self, obj): + # 任取一个运行中的todo,获取operators即可 + obj.running_todos = [todo for todo in obj.todo_of_ticket.all() if todo.status == TodoStatus.TODO] + return obj.running_todos[0].operators if obj.running_todos else [] + + def get_status(self, obj): + if obj.status == TicketStatus.RUNNING and obj.running_todos: + obj.status = TicketStatus.INNER_TODO + return obj.status + def get_ticket_type_display(self, obj): return obj.get_ticket_type_display() def get_status_display(self, obj): - return obj.get_status_display() + return TicketStatus.get_choice_label(obj.status) def get_cost_time(self, obj): - if obj.status in [TicketStatus.PENDING, TicketStatus.RUNNING]: + if obj.status in [TicketStatus.PENDING, *TICKET_RUNNING_STATUS]: return calculate_cost_time(timezone.now(), obj.create_at) return calculate_cost_time(obj.update_at, obj.create_at) @@ -164,7 +183,7 @@ def get_end_time(self, obj): def get_cost_time(self, obj): start_time = strptime(self.get_start_time(obj)) end_time = strptime(self.get_end_time(obj)) - if self.get_status(obj) in [TicketStatus.PENDING, TicketStatus.RUNNING]: + if self.get_status(obj) in [TicketFlowStatus.PENDING, TicketFlowStatus.RUNNING]: return calculate_cost_time(timezone.now(), start_time) return calculate_cost_time(end_time, start_time) @@ -215,7 +234,7 @@ class TodoSerializer(serializers.ModelSerializer): cost_time = serializers.SerializerMethodField(help_text=_("耗时")) def get_cost_time(self, obj): - if obj.status in [TodoStatus.TODO, TodoStatus.RUNNING]: + if obj.status in TODO_RUNNING_STATUS: return calculate_cost_time(timezone.now(), obj.create_at) return calculate_cost_time(obj.done_at, obj.create_at) @@ -260,30 +279,38 @@ class RevokeFlowSLZ(serializers.Serializer): flow_id = serializers.IntegerField(help_text=_("单据流程的ID")) +class RevokeTicketSLZ(serializers.Serializer): + ticket_ids = serializers.ListField(help_text=_("终止单据ID"), child=serializers.IntegerField()) + + class GetTodosSLZ(serializers.Serializer): todo_status = serializers.ChoiceField( help_text=_("状态"), choices=TodoStatus.get_choices(), required=False, allow_blank=True ) -class CountTicketSLZ(serializers.Serializer): - count_type = serializers.ChoiceField(help_text=_("类型"), choices=CountType.get_choices(), default=CountType.MY_TODO) - - -class ClusterModifyOpSerializer(serializers.Serializer): - cluster_id = serializers.IntegerField(help_text=_("集群ID")) +class OpRecordSerializer(serializers.Serializer): start_time = serializers.DateTimeField(help_text=_("查询起始时间"), required=False) end_time = serializers.DateTimeField(help_text=_("查询终止时间"), required=False) op_type = serializers.ChoiceField(help_text=_("操作类型"), choices=TicketType.get_choices(), required=False) op_status = serializers.ChoiceField(help_text=_("操作状态"), choices=TicketStatus.get_choices(), required=False) + def to_representation(self, instance): + return { + "create_at": instance.create_at, + "op_type": instance.ticket.ticket_type, + "op_status": instance.ticket.status, + "ticket_id": instance.ticket.id, + "creator": instance.creator, + } + -class InstanceModifyOpSerializer(serializers.Serializer): +class ClusterModifyOpSerializer(OpRecordSerializer): + cluster_id = serializers.IntegerField(help_text=_("集群ID")) + + +class InstanceModifyOpSerializer(OpRecordSerializer): instance_id = serializers.IntegerField(help_text=_("实例ID")) - start_time = serializers.DateTimeField(help_text=_("查询起始时间"), required=False) - end_time = serializers.DateTimeField(help_text=_("查询终止时间"), required=False) - op_type = serializers.ChoiceField(help_text=_("操作类型"), choices=TicketType.get_choices(), required=False) - op_status = serializers.ChoiceField(help_text=_("操作状态"), choices=TicketStatus.get_choices(), required=False) class QueryTicketFlowDescribeSerializer(serializers.Serializer): @@ -383,3 +410,15 @@ def validate(self, attrs): if todo_id not in existing_todo_ids: raise serializers.ValidationError(_("待办id{}不存在".format(attrs["todo_id"]))) return attrs + + +class BatchTicketOperateSerializer(serializers.Serializer): + action = serializers.ChoiceField( + choices=[ActionType.APPROVE.value, ActionType.TERMINATE.value], help_text=_("统一动作") + ) + ticket_ids = serializers.ListField(help_text=_("单据ID列表"), child=serializers.IntegerField()) + params = serializers.JSONField(help_text=_("动作参数"), required=False, default={}) + + +class GetInnerFlowSerializer(serializers.Serializer): + ticket_ids = serializers.CharField(help_text=_("单据ID(逗号分隔)")) diff --git a/dbm-ui/backend/ticket/tasks/ticket_tasks.py b/dbm-ui/backend/ticket/tasks/ticket_tasks.py index 9025ffaf21..4f45f0425a 100644 --- a/dbm-ui/backend/ticket/tasks/ticket_tasks.py +++ b/dbm-ui/backend/ticket/tasks/ticket_tasks.py @@ -45,7 +45,6 @@ TodoType, ) from backend.ticket.exceptions import TicketTaskTriggerException -from backend.ticket.flow_manager.inner import InnerFlow from backend.ticket.models.ticket import Flow, Ticket, TicketFlowsConfig from backend.utils.time import date2str, datetime2str @@ -69,6 +68,8 @@ def run_next_flow(self) -> None: @classmethod def retry_exclusive_inner_flow(cls) -> None: """重试互斥错误的inner flow""" + from backend.ticket.flow_manager.inner import InnerFlow + to_retry_flows = Flow.objects.filter(err_code=FlowErrCode.AUTO_EXCLUSIVE_ERROR) if not to_retry_flows: return diff --git a/dbm-ui/backend/ticket/todos/__init__.py b/dbm-ui/backend/ticket/todos/__init__.py index bafffa53a9..35412d6cc3 100644 --- a/dbm-ui/backend/ticket/todos/__init__.py +++ b/dbm-ui/backend/ticket/todos/__init__.py @@ -14,8 +14,12 @@ from dataclasses import asdict, dataclass from typing import Callable +from blueapps.account.models import User from django.utils.translation import ugettext_lazy as _ +from backend.constants import DEFAULT_SYSTEM_USER +from backend.ticket.constants import TODO_RUNNING_STATUS +from backend.ticket.exceptions import TodoWrongOperatorException from backend.ticket.models import Todo from blue_krill.data_types.enum import EnumField, StructuredEnum @@ -37,8 +41,32 @@ def __init__(self, todo: Todo): def name(cls): return cls.__name__ + def update_context(self, params): + # 更新上下文信息 + if "remark" in params: + self.todo.context.update(remark=params["remark"]) + self.todo.save(update_fields=["context"]) + def process(self, username, action, params): - """处理操作""" + # 当状态已经被确认,则不允许重复操作 + if self.todo.status not in TODO_RUNNING_STATUS: + raise TodoWrongOperatorException(_("当前代办操作已经处理,不能重复处理!")) + + # 允许系统内置用户确认 + if username == DEFAULT_SYSTEM_USER: + self._process(username, action, params) + return + # 允许超级用户和操作人确认 + is_superuser = User.objects.get(username=username).is_superuser + if not is_superuser and username not in self.todo.operators: + raise TodoWrongOperatorException(_("{}不在处理人: {}中,无法处理").format(username, self.todo.operators)) + + # 执行确认操作 + self._process(username, action, params) + self.update_context(params) + + def _process(self, username, action, params): + """处理操作的具体实现""" raise NotImplementedError @@ -98,7 +126,6 @@ class ActionType(str, StructuredEnum): APPROVE = EnumField("APPROVE", _("确认执行")) TERMINATE = EnumField("TERMINATE", _("终止单据")) - RESOURCE_REAPPLY = EnumField("RESOURCE_REAPPLY", _("资源重新申请")) @dataclass diff --git a/dbm-ui/backend/ticket/todos/itsm_todo.py b/dbm-ui/backend/ticket/todos/itsm_todo.py new file mode 100644 index 0000000000..3da2ac613d --- /dev/null +++ b/dbm-ui/backend/ticket/todos/itsm_todo.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-DB管理系统(BlueKing-BK-DBM) available. +Copyright (C) 2017-2023 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 https://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +import logging +from dataclasses import dataclass + +from backend.constants import DEFAULT_SYSTEM_USER +from backend.ticket import todos +from backend.ticket.constants import OperateNodeActionType, TodoType +from backend.ticket.todos import ActionType, BaseTodoContext + +logger = logging.getLogger("root") + + +@dataclass +class ItsmTodoContext(BaseTodoContext): + pass + + +@todos.TodoActorFactory.register(TodoType.ITSM) +class ItsmTodo(todos.TodoActor): + """来自审批中的待办""" + + def process(self, username, action, params): + # itsm的todo允许本人操作 + if username == self.todo.ticket.creator: + self._process(username, action, params) + super().process(username, action, params) + + def _process(self, username, action, params): + from backend.ticket.handler import TicketHandler + + ticket_id = self.context.get("ticket_id") + own = self.todo.ticket.creator + message = params.get("remark", "") + + def approve_itsm_ticket(itsm_action, is_approved): + sn = TicketHandler.approve_itsm_ticket( + ticket_id, + action=itsm_action, + operator=username, + is_approved=is_approved, + action_message=message, + ) + return sn + + # 系统终止,认为是关单(调用itsm接口要用admin发起) + if action == ActionType.TERMINATE and username == DEFAULT_SYSTEM_USER: + username = "admin" + approve_itsm_ticket(OperateNodeActionType.TERMINATE, is_approved=False) + self.todo.set_terminated(username, action) + # 审批人终止,认为是拒单 + elif action == ActionType.TERMINATE and username != own: + approve_itsm_ticket(OperateNodeActionType.TRANSITION, is_approved=False) + self.todo.set_terminated(username, action) + # 自己终止,认为是撤单 + elif action == ActionType.TERMINATE and username == own: + approve_itsm_ticket(OperateNodeActionType.WITHDRAW, is_approved=False) + self.todo.set_terminated(username, action) + # 只允许审批人通过 + elif action == ActionType.APPROVE and username != own: + approve_itsm_ticket(OperateNodeActionType.TRANSITION, is_approved=True) + self.todo.set_success(username, action) diff --git a/dbm-ui/backend/ticket/todos/pause_todo.py b/dbm-ui/backend/ticket/todos/pause_todo.py index 10e8c412a8..9b44d8275a 100644 --- a/dbm-ui/backend/ticket/todos/pause_todo.py +++ b/dbm-ui/backend/ticket/todos/pause_todo.py @@ -10,13 +10,9 @@ """ from dataclasses import dataclass -from django.utils.translation import gettext as _ - -from backend.constants import DEFAULT_SYSTEM_USER from backend.db_meta.models.sqlserver_dts import DtsStatus, SqlserverDtsInfo from backend.ticket import todos from backend.ticket.constants import TicketFlowStatus, TicketType, TodoType -from backend.ticket.exceptions import TodoWrongOperatorException from backend.ticket.flow_manager import manager from backend.ticket.flow_manager.manager import TicketFlowManager from backend.ticket.todos import ActionType, BaseTodoContext @@ -37,11 +33,8 @@ class ResourceReplenishTodoContext(BaseTodoContext): class PauseTodo(todos.TodoActor): """来自主流程的待办""" - def process(self, username, action, params): + def _process(self, username, action, params): """确认/终止""" - if username not in self.todo.operators and username != DEFAULT_SYSTEM_USER: - raise TodoWrongOperatorException(_("{}不在处理人: {}中,无法处理").format(username, self.todo.operators)) - if action == ActionType.TERMINATE: self.todo.set_terminated(username, action) return @@ -63,11 +56,8 @@ def process(self, username, action, params): class ResourceReplenishTodo(todos.TodoActor): """资源补货的代办""" - def process(self, username, action, params): + def _process(self, username, action, params): """确认/终止""" - if username not in self.todo.operators and username != DEFAULT_SYSTEM_USER: - raise TodoWrongOperatorException(_("{}不在处理人: {}中,无法处理").format(username, self.todo.operators)) - # 终止单据 if action == ActionType.TERMINATE: self.todo.set_terminated(username, action) diff --git a/dbm-ui/backend/ticket/todos/pipeline_todo.py b/dbm-ui/backend/ticket/todos/pipeline_todo.py index 6139717998..96c0da8bf9 100644 --- a/dbm-ui/backend/ticket/todos/pipeline_todo.py +++ b/dbm-ui/backend/ticket/todos/pipeline_todo.py @@ -11,13 +11,11 @@ import logging from dataclasses import dataclass -from django.utils.translation import gettext as _ +from django.utils.translation import ugettext as _ -from backend.constants import DEFAULT_SYSTEM_USER from backend.flow.engine.bamboo.engine import BambooEngine from backend.ticket import todos from backend.ticket.constants import TodoType -from backend.ticket.exceptions import TodoWrongOperatorException from backend.ticket.models import TodoHistory from backend.ticket.todos import ActionType, BaseTodoContext @@ -34,11 +32,8 @@ class PipelineTodoContext(BaseTodoContext): class PipelineTodo(todos.TodoActor): """来自自动化流程中的待办""" - def process(self, username, action, params): + def _process(self, username, action, params): """确认/终止""" - if username not in self.todo.operators and username != DEFAULT_SYSTEM_USER: - raise TodoWrongOperatorException(_("{}不在处理人: {}中,无法处理").format(username, self.todo.operators)) - # 从todo的上下文获取pipeline节点信息 root_id, node_id = self.context.get("root_id"), self.context.get("node_id") engine = BambooEngine(root_id=root_id) @@ -66,3 +61,18 @@ def process(self, username, action, params): raise Exception(",".join(res.exc.args)) self.todo.set_success(username, action) + + @classmethod + def create(cls, ticket, flow, root_id, node_id): + from backend.ticket.models import Todo + + # 创建一条代办 + Todo.objects.create( + name=_("【{}】流程待确认,是否继续?").format(ticket.get_ticket_type_display()), + flow=flow, + ticket=ticket, + type=TodoType.INNER_APPROVE, + # todo: 待办人暂定为提单人 + operators=[ticket.creator], + context=PipelineTodoContext(flow.id, ticket.id, root_id, node_id).to_dict(), + ) diff --git a/dbm-ui/backend/ticket/views.py b/dbm-ui/backend/ticket/views.py index 9b66b6e7a4..b7d2f631d7 100644 --- a/dbm-ui/backend/ticket/views.py +++ b/dbm-ui/backend/ticket/views.py @@ -9,7 +9,9 @@ specific language governing permissions and limitations under the License. """ import operator +from collections import Counter from functools import reduce +from typing import Dict, List from django.db import transaction from django.db.models import Q @@ -22,7 +24,9 @@ from backend import env from backend.bk_web import viewsets +from backend.bk_web.pagination import AuditedLimitOffsetPagination from backend.bk_web.swagger import PaginatedResponseSwaggerAutoSchema, common_swagger_auto_schema +from backend.configuration.constants import DBType from backend.configuration.models import DBAdministrator from backend.db_services.ipchooser.query.resource import ResourceQueryHelper from backend.iam_app.dataclass import ResourceEnum @@ -38,34 +42,36 @@ from backend.ticket.builders import BuilderFactory from backend.ticket.builders.common.base import InfluxdbTicketFlowBuilderPatchMixin, fetch_cluster_ids from backend.ticket.constants import ( - TODO_DONE_STATUS, + FLOW_NOT_EXECUTE_STATUS, + TICKET_RUNNING_STATUS, + TICKET_TODO_STATUS, + TODO_RUNNING_STATUS, CountType, - OperateNodeActionType, - TicketStatus, + FlowType, TicketType, - TodoStatus, + TodoType, ) from backend.ticket.contexts import TicketContext from backend.ticket.exceptions import TicketDuplicationException -from backend.ticket.filters import TicketListFilter +from backend.ticket.filters import ClusterOpRecordListFilter, InstanceOpRecordListFilter, TicketListFilter from backend.ticket.flow_manager.manager import TicketFlowManager from backend.ticket.handler import TicketHandler -from backend.ticket.models import ClusterOperateRecord, InstanceOperateRecord, Ticket, TicketFlowsConfig, Todo +from backend.ticket.models import ClusterOperateRecord, Flow, InstanceOperateRecord, Ticket, TicketFlowsConfig from backend.ticket.serializers import ( - BatchApprovalSerializer, + BatchTicketOperateSerializer, BatchTodoOperateSerializer, ClusterModifyOpSerializer, - CountTicketSLZ, CreateTicketFlowConfigSerializer, DeleteTicketFlowConfigSerializer, FastCreateCloudComponentSerializer, + GetInnerFlowSerializer, GetNodesSLZ, - GetTodosSLZ, InstanceModifyOpSerializer, ListTicketStatusSerializer, QueryTicketFlowDescribeSerializer, RetryFlowSLZ, RevokeFlowSLZ, + RevokeTicketSLZ, SensitiveTicketSerializer, TicketFlowDescribeSerializer, TicketFlowSerializer, @@ -77,7 +83,6 @@ UpdateTicketFlowConfigSerializer, ) from backend.ticket.todos import TodoActorFactory -from backend.utils.batch_request import request_multi_thread TICKET_TAG = "ticket" @@ -87,9 +92,10 @@ class TicketViewSet(viewsets.AuditedModelViewSet): 单据视图 """ - queryset = Ticket.objects.all() + queryset = Ticket.objects.all().prefetch_related("todo_of_ticket") serializer_class = TicketSerializer filter_class = TicketListFilter + pagination_class = AuditedLimitOffsetPagination def _get_custom_permissions(self): # 创建单据,关联单据类型的动作 @@ -114,17 +120,18 @@ def _get_custom_permissions(self): elif self.action in ["update_ticket_flow_config", "create_ticket_flow_config", "delete_ticket_flow_config"]: return ticket_flows_config_permission(self.action, self.request) # 对于处理todo的接口,可以不用鉴权,todo本身会判断是否是确认人 - elif self.action in ["process_todo", "batch_process_todo"]: + elif self.action in ["process_todo", "batch_process_todo", "batch_process_ticket"]: return [] # 其他非敏感GET接口,不鉴权 elif self.action in [ "list", "flow_types", "get_nodes", - "get_todo_tickets", "get_tickets_count", "query_ticket_flow_describe", "list_ticket_status", + "get_inner_flow_infos", + "revoke_ticket", ]: return [] # 回调和处理无需鉴权 @@ -138,6 +145,19 @@ def _get_login_exempt_view_func(cls): # 需要豁免的接口方法与名字 return {"post": [cls.callback.__name__], "put": [], "get": [], "delete": []} + @classmethod + def _get_self_manage_tickets(cls, user): + # 超级管理员返回所有单据 + if user.username in env.ADMIN_USERS or user.is_superuser: + return Ticket.objects.all() + # 获取user管理的单据合集 + manage_filters = [ + Q(group=manage.db_type) & Q(bk_biz_id=manage.bk_biz_id) if manage.bk_biz_id else Q(group=manage.db_type) + for manage in DBAdministrator.objects.filter(users__contains=user.username) + ] + ticket_filter = Q(creator=user.username) | reduce(operator.or_, manage_filters or [Q()]) + return Ticket.objects.filter(ticket_filter) + def get_queryset(self): """ 单据queryset规则--针对list: @@ -151,17 +171,11 @@ def get_queryset(self): self_manage = int(self.request.query_params["self_manage"]) # 只返回自己创建的单据 if self_manage == 0: - return Ticket.objects.filter(creator=username) - # 超级管理员返回所有单据 - if username in env.ADMIN_USERS or self.request.user.is_superuser: - return Ticket.objects.all() + qs = Ticket.objects.filter(creator=username) # 返回自己管理的组件单据 - manage_filters = [ - Q(group=manage.db_type) & Q(bk_biz_id=manage.bk_biz_id) if manage.bk_biz_id else Q(group=manage.db_type) - for manage in DBAdministrator.objects.filter(users__contains=username) - ] - ticket_filter = Q(creator=username) | reduce(operator.or_, manage_filters or [Q()]) - return Ticket.objects.filter(ticket_filter) + else: + qs = self._get_self_manage_tickets(self.request.user) + return qs def get_serializer_context(self): context = super(TicketViewSet, self).get_serializer_context() @@ -171,28 +185,28 @@ def get_serializer_context(self): context["ticket_ctx"] = TicketContext() return context - def _verify_duplicate_ticket(self, ticket_type, details, user): - """校验是否重复提交""" + @staticmethod + def _verify_influxdb_duplicate_ticket(ticket_type, details, user, active_tickets): + current_instances = InfluxdbTicketFlowBuilderPatchMixin.get_instances(ticket_type, details) + for ticket in active_tickets: + active_instances = ticket.details["instances"] + duplicate_ids = list(set(active_instances).intersection(current_instances)) + if duplicate_ids: + raise TicketDuplicationException( + context=_("实例{}已存在相同类型的单据[{}]正在运行,请确认是否重复提交").format(duplicate_ids, ticket.id), + data={"duplicate_instance_ids": duplicate_ids, "duplicate_ticket_id": ticket.id}, + ) - active_tickets = self.get_queryset().filter(ticket_type=ticket_type, status=TicketStatus.RUNNING, creator=user) + def verify_duplicate_ticket(self, ticket_type, details, user): + """校验是否重复提交""" + active_tickets = self.get_queryset().filter( + ticket_type=ticket_type, status__in=TICKET_RUNNING_STATUS, creator=user + ) # influxdb 相关操作单独适配,这里暂时没有找到更好的写法,唯一的改进就是创建单据时,会提前提取出对比内容,比如instances - if ticket_type in [ - TicketType.INFLUXDB_ENABLE, - TicketType.INFLUXDB_DISABLE, - TicketType.INFLUXDB_REBOOT, - TicketType.INFLUXDB_DESTROY, - TicketType.INFLUXDB_REPLACE, - ]: - current_instances = InfluxdbTicketFlowBuilderPatchMixin.get_instances(ticket_type, details) - for ticket in active_tickets: - active_instances = ticket.details["instances"] - duplicate_ids = list(set(active_instances).intersection(current_instances)) - if duplicate_ids: - raise TicketDuplicationException( - context=_("实例{}已存在相同类型的单据[{}]正在运行,请确认是否重复提交").format(duplicate_ids, ticket.id), - data={"duplicate_instance_ids": duplicate_ids, "duplicate_ticket_id": ticket.id}, - ) + # TODO: 后续这段逻辑待删除,influxdb已经弃用 + if ticket_type in TicketType.get_ticket_type_by_db(DBType.InfluxDB): + self._verify_influxdb_duplicate_ticket(ticket_type, details, user, active_tickets) return cluster_ids = fetch_cluster_ids(details=details) @@ -210,7 +224,7 @@ def perform_create(self, serializer): ignore_duplication = self.request.data.get("ignore_duplication") or False # 如果不允许忽略重复提交,则进行校验 if not ignore_duplication: - self._verify_duplicate_ticket(ticket_type, self.request.data["details"], self.request.user.username) + self.verify_duplicate_ticket(ticket_type, self.request.data["details"], self.request.user.username) with transaction.atomic(): # 设置单据类别 TODO: 这里会请求两次数据库,是否考虑group参数让前端传递 @@ -338,6 +352,17 @@ def revoke_flow(self, request, pk): TicketHandler.operate_flow(ticket_id=pk, flow_id=validated_data["flow_id"], func="revoke", operator=user) return Response() + @common_swagger_auto_schema( + operation_summary=_("单据终止"), + request_body=RevokeTicketSLZ(), + tags=[TICKET_TAG], + ) + @action(methods=["POST"], detail=False, serializer_class=RevokeTicketSLZ) + def revoke_ticket(self, request, *args, **kwargs): + ticket_ids = self.params_validate(self.get_serializer_class())["ticket_ids"] + TicketHandler.revoke_ticket(ticket_ids, operator=request.user.username) + return Response() + @swagger_auto_schema( operation_summary=_("获取单据类型列表"), query_serializer=TicketTypeSLZ(), @@ -381,46 +406,6 @@ def get_nodes(self, request, *args, **kwargs): return Response(hosts) - @common_swagger_auto_schema( - operation_summary=_("待办单据列表"), - query_serializer=GetTodosSLZ(), - tags=[TICKET_TAG], - ) - @Permission.decorator_permission_field( - id_field=lambda d: d["id"], - data_field=lambda d: d["results"], - actions=[ActionEnum.TICKET_VIEW], - resource_meta=ResourceEnum.TICKET, - ) - @action(methods=["GET"], detail=False, serializer_class=GetTodosSLZ) - def get_todo_tickets(self, request, *args, **kwargs): - """待办视图单据列表""" - - # 获取我的待办 - validated_data = self.params_validate(self.get_serializer_class()) - todo_status = validated_data.get("todo_status") - my_todos = Todo.objects.filter(operators__contains=request.user.username) - - # 状态筛选:已处理/未处理 - if todo_status in TODO_DONE_STATUS: - my_todos = my_todos.filter(status__in=TODO_DONE_STATUS) - elif todo_status: - my_todos = my_todos.filter(status=todo_status) - - # 复用全局过滤器 - tickets = self.filter_queryset(self.get_queryset()) - - # 关联查询单据 - my_todo_tickets = tickets.filter(id__in=my_todos.values_list("ticket_id")) - context = self.get_serializer_context() - - # 分页处理 - page = self.paginate_queryset(my_todo_tickets) - serializer = TicketSerializer(page, many=True, context=context) - resp = self.get_paginated_response(serializer.data) - resp.data["results"] = TicketHandler.add_related_object(resp.data["results"]) - return resp - @swagger_auto_schema( operation_summary=_("待办处理"), request_body=TodoOperateSerializer(), @@ -444,96 +429,74 @@ def process_todo(self, request, *args, **kwargs): @common_swagger_auto_schema( operation_summary=_("待办单据数"), - query_serializer=CountTicketSLZ(), tags=[TICKET_TAG], ) - @action(methods=["GET"], detail=False, serializer_class=CountTicketSLZ) + @action(methods=["GET"], detail=False, filter_class=None, pagination_class=None) def get_tickets_count(self, request, *args, **kwargs): - validated_data = self.params_validate(self.get_serializer_class()) - count_type = validated_data.get("count_type") - - # 待办单数量 - if count_type == CountType.MY_TODO: - my_todos = Todo.objects.filter(status=TodoStatus.TODO, operators__contains=request.user.username) - tickets = self.filter_queryset(self.get_queryset()) - my_tickets = tickets.filter(id__in=my_todos.values_list("ticket_id")) - else: - # 申请单数量 - my_tickets = Ticket.objects.filter( - creator=request.user.username, status__in=[TicketStatus.RUNNING, TicketStatus.PENDING] + """ + 获取单据的数量,目前需要获取 + - 我的申请 + - (代办)待我审批、待我确认,待我补货 + - 我的已办 + - 我负责的业务 + """ + user = request.user.username + tickets = self._get_self_manage_tickets(request.user) + count_map = {count_type: 0 for count_type in CountType.get_values()} + + # 我负责的业务 + count_map[CountType.SELF_MANAGE] = tickets.count() + # 我的申请 + count_map[CountType.MY_APPROVE] = tickets.filter(creator=user).count() + # 我的代办 + todo_status = ( + tickets.filter( + status__in=TICKET_TODO_STATUS, + todo_of_ticket__operators__contains=user, + todo_of_ticket__status__in=TODO_RUNNING_STATUS, ) + .distinct() + .values_list("status", flat=True) + ) + for sts, count in Counter(todo_status).items(): + sts = CountType.INNER_TODO.value if sts == "RUNNING" else sts + count_map[sts] = count + # 我的已办 + count_map[CountType.DONE] = tickets.filter(todo_of_ticket__done_by=user).count() - return Response(my_tickets.count()) + return Response(count_map) @common_swagger_auto_schema( operation_summary=_("查询集群变更单据事件"), - query_serializer=ClusterModifyOpSerializer(), tags=[TICKET_TAG], ) - @action(methods=["GET"], detail=False, serializer_class=ClusterModifyOpSerializer) + @action( + methods=["GET"], + detail=False, + serializer_class=ClusterModifyOpSerializer, + queryset=ClusterOperateRecord.objects.select_related("ticket").order_by("-create_at"), + filter_class=ClusterOpRecordListFilter, + ) def get_cluster_operate_records(self, request, *args, **kwargs): - validated_data = self.params_validate(self.get_serializer_class()) - op_filters = Q(cluster_id=validated_data["cluster_id"]) - if validated_data.get("start_time"): - op_filters &= Q(create_at__gte=validated_data.get("start_time")) - - if validated_data.get("end_time"): - op_filters &= Q(create_at__lte=validated_data.get("end_time")) - - if validated_data.get("op_type"): - op_filters &= Q(ticket__ticket_type=validated_data.get("op_type")) - - if validated_data.get("op_status"): - op_filters &= Q(ticket__status=validated_data.get("op_status")) - - op_records = ClusterOperateRecord.objects.select_related("ticket").filter(op_filters).order_by("-create_at") - op_records_info = [ - { - "create_at": record.create_at, - "op_type": TicketType.get_choice_label(record.ticket.ticket_type), - "op_status": record.ticket.status, - "ticket_id": record.ticket.id, - "creator": record.creator, - } - for record in op_records - ] - op_records_page = self.paginate_queryset(op_records_info) - return self.get_paginated_response(op_records_page) + op_records_page_qs = self.paginate_queryset(self.filter_queryset(self.queryset)) + op_records_page_data = self.serializer_class(op_records_page_qs, many=True).data + return self.get_paginated_response(data=op_records_page_data) @common_swagger_auto_schema( operation_summary=_("查询集群实例变更单据事件"), - query_serializer=InstanceModifyOpSerializer(), tags=[TICKET_TAG], ) - @action(methods=["GET"], detail=False, serializer_class=InstanceModifyOpSerializer) + @action( + methods=["GET"], + detail=False, + serializer_class=InstanceModifyOpSerializer, + queryset=InstanceOperateRecord.objects.select_related("ticket").order_by("-create_at"), + filter_class=InstanceOpRecordListFilter, + ) def get_instance_operate_records(self, request, *args, **kwargs): - validated_data = self.params_validate(self.get_serializer_class()) - op_filters = Q(instance_id=validated_data["instance_id"]) - if validated_data.get("start_time"): - op_filters &= Q(create_at__gte=validated_data.get("start_time")) - - if validated_data.get("end_time"): - op_filters &= Q(create_at__lte=validated_data.get("end_time")) - - if validated_data.get("op_type"): - op_filters &= Q(ticket__ticket_type=validated_data.get("op_type")) - - if validated_data.get("op_status"): - op_filters &= Q(ticket__status=validated_data.get("op_status")) - - op_records = InstanceOperateRecord.objects.select_related("ticket").filter(op_filters).order_by("-create_at") - op_records_info = [ - { - "create_at": record.create_at, - "op_type": TicketType.get_choice_label(record.ticket.ticket_type), - "op_status": record.ticket.status, - "ticket_id": record.ticket.id, - "creator": record.creator, - } - for record in op_records - ] - op_records_page = self.paginate_queryset(op_records_info) - return self.get_paginated_response(op_records_page) + op_records_page_qs = self.paginate_queryset(self.filter_queryset(self.queryset)) + op_records_page_data = self.serializer_class(op_records_page_qs, many=True).data + return self.get_paginated_response(data=op_records_page_data) @swagger_auto_schema( operation_summary=_("查询可编辑单据流程描述"), @@ -610,49 +573,68 @@ def fast_create_cloud_component(self, request, *args, **kwargs): TicketHandler.fast_create_cloud_component_method(bk_biz_id, bk_cloud_id, ips, request.user.username) return Response() - @common_swagger_auto_schema( - operation_summary=_("批量审批"), - request_body=BatchApprovalSerializer(), + @swagger_auto_schema( + operation_summary=_("批量待办处理"), + request_body=BatchTodoOperateSerializer(), + responses={status.HTTP_200_OK: TodoSerializer(many=True)}, tags=[TICKET_TAG], ) - @action(methods=["POST"], detail=False, serializer_class=BatchApprovalSerializer) - def batch_approval(self, request, *args, **kwargs): + @action(methods=["POST"], detail=False, serializer_class=BatchTodoOperateSerializer) + def batch_process_todo(self, request, *args, **kwargs): """ - sns: 单号集合 - is_approved: 是否审批通过 + 批量处理待办: 返回处理后的待办列表 """ data = self.params_validate(self.get_serializer_class()) - ticket_ids, is_approved = data["ticket_ids"], data["is_approved"] - user, itsm_action = request.user.username, OperateNodeActionType.TRANSITION - params_list = [ - {"ticket_id": ticket, "action": itsm_action, "is_approved": is_approved, "operator": user} - for ticket in ticket_ids - ] - request_multi_thread(TicketHandler.approve_itsm_ticket, params_list) - return Response() + user = request.user.username + return Response(TicketHandler.batch_process_todo(user=user, **data)) @swagger_auto_schema( - operation_summary=_("批量待办处理"), - request_body=BatchTodoOperateSerializer(), + operation_summary=_("批量单据待办处理"), + request_body=BatchTicketOperateSerializer(), responses={status.HTTP_200_OK: TodoSerializer(many=True)}, tags=[TICKET_TAG], ) - @action(methods=["POST"], detail=False, serializer_class=BatchTodoOperateSerializer) - def batch_process_todo(self, request, *args, **kwargs): + @action(methods=["POST"], detail=False, serializer_class=BatchTicketOperateSerializer) + def batch_process_ticket(self, request, *args, **kwargs): """ - 批量处理待办: 返回处理后的待办列表 + 批量处理单据的待办,处理单据的第一个todo + 根据todo的类型可以触发不同的factor函数 """ - validated_data = self.params_validate(self.get_serializer_class()) - act = validated_data["action"] - - # 批量处理待办操作 - results = [] - for operation in validated_data["operations"]: - todo_id = operation["todo_id"] - params = operation["params"] - todo = Todo.objects.get(id=todo_id) - TodoActorFactory.actor(todo).process(request.user.username, act, params) - results.append(todo) - - # 使用 TodoSerializer 序列化响应数据 - return Response(TodoSerializer(results, many=True).data) + data = self.params_validate(self.get_serializer_class()) + user = request.user.username + + tickets = Ticket.objects.prefetch_related("todo_of_ticket").filter(id__in=data["ticket_ids"]) + # 找到单据第一个代办(排除INNER_APPROVE,这是任务流程的人工确认节点产生的,不允许在单据维度操作) + running_todos = [ + ticket.todo_of_ticket.exclude(type=TodoType.INNER_APPROVE).filter(status__in=TODO_RUNNING_STATUS).first() + for ticket in tickets + ] + operations = [{"todo_id": todo.id, "params": data["params"]} for todo in running_todos if todo] + + return Response(TicketHandler.batch_process_todo(user=user, action=data["action"], operations=operations)) + + @swagger_auto_schema( + operation_summary=_("获取单据关联任务流程信息"), + query_serializer=GetInnerFlowSerializer(), + tags=[TICKET_TAG], + ) + @action(methods=["GET"], detail=False, serializer_class=GetInnerFlowSerializer, filter_class=None) + def get_inner_flow_infos(self, request, *args, **kwargs): + """ + 获取单据关联后台任务的信息 + """ + ticket_ids = self.params_validate(self.get_serializer_class())["ticket_ids"].split(",") + inner_flows = Flow.objects.filter(flow_type=FlowType.INNER_FLOW, ticket_id__in=ticket_ids).values( + "ticket_id", "flow_obj_id", "flow_alias", "err_msg", "status" + ) + ticket__inner_flow_map: Dict[int, List] = {int(t_id): [] for t_id in ticket_ids} + for flow in inner_flows: + # 快速判断流程树是否存在:有root_id,不包含error_msg,并且流程状态是执行过 + has_tree = bool( + flow["flow_obj_id"] and not flow["err_msg"] and flow["status"] not in FLOW_NOT_EXECUTE_STATUS + ) + flow_info = {"flow_id": flow["flow_obj_id"], "flow_alias": flow["flow_alias"], "pipeline_tree": has_tree} + # 默认只将产生过pipeline tree的返回给前端 + if has_tree: + ticket__inner_flow_map[flow["ticket_id"]].append(flow_info) + return Response(ticket__inner_flow_map)